Skip to content

Instantly share code, notes, and snippets.

@davidfowl
Created June 26, 2025 06:26
Show Gist options
  • Save davidfowl/6ae624dda1bfe9ce68a8adafc0cbd742 to your computer and use it in GitHub Desktop.
Save davidfowl/6ae624dda1bfe9ce68a8adafc0cbd742 to your computer and use it in GitHub Desktop.
cli tool to install dotnet
#: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