#:package Spectre.Console@0.50.0 #:package NuGet.Versioning@6.14.0 using System.Text.Json; using System.Text.Json.Serialization; using Spectre.Console; using NuGet.Versioning; using System.Diagnostics; // Business logic class for .NET installation using var installer = new DotNetInstaller(); // Helper function to format time remaining until EOL string FormatTimeRemaining(DateOnly eolDate) { var timeSpan = eolDate.ToDateTime(TimeOnly.MinValue) - DateTime.Now; if (timeSpan.TotalDays < 0) return "[red]Expired[/]"; if (timeSpan.TotalDays < 30) return $"[red]{(int)timeSpan.TotalDays} days[/]"; if (timeSpan.TotalDays < 365) return $"[yellow]{(int)(timeSpan.TotalDays / 30)} months[/]"; return $"[green]{(int)(timeSpan.TotalDays / 365)} years[/]"; } // Get installation path from user with current directory as default var defaultPath = Path.Combine(Environment.CurrentDirectory, ".dotnet"); var installPath = AnsiConsole.Prompt( new TextPrompt("Enter .NET installation path:") .DefaultValue(defaultPath) .ValidationErrorMessage("[red]Please enter a valid path[/]") .Validate(path => { if (string.IsNullOrWhiteSpace(path)) return ValidationResult.Error("[red]Path cannot be empty[/]"); try { Path.GetFullPath(path); return ValidationResult.Success(); } catch { return ValidationResult.Error("[red]Invalid path[/]"); } })); // Get available .NET releases var stopwatch = Stopwatch.StartNew(); var releases = await AnsiConsole.Status() .SpinnerStyle(Style.Parse("green")) .StartAsync("Fetching available .NET versions...", async ctx => { var timer = new Timer(_ => { ctx.Status($"Fetching available .NET versions... ({stopwatch.ElapsedMilliseconds:N0}ms)"); }, null, TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(100)); try { return await installer.GetReleasesAsync(); } finally { timer.Dispose(); } }); stopwatch.Stop(); AnsiConsole.Write(new Padder(new Markup($"[green]✓[/] Fetched {releases.Count} .NET versions in [blue]{stopwatch.ElapsedMilliseconds:N0}ms[/]"), new Padding(0, 1))); // Display versions in a table var table = new Table() .Title("[blue].NET Versions Available for Installation[/]") .AddColumn("Version") .AddColumn("Latest Release") .AddColumn("Release Date") .AddColumn("Type") .AddColumn("Support Phase") .AddColumn("Support Until") .AddColumn("Security") .Border(TableBorder.Rounded); foreach (var release in releases) { var supportStyle = release.SupportPhase.ToLowerInvariant() switch { "lts" => "green", "sts" => "yellow", "eol" => "red", _ => "grey" }; var securityIcon = release.Security ? "[red]🔒[/]" : "[grey]-[/]"; var releaseDate = release.LatestReleaseDate.ToString("yyyy-MM-dd"); var supportUntil = release.EolDate.HasValue ? $"{release.EolDate.Value:yyyy-MM-dd} ({FormatTimeRemaining(release.EolDate.Value)})" : "[grey]TBD[/]"; table.AddRow( release.ChannelVersion, release.LatestRelease, releaseDate, $"{release.ReleaseType.ToUpperInvariant()}", $"[{supportStyle}]{release.SupportPhase.ToUpperInvariant()}[/]", supportUntil, securityIcon ); } AnsiConsole.Write(table); // Let user select a version var selectedRelease = AnsiConsole.Prompt( new SelectionPrompt() .Title("Select a .NET version to install:") .PageSize(10) .EnableSearch() .UseConverter(r => r.DisplayText) .AddChoices(releases)); // Download script stopwatch.Restart(); string downloadedScriptPath = await AnsiConsole.Status() .SpinnerStyle(Style.Parse("green")) .StartAsync("Downloading .NET install script...", async ctx => { var timer = new Timer(_ => { ctx.Status($"Downloading .NET install script... ({stopwatch.ElapsedMilliseconds:N0}ms)"); }, null, TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(100)); try { return await installer.DownloadInstallScriptAsync(); } finally { timer.Dispose(); } }); stopwatch.Stop(); AnsiConsole.Write(new Padder(new Markup($"[green]✓[/] Downloaded install script in [blue]{stopwatch.ElapsedMilliseconds:N0}ms[/] to: {downloadedScriptPath}"), new Padding(0, 1))); // Run the installation stopwatch.Restart(); bool installSuccess = await AnsiConsole.Status() .SpinnerStyle(Style.Parse("green")) .StartAsync("Installing .NET SDK...", async ctx => { var timer = new Timer(_ => { ctx.Status($"Installing .NET SDK... ({stopwatch.Elapsed.TotalSeconds:F1}s)"); }, null, TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(500)); try { void UpdateOutput(string data, bool isError) { var color = isError ? "red" : "grey"; AnsiConsole.Write(new Padder(new Markup($"[{color}]{data}[/]"), new Padding(2, 0, 0, 0))); } return await installer.InstallDotNetAsync(downloadedScriptPath, installPath, selectedRelease, progress: null, outputCallback: UpdateOutput); } finally { timer.Dispose(); } }); stopwatch.Stop(); if (installSuccess) { AnsiConsole.Write(new Padder( new Markup($"[green]✓[/] Installation completed in [blue]{stopwatch.Elapsed.TotalSeconds:F1}s[/]"), new Padding(0, 1))); } else { AnsiConsole.Write(new Padder( new Markup($"[red]✗[/] Installation failed after [blue]{stopwatch.Elapsed.TotalSeconds:F1}s[/]"), new Padding(0, 1))); } AnsiConsole.MarkupLine($"\n[green]✓[/] .NET has been installed to: [blue]{installPath}[/]"); AnsiConsole.MarkupLine("\n[yellow]Next steps:[/]"); AnsiConsole.MarkupLine("1. [grey]Add the installation path to your PATH environment variable[/]"); AnsiConsole.MarkupLine("2. [grey]Open a new terminal to start using .NET[/]"); AnsiConsole.MarkupLine("3. [grey]Run 'dotnet --version' to verify the installation[/]"); public class DotNetInstaller : IDisposable { private readonly HttpClient _httpClient = new(); public async Task> GetReleasesAsync(IProgress? progress = null) { progress?.Report("Fetching .NET releases from Microsoft..."); var response = await _httpClient.GetStringAsync("https://dotnetcli.blob.core.windows.net/dotnet/release-metadata/releases-index.json"); var releasesIndex = JsonSerializer.Deserialize(response, DotNetReleasesJsonContext.Default.DotNetReleasesIndex); progress?.Report("Parsing release data..."); var releases = releasesIndex!.ReleasesIndex .Select(r => new DotNetRelease( ChannelVersion: r.ChannelVersion, LatestRelease: r.LatestRelease, LatestReleaseDate: DateOnly.Parse(r.LatestReleaseDate), LatestRuntime: r.LatestRuntime, LatestSdk: r.LatestSdk, Product: r.ProductName, ReleasesJsonUrl: r.ReleasesJson, ReleaseType: r.ReleaseType, SupportPhase: r.SupportPhase, Security: r.Security, EolDate: r.EolDate != null ? DateOnly.Parse(r.EolDate) : null, SupportedOsJsonUrl: r.SupportedOsJson )) .Where(r => !r.SupportPhase.Equals("eol", StringComparison.OrdinalIgnoreCase)) .OrderByDescending(r => SemanticVersion.Parse(r.LatestRelease)) .ToList(); progress?.Report($"Found {releases.Count} supported releases"); return releases; } public async Task DownloadInstallScriptAsync(IProgress? progress = null) { var (scriptName, url) = OperatingSystem.IsWindows() ? ("dotnet-install.ps1", "https://dot.net/v1/dotnet-install.ps1") : ("dotnet-install.sh", "https://dot.net/v1/dotnet-install.sh"); var scriptsDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".dotnet-installs" ); Directory.CreateDirectory(scriptsDir); var scriptPath = Path.Combine(scriptsDir, scriptName); progress?.Report($"Downloading install script from {url}..."); var script = await _httpClient.GetStringAsync(url); await File.WriteAllTextAsync(scriptPath, script); // Set executable permissions on Unix systems if (!OperatingSystem.IsWindows()) { File.SetUnixFileMode(scriptPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } progress?.Report($"Install script saved to {scriptPath}"); return scriptPath; } public async Task InstallDotNetAsync(string scriptPath, string installPath, DotNetRelease selectedRelease, IProgress? progress = null, Action? outputCallback = null) { var channelArg = OperatingSystem.IsWindows() ? "-Channel" : "--channel"; var installArgs = OperatingSystem.IsWindows() ? $"-InstallDir \"{installPath}\" {channelArg} {selectedRelease.VersionDisplay}" : $"--install-dir \"{installPath}\" {channelArg} {selectedRelease.VersionDisplay}"; var startInfo = OperatingSystem.IsWindows() ? new ProcessStartInfo { FileName = "pwsh", Arguments = $"-NoProfile -ExecutionPolicy unrestricted -Command \"{scriptPath} {installArgs}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true } : new ProcessStartInfo { FileName = "/bin/bash", Arguments = $"{scriptPath} {installArgs}", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; progress?.Report($"Starting .NET {selectedRelease.ChannelVersion} installation..."); outputCallback?.Invoke($"Running: {startInfo.FileName} {startInfo.Arguments}", false); var process = Process.Start(startInfo); var outputLock = new object(); void UpdateOutput(string? data, bool isError) { if (data == null) return; lock (outputLock) { outputCallback?.Invoke(data, isError); } } process!.OutputDataReceived += (s, e) => UpdateOutput(e.Data, false); process.ErrorDataReceived += (s, e) => UpdateOutput(e.Data, true); process.BeginOutputReadLine(); process.BeginErrorReadLine(); await process.WaitForExitAsync(); if (process.ExitCode != 0) { progress?.Report($"Installation failed with exit code {process.ExitCode}"); outputCallback?.Invoke($"Command run: {startInfo.FileName} {startInfo.Arguments}", true); return false; } progress?.Report("Installation completed successfully"); return true; } public void Dispose() { _httpClient?.Dispose(); } } public record DotNetRelease( string ChannelVersion, string LatestRelease, DateOnly LatestReleaseDate, string LatestRuntime, string LatestSdk, string Product, string ReleasesJsonUrl, string ReleaseType, string SupportPhase, bool Security, DateOnly? EolDate, string? SupportedOsJsonUrl) { public string DisplayText => $"{ChannelVersion} ({SupportPhase.ToUpperInvariant()}{(Security ? " - Security Update" : "")})"; public string VersionDisplay => ChannelVersion; public string Version => LatestRelease; } // JSON Source Generator models public class DotNetReleasesIndex { [JsonPropertyName("releases-index")] public Product[] ReleasesIndex { get; set; } = []; } public class Product { [JsonPropertyName("channel-version")] public string ChannelVersion { get; set; } = ""; [JsonPropertyName("eol-date")] public string? EolDate { get; set; } [JsonPropertyName("security")] public bool Security { get; set; } [JsonPropertyName("latest-release-date")] public string LatestReleaseDate { get; set; } = ""; [JsonPropertyName("latest-release")] public string LatestRelease { get; set; } = ""; [JsonPropertyName("latest-runtime")] public string LatestRuntime { get; set; } = ""; [JsonPropertyName("latest-sdk")] public string LatestSdk { get; set; } = ""; [JsonPropertyName("product")] public string ProductName { get; set; } = ""; [JsonPropertyName("releases.json")] public string ReleasesJson { get; set; } = ""; [JsonPropertyName("release-type")] public string ReleaseType { get; set; } = ""; [JsonPropertyName("support-phase")] public string SupportPhase { get; set; } = ""; [JsonPropertyName("supported-os.json")] public string? SupportedOsJson { get; set; } } [JsonSerializable(typeof(DotNetReleasesIndex))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.KebabCaseLower)] public partial class DotNetReleasesJsonContext : JsonSerializerContext { }