Created
June 26, 2025 06:26
-
-
Save davidfowl/6ae624dda1bfe9ce68a8adafc0cbd742 to your computer and use it in GitHub Desktop.
cli tool to install dotnet
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#:package [email protected] | |
#:package [email protected] | |
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<string>("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<DotNetRelease>() | |
.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<List<DotNetRelease>> GetReleasesAsync(IProgress<string>? 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<string> DownloadInstallScriptAsync(IProgress<string>? 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<bool> InstallDotNetAsync(string scriptPath, string installPath, DotNetRelease selectedRelease, | |
IProgress<string>? progress = null, Action<string, bool>? 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 | |
{ | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment