diff --git a/Directory.Packages.props b/Directory.Packages.props
index ab0b8d7a7afa..00af589d9996 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -98,6 +98,9 @@
+
+
+
@@ -125,6 +128,7 @@
+
+
diff --git a/sdk.sln b/sdk.sln
index 9d90c45efbfd..0b7a84ef7cd8 100644
--- a/sdk.sln
+++ b/sdk.sln
@@ -1,4 +1,5 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
+
+Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.1.31903.286
MinimumVisualStudioVersion = 10.0.40219.1
@@ -546,6 +547,23 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.ApiDiff.Te
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.ApiDiff", "src\Compatibility\ApiDiff\Microsoft.DotNet.ApiDiff\Microsoft.DotNet.ApiDiff.csproj", "{4F23A9C8-945A-A4F4-51E9-FCA215943C0D}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DNVM", "DNVM", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
+ ProjectSection(SolutionItems) = preProject
+ src\DNVM\Channel.cs = src\DNVM\Channel.cs
+ src\DNVM\dnvm.csproj = src\DNVM\dnvm.csproj
+ src\DNVM\DnvmEnv.cs = src\DNVM\DnvmEnv.cs
+ src\DNVM\DotnetReleasesIndex.cs = src\DNVM\DotnetReleasesIndex.cs
+ src\DNVM\InstallCommand.cs = src\DNVM\InstallCommand.cs
+ src\DNVM\ListCommand.cs = src\DNVM\ListCommand.cs
+ src\DNVM\Logger.cs = src\DNVM\Logger.cs
+ src\DNVM\ManifestUtils.cs = src\DNVM\ManifestUtils.cs
+ src\DNVM\Program.cs = src\DNVM\Program.cs
+ src\DNVM\ScalarDeserializer.cs = src\DNVM\ScalarDeserializer.cs
+ src\DNVM\SelectCommand.cs = src\DNVM\SelectCommand.cs
+ src\DNVM\UninstallCommand.cs = src\DNVM\UninstallCommand.cs
+ src\DNVM\UpdateCommand.cs = src\DNVM\UpdateCommand.cs
+ EndProjectSection
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -1237,6 +1255,7 @@ Global
{3F0093BF-A64D-4EE8-8A2A-22800BB25CFF} = {C66B5859-B05E-5DF4-58E9-78CA919DB89A}
{A57B724D-D12B-483E-82F2-2183DEA2DFA7} = {AFA55F45-CFCB-9821-A210-2D3496088416}
{4F23A9C8-945A-A4F4-51E9-FCA215943C0D} = {C66B5859-B05E-5DF4-58E9-78CA919DB89A}
+ {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {22AB674F-ED91-4FBC-BFEE-8A1E82F9F05E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {FB8F26CE-4DE6-433F-B32A-79183020BBD6}
diff --git a/src/Cli/dotnet/Commands/DNVM/DnvmCommandParser.cs b/src/Cli/dotnet/Commands/DNVM/DnvmCommandParser.cs
new file mode 100644
index 000000000000..0dfabd1b7398
--- /dev/null
+++ b/src/Cli/dotnet/Commands/DNVM/DnvmCommandParser.cs
@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable disable
+
+using System.CommandLine;
+using Microsoft.DotNet.Cli.Extensions;
+
+namespace Microsoft.DotNet.Cli.Commands.DNVM;
+
+internal static class DnvmCommandParser
+{
+ public static readonly string DocsLink = "https://aka.ms/dotnet-dnvm";
+
+ private static readonly Command Command = ConstructCommand();
+
+ public static Command GetCommand()
+ {
+ return Command;
+ }
+
+ private static Command ConstructCommand()
+ {
+ DocumentedCommand command = new("dnvm", DocsLink, "The .NET version manager");
+ command.Subcommands.Add(InstallCommandParser.GetCommand());
+ command.Subcommands.Add(UninstallCommandParser.GetCommand());
+
+ command.SetAction((parseResult) => parseResult.HandleMissingCommand());
+
+ return command;
+ }
+}
diff --git a/src/Cli/dotnet/Commands/DNVM/InstallCommand.cs b/src/Cli/dotnet/Commands/DNVM/InstallCommand.cs
new file mode 100644
index 000000000000..845f39bf00d7
--- /dev/null
+++ b/src/Cli/dotnet/Commands/DNVM/InstallCommand.cs
@@ -0,0 +1,55 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable disable
+
+using System;
+using System.CommandLine;
+using System.Threading.Tasks;
+using Microsoft.DotNet.Cli.Extensions;
+using Microsoft.DotNet.Cli.Utils;
+using Microsoft.DotNet.DNVM;
+using Semver;
+
+namespace Microsoft.DotNet.Cli.Commands.DNVM;
+
+public class InstallCommand
+{
+ public static int Run(ParseResult parseResult)
+ {
+ parseResult.HandleDebugSwitch();
+ parseResult.ShowHelpOrErrorIfAppropriate();
+
+ return RunAsync(parseResult).GetAwaiter().GetResult();
+ }
+
+ private static async Task RunAsync(ParseResult parseResult)
+ {
+ // Map command line options to DNVM InstallCommand.Options
+ var sdkVersion = parseResult.GetValue(InstallCommandParser.SdkVersionOption);
+ var force = parseResult.GetValueForOption(InstallCommandParser.ForceOption);
+ var sdkDirString = parseResult.GetValueForOption(InstallCommandParser.SdkDirOption);
+ var verbose = parseResult.GetValueForOption(InstallCommandParser.VerboseOption);
+
+ var env = DnvmEnv.Create();
+ var logger = new Logger(verbose ? LogLevel.Info : LogLevel.Standard);
+
+ SdkDirName? sdkDir = null;
+ if (!string.IsNullOrEmpty(sdkDirString))
+ {
+ sdkDir = new SdkDirName(sdkDirString);
+ }
+
+ var options = new Microsoft.DotNet.DNVM.InstallCommand.Options
+ {
+ SdkVersion = sdkVersion,
+ Force = force,
+ SdkDir = sdkDir,
+ Verbose = verbose
+ };
+
+ var result = await Microsoft.DotNet.DNVM.InstallCommand.Run(env, logger, options);
+
+ return (int)result;
+ }
+}
\ No newline at end of file
diff --git a/src/Cli/dotnet/Commands/DNVM/InstallCommandParser.cs b/src/Cli/dotnet/Commands/DNVM/InstallCommandParser.cs
new file mode 100644
index 000000000000..e4ada5a0f3d8
--- /dev/null
+++ b/src/Cli/dotnet/Commands/DNVM/InstallCommandParser.cs
@@ -0,0 +1,50 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable disable
+
+using System.CommandLine;
+using Semver;
+using Microsoft.DotNet.Cli.Extensions;
+using Microsoft.DotNet.DNVM;
+
+namespace Microsoft.DotNet.Cli.Commands.DNVM;
+
+internal static class InstallCommandParser
+{
+ public static readonly string DocsLink = "https://aka.ms/dotnet-install";
+
+ public static readonly Option SdkVersionOption =
+ new("--sdk-version", "The version of the SDK to install") { IsRequired = true };
+
+ public static readonly Option ForceOption =
+ new("--force", "Force installation even if the SDK is already installed");
+
+ public static readonly Option SdkDirOption =
+ new("--sdk-dir", "The directory to install the SDK into");
+
+ public static readonly Option VerboseOption =
+ new("--verbose", "Enable verbose logging");
+
+ private static readonly Command Command = ConstructCommand();
+
+ public static Command GetCommand()
+ {
+ return Command;
+ }
+
+ private static Command ConstructCommand()
+ {
+ var installCommand = new DocumentedCommand("install", DocsLink)
+ {
+ SdkVersionOption,
+ ForceOption,
+ SdkDirOption,
+ VerboseOption
+ };
+
+ installCommand.SetAction((parseResult) => InstallCommand.Run(parseResult));
+
+ return installCommand;
+ }
+}
\ No newline at end of file
diff --git a/src/Cli/dotnet/Commands/DNVM/UninstallCommand.cs b/src/Cli/dotnet/Commands/DNVM/UninstallCommand.cs
new file mode 100644
index 000000000000..92d7e9e07588
--- /dev/null
+++ b/src/Cli/dotnet/Commands/DNVM/UninstallCommand.cs
@@ -0,0 +1,42 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable disable
+
+using System.CommandLine;
+using System.Threading.Tasks;
+using Microsoft.DotNet.Cli.Extensions;
+using Microsoft.DotNet.Cli.Utils;
+using Microsoft.DotNet.DNVM;
+using Semver;
+
+namespace Microsoft.DotNet.Cli.Commands.DNVM;
+
+public class UninstallCommand
+{
+ public static int Run(ParseResult parseResult)
+ {
+ parseResult.HandleDebugSwitch();
+ parseResult.ShowHelpOrErrorIfAppropriate();
+
+ return RunAsync(parseResult).GetAwaiter().GetResult();
+ }
+
+ private static async Task RunAsync(ParseResult parseResult)
+ {
+ var sdkVersion = parseResult.GetValue(UninstallCommandParser.SdkVersionArgument);
+ var sdkDirString = parseResult.GetValueForOption(UninstallCommandParser.SdkDirOption);
+
+ var env = DnvmEnv.Create();
+ var logger = new Logger(LogLevel.Standard);
+
+ // Convert sdk-dir option from string to SdkDirName if provided
+ SdkDirName? sdkDir = null;
+ if (!string.IsNullOrEmpty(sdkDirString))
+ {
+ sdkDir = new SdkDirName(sdkDirString);
+ }
+
+ return await Microsoft.DotNet.DNVM.UninstallCommand.Run(env, logger, sdkVersion, sdkDir);
+ }
+}
diff --git a/src/Cli/dotnet/Commands/DNVM/UninstallCommandParser.cs b/src/Cli/dotnet/Commands/DNVM/UninstallCommandParser.cs
new file mode 100644
index 000000000000..da7b599b8b9d
--- /dev/null
+++ b/src/Cli/dotnet/Commands/DNVM/UninstallCommandParser.cs
@@ -0,0 +1,42 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable disable
+
+using System.CommandLine;
+using Semver;
+using Microsoft.DotNet.Cli.Extensions;
+using Microsoft.DotNet.DNVM;
+
+namespace Microsoft.DotNet.Cli.Commands.DNVM;
+
+internal static class UninstallCommandParser
+{
+ public static readonly string DocsLink = "https://aka.ms/dotnet-dnvm";
+
+ public static readonly Argument SdkVersionArgument =
+ new("sdk-version", "The version of the SDK to uninstall");
+
+ public static readonly Option SdkDirOption =
+ new("--sdk-dir", "Uninstall the SDK from the given directory");
+
+ private static readonly Command Command = ConstructCommand();
+
+ public static Command GetCommand()
+ {
+ return Command;
+ }
+
+ private static Command ConstructCommand()
+ {
+ var uninstallCommand = new DocumentedCommand("uninstall", DocsLink)
+ {
+ SdkVersionArgument,
+ SdkDirOption
+ };
+
+ uninstallCommand.SetAction((parseResult) => UninstallCommand.Run(parseResult));
+
+ return uninstallCommand;
+ }
+}
diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs
index 3ce2e132ad25..d653f79702f8 100644
--- a/src/Cli/dotnet/Parser.cs
+++ b/src/Cli/dotnet/Parser.cs
@@ -41,6 +41,7 @@
using Microsoft.DotNet.Cli.Commands.VSTest;
using Microsoft.DotNet.Cli.Commands.Workload;
using Microsoft.DotNet.Cli.Commands.Workload.Search;
+using Microsoft.DotNet.Cli.Commands.DNVM;
using Microsoft.DotNet.Cli.Extensions;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Cli.Utils.Extensions;
@@ -89,6 +90,7 @@ public static class Parser
VSTestCommandParser.GetCommand(),
HelpCommandParser.GetCommand(),
SdkCommandParser.GetCommand(),
+ DnvmCommandParser.GetCommand(),
InstallSuccessCommand,
WorkloadCommandParser.GetCommand(),
new System.CommandLine.StaticCompletions.CompletionsCommand()
diff --git a/src/Cli/dotnet/dotnet.csproj b/src/Cli/dotnet/dotnet.csproj
index f6b9b0a7015d..4ddea2108093 100644
--- a/src/Cli/dotnet/dotnet.csproj
+++ b/src/Cli/dotnet/dotnet.csproj
@@ -45,6 +45,7 @@
+
diff --git a/src/DNVM/Channel.cs b/src/DNVM/Channel.cs
new file mode 100644
index 000000000000..d03ec907e979
--- /dev/null
+++ b/src/DNVM/Channel.cs
@@ -0,0 +1,146 @@
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Reflection;
+using Semver;
+
+namespace Microsoft.DotNet.DNVM;
+
+public static class Channels
+{
+ public static string GetDesc(this Channel c) => c switch
+ {
+ Channel.VersionedMajorMinor v => $"The latest version in the {v} support channel",
+ Channel.VersionedFeature v => $"The latest version in the {v} support channel",
+ Channel.Lts => "The latest version in Long-Term support",
+ Channel.Sts => "The latest version in Short-Term support",
+ Channel.Latest => "The latest supported version from either the LTS or STS support channels.",
+ Channel.Preview => "The latest preview version",
+ };
+}
+
+
+public abstract partial record Channel
+{
+ private Channel() { }
+
+ ///
+ /// A major-minor versioned channel.
+ ///
+ public sealed partial record VersionedMajorMinor(int Major, int Minor) : Channel;
+ ///
+ /// A major-minor-patch versioned channel.
+ ///
+ /// The feature level of the version, e.g. 1 in 9.0.100
+ public sealed partial record VersionedFeature(int Major, int Minor, int FeatureLevel) : Channel;
+ ///
+ /// Newest Long Term Support release.
+ ///
+ public sealed partial record Lts : Channel;
+ ///
+ /// Newest Short Term Support release.
+ ///
+ public sealed partial record Sts : Channel;
+ ///
+ /// Latest supported version from either the LTS or STS support channels.
+ ///
+ public sealed partial record Latest : Channel;
+ ///
+ /// Latest preview version.
+ ///
+ public sealed partial record Preview : Channel;
+}
+
+partial record Channel : ISerializeProvider
+{
+ public abstract string GetDisplayName();
+ public sealed override string ToString() => GetDisplayName();
+ public string GetLowerName() => GetDisplayName().ToLowerInvariant();
+
+ static ISerialize ISerializeProvider.Instance => Serialize.Instance;
+
+ private sealed class Serialize : ISerialize
+ {
+ public static readonly Serialize Instance = new();
+ private Serialize() { }
+
+ ///
+ /// Serialize as a string.
+ ///
+ void ISerialize.Serialize(Channel channel, ISerializer serializer)
+ => serializer.WriteString(channel.GetLowerName());
+ }
+
+ partial record VersionedMajorMinor
+ {
+ public override string GetDisplayName() => $"{Major}.{Minor}";
+ }
+ partial record VersionedFeature
+ {
+ public override string GetDisplayName() => $"{Major}.{Minor}.{FeatureLevel}xx";
+ }
+ partial record Lts : Channel
+ {
+ public override string GetDisplayName() => "LTS";
+ }
+ partial record Sts : Channel
+ {
+ public override string GetDisplayName() => "STS";
+ }
+ partial record Latest : Channel
+ {
+ public override string GetDisplayName() => "Latest";
+ }
+ partial record Preview : Channel
+ {
+ public override string GetDisplayName() => "Preview";
+ }
+}
+
+partial record Channel : IDeserializeProvider
+{
+ static IDeserialize IDeserializeProvider.Instance => DeserializeProxy.Instance;
+
+ public static Channel FromString(string str)
+ {
+ switch (str)
+ {
+ case "lts": return new Lts();
+ case "sts": return new Sts();
+ case "latest": return new Latest();
+ case "preview": return new Preview();
+ default:
+ var components = str.Split('.');
+ switch (components)
+ {
+ case [_, _]:
+ var major = int.Parse(components[0]);
+ var minor = int.Parse(components[1]);
+ return new VersionedMajorMinor(major, minor);
+ case [_, _, _]:
+ if (components[2] is not [<= '9' and >= '0', 'x', 'x'])
+ {
+ throw new DeserializeException($"Feature band must be 3 characters and end in 'xx': {str}");
+ }
+ major = int.Parse(components[0]);
+ minor = int.Parse(components[1]);
+ var featureLevel = components[2][0] - '0';
+ return new VersionedFeature(major, minor, featureLevel);
+ default:
+ throw new DeserializeException($"Invalid channel version: {str}");
+ }
+ }
+ }
+
+ private sealed class DeserializeProxy : IDeserialize
+ {
+ public static readonly DeserializeProxy Instance = new();
+
+ ///
+ /// Deserialize as a string.
+ ///
+ public Channel Deserialize(IDeserializer deserializer)
+ => FromString(StringProxy.Instance.Deserialize(deserializer));
+ }
+}
diff --git a/src/DNVM/CommandLineArguments.cs b/src/DNVM/CommandLineArguments.cs
new file mode 100644
index 000000000000..e37b73c42cbb
--- /dev/null
+++ b/src/DNVM/CommandLineArguments.cs
@@ -0,0 +1,238 @@
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.ComponentModel;
+using System.Linq;
+using Semver;
+using Serde;
+using Serde.CmdLine;
+using Spectre.Console;
+using StaticCs;
+
+namespace Microsoft.DotNet.DNVM;
+
+[GenerateDeserialize]
+[Command("dnvm", Summary = "Install and manage .NET SDKs.")]
+public partial record DnvmArgs
+{
+ [CommandOption("--enable-dnvm-previews", Description = "Enable dnvm previews.")]
+ public bool? EnableDnvmPreviews { get; init; }
+
+ [CommandGroup("command")]
+ public DnvmSubCommand? SubCommand { get; init; }
+}
+
+[Closed]
+[GenerateDeserialize]
+public abstract partial record DnvmSubCommand
+{
+ private DnvmSubCommand() { }
+
+ [Command("install", Summary = "Install an SDK.")]
+ public sealed partial record InstallArgs : DnvmSubCommand
+ {
+ [CommandParameter(0, "version", Description = "The version of the SDK to install.")]
+ [SerdeMemberOptions(DeserializeProxy = typeof(SemVersionProxy))]
+ public required SemVersion SdkVersion { get; init; }
+
+ [CommandOption("-f|--force", Description = "Force install the given SDK, even if already installed")]
+ public bool? Force { get; init; } = null;
+
+ [CommandOption("-s|--sdk-dir", Description = "Install the SDK into a separate directory with the given name.")]
+ [SerdeMemberOptions(DeserializeProxy = typeof(NullableRefProxy.De))] // Treat as string
+ public SdkDirName? SdkDir { get; init; } = null;
+
+ [CommandOption("-v|--verbose", Description = "Print debugging messages to the console.")]
+ public bool? Verbose { get; init; } = null;
+ }
+
+ [Command("track", Summary = "Start tracking a new channel.")]
+ public sealed partial record TrackArgs : DnvmSubCommand
+ {
+ [CommandParameter(0, "channel", Description = "Track the channel specified.")]
+ [SerdeMemberOptions(DeserializeProxy = typeof(CaseInsensitiveChannel))]
+ public required Channel Channel { get; init; }
+
+ ///
+ /// URL to the dotnet feed containing the releases index and SDKs.
+ ///
+ [CommandOption("--feed-url", Description = "Set the feed URL to download the SDK from.")]
+ public string? FeedUrl { get; init; }
+
+ [CommandOption("-v|--verbose", Description = "Print debugging messages to the console.")]
+ public bool? Verbose { get; init; } = null;
+
+ [CommandOption("-f|--force", Description = "Force tracking the given channel, even if already tracked.")]
+ public bool? Force { get; init; } = null;
+
+ ///
+ /// Answer yes to every question or use the defaults.
+ ///
+ [CommandOption("-y", Description = "Answer yes to all prompts.")]
+ public bool? Yes { get; init; } = null;
+
+ [CommandOption("--prereqs", Description = "Print prereqs for dotnet on Ubuntu.")]
+ public bool? Prereqs { get; init; } = null;
+
+ ///
+ /// When specified, install the SDK into a separate directory with the given name,
+ /// translated to lower-case. Preview releases are installed into a directory named 'preview'
+ /// by default.
+ ///
+ [CommandOption("-s|--sdk-dir", Description = "Track the channel in a separate directory with the given name.")]
+ public string? SdkDir { get; init; } = null;
+ }
+
+ [Command("update", Summary = "Update the installed SDKs.")]
+ public sealed partial record UpdateArgs : DnvmSubCommand
+ {
+ [CommandOption("--feed-url", Description = "Set the feed URL to download the SDK from.")]
+ public string? FeedUrl { get; init; } = null;
+
+ [CommandOption("-v|--verbose", Description = "Print debugging messages to the console.")]
+ public bool? Verbose { get; init; } = null;
+
+ [CommandOption("--self", Description = "Update dnvm itself in the current location.")]
+ public bool? Self { get; init; } = null;
+
+ [CommandOption("-y", Description = "Answer yes to all prompts.")]
+ public bool? Yes { get; init; } = null;
+ }
+
+ [Command("list", Summary = "List installed SDKs.")]
+ public sealed partial record ListArgs : DnvmSubCommand
+ {
+ }
+
+ [Command("select", Summary = "Select the active SDK directory.", Description =
+"Select the active SDK directory, meaning the directory that will be used when running `dotnet` " +
+"commands. This is the same directory passed to the `-s` option for `dnvm install`.\n" +
+"\n" +
+"Note: This command does not change between SDK versions installed in the same directory. For " +
+"that, use the built-in dotnet global.json file. Information about global.json can be found at " +
+"https://learn.microsoft.com/en-us/dotnet/core/tools/global-json.")]
+ public sealed partial record SelectArgs : DnvmSubCommand
+ {
+ [CommandParameter(0, "sdkDirName", Description = "The name of the SDK directory to select.")]
+ public required string SdkDirName { get; init; }
+ }
+
+ [Command("untrack", Summary = "Remove a channel from the list of tracked channels.")]
+ public sealed partial record UntrackArgs : DnvmSubCommand
+ {
+ [CommandParameter(0, "channel", Description = "The channel to untrack.")]
+ [SerdeMemberOptions(DeserializeProxy = typeof(CaseInsensitiveChannel))]
+ public required Channel Channel { get; init; }
+ }
+
+ [Command("uninstall", Summary = "Uninstall an SDK.")]
+ public sealed partial record UninstallArgs : DnvmSubCommand
+ {
+ [CommandParameter(0, "sdkVersion", Description = "The version of the SDK to uninstall.")]
+ [SerdeMemberOptions(DeserializeProxy = typeof(SemVersionProxy))]
+ public required SemVersion SdkVersion { get; init; }
+
+ [CommandOption("-s|--sdk-dir", Description = "Uninstall the SDK from the given directory.")]
+ [SerdeMemberOptions(DeserializeProxy = typeof(NullableRefProxy.De))] // Treat as string
+ public SdkDirName? SdkDir { get; init; } = null;
+ }
+
+ [Command("prune", Summary = "Remove all SDKs with older patch versions.")]
+ public sealed partial record PruneArgs : DnvmSubCommand
+ {
+ [CommandOption("-v|--verbose", Description = "Print extra debugging info to the console.")]
+ public bool? Verbose { get; init; } = null;
+
+ [CommandOption("--dry-run", Description = "Print the list of the SDKs to be uninstalled, but don't uninstall.")]
+ public bool? DryRun { get; init; } = null;
+ }
+
+ [Command("restore", Summary = "Restore the SDK listed in the global.json file.",
+ Description = "Downloads the SDK in the global.json in or above the current directory.")]
+ public sealed partial record RestoreArgs : DnvmSubCommand
+ {
+ [CommandOption("-l|--local", Description = "Install the sdk into the .dotnet folder in the same directory as global.json.")]
+ public bool? Local { get; init; } = null;
+
+ [CommandOption("-f|--force", Description = "Force install the SDK, even if already installed.")]
+ public bool? Force { get; init; } = null;
+
+ [CommandOption("-v|--verbose", Description = "Print extra debugging info to the console.")]
+ public bool? Verbose { get; init; } = null;
+ }
+
+ ///
+ /// Deserialize a named channel case-insensitively. Produces a user-friendly error message if the
+ /// channel is not recognized.
+ ///
+ private sealed class CaseInsensitiveChannel : IDeserializeProvider, IDeserialize
+ {
+ public ISerdeInfo SerdeInfo => StringProxy.SerdeInfo;
+ static IDeserialize IDeserializeProvider.Instance { get; } = new CaseInsensitiveChannel();
+ private CaseInsensitiveChannel() { }
+
+ public Channel Deserialize(IDeserializer deserializer)
+ {
+ try
+ {
+ return Channel.FromString(StringProxy.Instance.Deserialize(deserializer).ToLowerInvariant());
+ }
+ catch (DeserializeException)
+ {
+ var sep = Environment.NewLine + "\t- ";
+ IEnumerable channels = [new Channel.Latest(), new Channel.Preview(), new Channel.Lts(), new Channel.Sts()];
+ throw new DeserializeException(
+ "Channel must be one of:"
+ + sep + string.Join(sep, channels));
+ }
+ }
+ }
+}
+
+public static class CommandLineArguments
+{
+ ///
+ /// Returns null if an error was produced or help was requested.
+ ///
+ public static DnvmArgs? TryParse(IAnsiConsole console, string[] args)
+ {
+ try
+ {
+ return ParseRaw(console, args);
+ }
+ catch (ArgumentSyntaxException ex)
+ {
+ console.WriteLine("error: " + ex.Message);
+ console.WriteLine(CmdLine.GetHelpText(includeHelp: true));
+ return null;
+ }
+ }
+
+ ///
+ /// Throws an exception if the command was not recognized. Returns null if help was requested.
+ ///
+ public static DnvmArgs? ParseRaw(IAnsiConsole console, string[] args)
+ {
+ var result = CmdLine.ParseRawWithHelp(args);
+ DnvmArgs dnvmCmd;
+ switch (result)
+ {
+ case CmdLine.ParsedArgsOrHelpInfos.Parsed(var value):
+ dnvmCmd = value;
+ if (dnvmCmd.EnableDnvmPreviews is null && dnvmCmd.SubCommand is null)
+ {
+ // Empty command is a help request.
+ console.WriteLine(CmdLine.GetHelpText(includeHelp: true));
+ }
+ return dnvmCmd;
+ case CmdLine.ParsedArgsOrHelpInfos.Help(var helpInfos):
+ var rootInfo = SerdeInfoProvider.GetDeserializeInfo();
+ var lastInfo = helpInfos.Last();
+ console.WriteLine(CmdLine.GetHelpText(rootInfo, lastInfo, includeHelp: true));
+ return null;
+ default:
+ throw new InvalidOperationException();
+ }
+ }
+}
diff --git a/src/DNVM/DnvmEnv.cs b/src/DNVM/DnvmEnv.cs
new file mode 100644
index 000000000000..f402f31d7836
--- /dev/null
+++ b/src/DNVM/DnvmEnv.cs
@@ -0,0 +1,148 @@
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Zio;
+using Zio.FileSystems;
+using static System.Environment;
+
+namespace Microsoft.DotNet.DNVM;
+
+///
+/// Represents the environment of a dnvm process.
+///
+public sealed partial class DnvmEnv
+{
+ public bool IsPhysicalDnvmHome { get; }
+ public readonly IFileSystem DnvmHomeFs;
+ public readonly IFileSystem CwdFs;
+ public readonly UPath Cwd;
+ public string RealPath(UPath path) => DnvmHomeFs.ConvertPathToInternal(path);
+ public SubFileSystem TempFs { get; }
+ public Func GetUserEnvVar { get; }
+ public Action SetUserEnvVar { get; }
+ public IEnumerable DotnetFeedUrls { get; }
+ public string UserHome { get; }
+ public ScopedHttpClient HttpClient { get; }
+
+ public DnvmEnv(
+ string userHome,
+ IFileSystem homeFs,
+ IFileSystem cwdFs,
+ UPath cwd,
+ bool isPhysical,
+ Func getUserEnvVar,
+ Action setUserEnvVar,
+ IEnumerable? dotnetFeedUrls = null,
+ HttpClient? httpClient = null)
+ {
+ UserHome = userHome;
+ DnvmHomeFs = homeFs;
+ CwdFs = cwdFs;
+ Cwd = cwd;
+ IsPhysicalDnvmHome = isPhysical;
+ // TempFs must be a physical file system because we pass the path to external
+ // commands that will not be able to write to shared memory
+ TempFs = new SubFileSystem(
+ PhysicalFs,
+ PhysicalFs.ConvertPathFromInternal(Path.GetTempPath()),
+ owned: false);
+ GetUserEnvVar = getUserEnvVar;
+ SetUserEnvVar = setUserEnvVar;
+ DotnetFeedUrls = dotnetFeedUrls ?? DefaultDotnetFeedUrls;
+ HttpClient = new ScopedHttpClient(httpClient ?? new HttpClient() {
+ Timeout = Timeout.InfiniteTimeSpan
+ });
+ }
+}
+
+public sealed partial class DnvmEnv : IDisposable
+{
+ public const string ManifestFileName = "dnvmManifest.json";
+ public static EqArray DefaultDotnetFeedUrls { get;} = [
+ "https://builds.dotnet.microsoft.com/dotnet",
+ "https://dotnetcli.blob.core.windows.net/dotnet",
+ ];
+ public static UPath ManifestPath => UPath.Root / ManifestFileName;
+ public static UPath EnvPath => UPath.Root / "env";
+ public static UPath DnvmExePath => UPath.Root / Utilities.DnvmExeName;
+ public static UPath SymlinkPath => UPath.Root / Utilities.DotnetExeName;
+ public static UPath GetSdkPath(SdkDirName sdkDirName) => UPath.Root / sdkDirName.Name;
+ ///
+ /// Default DNVM_HOME is
+ /// ~/.local/share/dnvm on Linux
+ /// %LocalAppData%/dnvm on Windows
+ /// ~/Library/Application Support/dnvm on Mac
+ ///
+ public static readonly string DefaultDnvmHome = Path.Combine(
+ GetFolderPath(SpecialFolder.LocalApplicationData, SpecialFolderOption.DoNotVerify),
+ "dnvm");
+ ///
+ /// The location of the SDK install directory, relative to
+ ///
+ public static readonly SdkDirName DefaultSdkDirName = new("dn");
+ public static readonly PhysicalFileSystem PhysicalFs = new();
+ public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Get the path to DNVM_HOME, which is the location of the dnvm manifest
+ /// and the installed SDKs. If the environment variable is not set, uses
+ /// as the default.
+ ///
+ public static DnvmEnv CreateDefault(
+ string? home = null,
+ string? dotnetFeedUrl = null)
+ {
+ home ??= Environment.GetEnvironmentVariable("DNVM_HOME");
+ var dnvmHome = string.IsNullOrWhiteSpace(home)
+ ? DefaultDnvmHome
+ : home;
+ return CreatePhysical(dnvmHome,
+ n => Environment.GetEnvironmentVariable(n, EnvironmentVariableTarget.User),
+ (n, v) => Environment.SetEnvironmentVariable(n, v, EnvironmentVariableTarget.User),
+ dotnetFeedUrl);
+ }
+
+ public static DnvmEnv CreatePhysical(
+ string realPath,
+ Func getUserEnvVar,
+ Action setUserEnvVar,
+ string? dotnetFeedUrl = null)
+ {
+ Directory.CreateDirectory(realPath);
+
+ return new DnvmEnv(
+ userHome: GetFolderPath(SpecialFolder.UserProfile, SpecialFolderOption.DoNotVerify),
+ new SubFileSystem(PhysicalFs, PhysicalFs.ConvertPathFromInternal(realPath)),
+ PhysicalFs,
+ PhysicalFs.ConvertPathFromInternal(Environment.CurrentDirectory),
+ isPhysical: true,
+ getUserEnvVar,
+ setUserEnvVar,
+ dotnetFeedUrls: dotnetFeedUrl is not null ? [ dotnetFeedUrl ] : null);
+ }
+
+ ///
+ /// Reads a manifest (any version) from the given path and returns an up-to-date (latest version). Throws if the manifest is invalid.
+ ///
+ public async Task ReadManifest()
+ {
+ var text = DnvmHomeFs.ReadAllText(ManifestPath);
+ return await ManifestUtils.DeserializeNewOrOldManifest(HttpClient, text, DotnetFeedUrls);
+ }
+
+ public void WriteManifest(Manifest manifest)
+ {
+ var text = JsonSerializer.Serialize(manifest);
+ DnvmHomeFs.WriteAllText(ManifestPath, text, Encoding.UTF8);
+ }
+
+ public void Dispose()
+ { }
+}
diff --git a/src/DNVM/DnvmReleases.cs b/src/DNVM/DnvmReleases.cs
new file mode 100644
index 000000000000..7f84bb7febc9
--- /dev/null
+++ b/src/DNVM/DnvmReleases.cs
@@ -0,0 +1,17 @@
+
+using System.Collections.Generic;
+using Serde;
+using static Dnvm.DnvmReleases;
+
+namespace Dnvm;
+
+[GenerateSerde]
+public partial record DnvmReleases(Release LatestVersion)
+{
+ public Release? LatestPreview { get; init; }
+
+ [GenerateSerde]
+ public partial record Release(
+ string Version,
+ Dictionary Artifacts);
+}
diff --git a/src/DNVM/DotnetReleasesIndex.cs b/src/DNVM/DotnetReleasesIndex.cs
new file mode 100644
index 000000000000..c662733b372b
--- /dev/null
+++ b/src/DNVM/DotnetReleasesIndex.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Threading.Tasks;
+using System.Xml.Schema;
+using Semver;
+
+namespace Microsoft.DotNet.DNVM;
+
+public partial record DotnetReleasesIndex
+{
+ public static readonly DotnetReleasesIndex Empty = new() { ChannelIndices = [ ] };
+
+ public required ImmutableArray ChannelIndices { get; init; }
+
+ public partial record ChannelIndex
+ {
+ ///
+ /// The major and minor version of the release, e.g. '42.42'.
+ ///
+ public required string MajorMinorVersion { get; init; }
+ ///
+ /// The version number of the latest SDK, e.g. '42.42.104'.
+ ///
+ public required string LatestSdk { get; init; }
+ ///
+ /// The version number of the release, e.g. '42.42.4'.
+ ///
+ public required string LatestRelease { get; init; }
+ ///
+ /// Whether this version is in an LTS or STS cadence.
+ ///
+ public required string ReleaseType { get; init; }
+ ///
+ /// The support phase the release is in, e.g. 'active' or 'eol'.
+ ///
+ public required string SupportPhase { get; init; }
+ ///
+ /// The URL to the releases index for this channel.
+ ///
+ public required string ChannelReleaseIndexUrl { get; init; }
+ }
+}
+
+public partial record ChannelReleaseIndex
+{
+ public static readonly ChannelReleaseIndex Empty = new() { Releases = [ ] };
+
+ public ChannelReleaseIndex AddRelease(Release release) => this with {
+ Releases = Releases.Add(release)
+ };
+
+ public required EqArray Releases { get; init; }
+
+ public partial record Release()
+ {
+ public required SemVersion ReleaseVersion { get; init; }
+ public required Component Runtime { get; init; }
+ public required Component Sdk { get; init; }
+ public required EqArray Sdks { get; init; }
+ public required Component AspNetCore { get; init; }
+ public required Component WindowsDesktop { get; init; }
+ }
+
+ public partial record Component
+ {
+ public required SemVersion Version { get; init; }
+
+ public required EqArray Files { get; init; }
+ }
+
+ public partial record File
+ {
+ public required string Name { get; init; }
+ public required string Url { get; init; }
+ public required string Rid { get; init; }
+ public string? Hash { get; init; } = null;
+ }
+}
+
+public partial record DotnetReleasesIndex
+{
+ public const string ReleasesUrlSuffix = "/release-metadata/releases-index.json";
+ public async static Task FetchLatestIndex(
+ ScopedHttpClient client,
+ IEnumerable feeds,
+ string urlSuffix = ReleasesUrlSuffix)
+ {
+ ScopedHttpResponseMessage? lastResponse = null;
+ foreach (var feed in feeds)
+ {
+ var adjustedUrl = feed.TrimEnd('/') + urlSuffix;
+ var response = await CancelScope.WithTimeoutAfter(DnvmEnv.DefaultTimeout,
+ _ => client.GetAsync(adjustedUrl)
+ );
+ if (response.IsSuccessStatusCode)
+ {
+ return JsonSerializer.Deserialize(
+ await CancelScope.WithTimeoutAfter(DnvmEnv.DefaultTimeout,
+ async _ => await response.Content.ReadAsStringAsync()
+ )
+ );
+ }
+ else
+ {
+ lastResponse = response;
+ }
+ }
+ lastResponse!.EnsureSuccessStatusCode();
+ throw ExceptionUtilities.Unreachable;
+ }
+
+ public ChannelIndex? GetChannelIndex(Channel c)
+ {
+ (ChannelIndex Release, SemVersion Version)? latestRelease = null;
+ foreach (var release in this.ChannelIndices)
+ {
+ var supportPhase = release.SupportPhase.ToLowerInvariant();
+ var releaseType = release.ReleaseType.ToLowerInvariant();
+ if (!SemVersion.TryParse(release.LatestRelease, SemVersionStyles.Strict, out var releaseVersion))
+ {
+ continue;
+ }
+ var found = (c, supportPhase, releaseType) switch
+ {
+ (Channel.Latest, "active", _)
+ or (Channel.Lts, "active", "lts")
+ or (Channel.Sts, "active", "sts")
+ or (Channel.Preview, "go-live", _)
+ or (Channel.Preview, "preview", _) => true,
+ (Channel.VersionedMajorMinor v, _, _) when v.ToString() == releaseVersion.ToMajorMinor() => true,
+ (Channel.VersionedFeature v, _, _) when v.ToString() == releaseVersion.ToFeature() => true,
+ _ => false
+ };
+ if (found &&
+ (latestRelease is not { } latest ||
+ SemVersion.ComparePrecedence(releaseVersion, latest.Version) > 0))
+ {
+ latestRelease = (release, releaseVersion);
+ }
+ }
+ return latestRelease?.Release;
+ }
+}
diff --git a/src/DNVM/InstallCommand.cs b/src/DNVM/InstallCommand.cs
new file mode 100644
index 000000000000..f5459cb8c422
--- /dev/null
+++ b/src/DNVM/InstallCommand.cs
@@ -0,0 +1,336 @@
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using Semver;
+
+using Spectre.Console;
+using Zio;
+
+namespace Microsoft.DotNet.DNVM;
+
+public static partial class InstallCommand
+{
+ public enum Result
+ {
+ Success = 0,
+ CouldntFetchReleaseIndex,
+ UnknownChannel,
+ ManifestFileCorrupted,
+ ManifestIOError,
+ InstallError
+ }
+
+ public sealed record Options
+ {
+ public required SemVersion SdkVersion { get; init; }
+ public bool Force { get; init; } = false;
+ public SdkDirName? SdkDir { get; init; } = null;
+ public bool Verbose { get; init; } = false;
+ }
+
+ public static Task Run(DnvmEnv env, Logger logger, DnvmSubCommand.InstallArgs args)
+ {
+ return Run(env, logger, new Options
+ {
+ SdkVersion = args.SdkVersion,
+ Force = args.Force ?? false,
+ SdkDir = args.SdkDir,
+ Verbose = args.Verbose ?? false,
+ });
+ }
+
+ public static async Task Run(DnvmEnv env, Logger logger, Options options)
+ {
+ var sdkDir = options.SdkDir ?? DnvmEnv.DefaultSdkDirName;
+
+ Manifest manifest;
+ try
+ {
+ manifest = await ManifestUtils.ReadOrCreateManifest(env);
+ }
+ catch (InvalidDataException)
+ {
+ logger.Error("Manifest file corrupted");
+ return Result.ManifestFileCorrupted;
+ }
+ catch (Exception e) when (e is not OperationCanceledException)
+ {
+ logger.Error("Error reading manifest file: " + e.Message);
+ return Result.ManifestIOError;
+ }
+
+ var sdkVersion = options.SdkVersion;
+ var channel = new Channel.VersionedMajorMinor(sdkVersion.Major, sdkVersion.Minor);
+
+ if (!options.Force && ManifestUtils.IsSdkInstalled(manifest, sdkVersion, sdkDir))
+ {
+ logger.Log($"Version {sdkVersion} is already installed in directory '{sdkDir.Name}'." +
+ " Skipping installation. To install anyway, pass --force.");
+ return Result.Success;
+ }
+
+ DotnetReleasesIndex versionIndex;
+ try
+ {
+ versionIndex = await DotnetReleasesIndex.FetchLatestIndex(env.HttpClient, env.DotnetFeedUrls);
+ }
+ catch (Exception e) when (e is not OperationCanceledException)
+ {
+ logger.Error($"Could not fetch the releases index: {e.Message}");
+ return Result.CouldntFetchReleaseIndex;
+ }
+
+ var result = await TryGetReleaseFromIndex(env.HttpClient, versionIndex, channel, sdkVersion)
+ ?? await TryGetReleaseFromServer(env, sdkVersion);
+ if (result is not ({ } sdkComponent, { } release))
+ {
+ logger.Error($"SDK version '{sdkVersion}' could not be found in .NET releases index or server.");
+ return Result.UnknownChannel;
+ }
+
+ var installError = await InstallSdk(env, manifest, sdkComponent, release, sdkDir, logger);
+ if (installError is not Result.Ok)
+ {
+ return Result.InstallError;
+ }
+
+ return Result.Success;
+ }
+
+ internal static async Task<(ChannelReleaseIndex.Component, ChannelReleaseIndex.Release)?> TryGetReleaseFromIndex(
+ ScopedHttpClient httpClient,
+ DotnetReleasesIndex versionIndex,
+ Channel channel,
+ SemVersion sdkVersion)
+ {
+ if (versionIndex.GetChannelIndex(channel) is { } channelIndex)
+ {
+ var channelReleaseIndexText = await httpClient.GetStringAsync(channelIndex.ChannelReleaseIndexUrl);
+ var releaseIndex = JsonSerializer.Deserialize(channelReleaseIndexText);
+ var result =
+ from r in releaseIndex.Releases
+ from sdk in r.Sdks
+ where sdk.Version == sdkVersion
+ select (sdk, r);
+
+ return result.FirstOrDefault();
+ }
+
+ return null;
+ }
+
+ private static async Task<(ChannelReleaseIndex.Component, ChannelReleaseIndex.Release)?> TryGetReleaseFromServer(
+ DnvmEnv env,
+ SemVersion sdkVersion)
+ {
+ foreach (var feedUrl in env.DotnetFeedUrls)
+ {
+ var downloadUrl = $"/Sdk/{sdkVersion}/productCommit-{Utilities.CurrentRID}.json";
+ try
+ {
+ var productCommitData = JsonSerializer.Deserialize(await env.HttpClient.GetStringAsync(feedUrl.TrimEnd('/') + downloadUrl));
+ if (productCommitData.Installer.Version != sdkVersion)
+ {
+ throw new InvalidOperationException("Fetched product commit data does not match requested SDK version");
+ }
+ var archiveName = ConstructArchiveName(versionString: sdkVersion.ToString(), Utilities.CurrentRID, Utilities.ZipSuffix);
+ var sdk = new ChannelReleaseIndex.Component
+ {
+ Version = sdkVersion,
+ Files = [ new ChannelReleaseIndex.File
+ {
+ Name = archiveName,
+ Url = MakeSdkUrl(feedUrl, sdkVersion, archiveName),
+ Rid = Utilities.CurrentRID.ToString(),
+ }]
+ };
+
+ var release = new ChannelReleaseIndex.Release
+ {
+ Sdk = sdk,
+ AspNetCore = new ChannelReleaseIndex.Component
+ {
+ Version = productCommitData.Aspnetcore.Version,
+ Files = []
+ },
+ Runtime = new()
+ {
+ Version = productCommitData.Runtime.Version,
+ Files = []
+ },
+ ReleaseVersion = productCommitData.Runtime.Version,
+ Sdks = [sdk],
+ WindowsDesktop = new()
+ {
+ Version = productCommitData.Windowsdesktop.Version,
+ Files = []
+ },
+ };
+ return (sdk, release);
+ }
+ catch
+ {
+ continue;
+ }
+ }
+ return null;
+ }
+
+ private static string MakeSdkUrl(string feedUrl, SemVersion version, string archiveName)
+ {
+ return feedUrl.TrimEnd('/') + $"/Sdk/{version}/{archiveName}";
+ }
+
+ [GenerateDeserialize]
+ private partial record CommitData
+ {
+ public required Component Installer { get; init; }
+ public required Component Sdk { get; init; }
+ public required Component Aspnetcore { get; init; }
+ public required Component Runtime { get; init; }
+ public required Component Windowsdesktop { get; init; }
+
+ [GenerateDeserialize]
+ public partial record Component
+ {
+ public required SemVersion Version { get; init; }
+ }
+ }
+
+ [Closed]
+ internal enum InstallError
+ {
+ DownloadFailed,
+ ExtractFailed
+ }
+
+ ///
+ /// Install the given SDK inside the given directory, and update the manifest. Does not update the channel manifest.
+ ///
+ /// Throws when manifest already contains the given SDK.
+ internal static async Task> InstallSdk(
+ DnvmEnv env,
+ Manifest manifest,
+ ChannelReleaseIndex.Component sdkComponent,
+ ChannelReleaseIndex.Release release,
+ SdkDirName sdkDir,
+ Logger logger)
+ {
+ var sdkVersion = sdkComponent.Version;
+
+ var ridString = Utilities.CurrentRID.ToString();
+ var sdkInstallPath = UPath.Root / sdkDir.Name;
+ var downloadFile = sdkComponent.Files.Single(f => f.Rid == ridString && f.Url.EndsWith(Utilities.ZipSuffix));
+ var link = downloadFile.Url;
+ logger.Info("Download link: " + link);
+
+ logger.Log($"Downloading SDK {sdkVersion} for {ridString}");
+ var curMuxerVersion = manifest.MuxerVersion(sdkDir);
+ var err = await InstallSdkToDir(curMuxerVersion, release.Runtime.Version, env.HttpClient, link, env.DnvmHomeFs, sdkInstallPath, env.TempFs, logger);
+ if (err is not null)
+ {
+ return err;
+ }
+
+ SelectCommand.SelectDir(logger, env, manifest.CurrentSdkDir, sdkDir);
+
+ var result = JsonSerializer.Serialize(manifest);
+ logger.Info("Existing manifest: " + result);
+
+ if (!ManifestUtils.IsSdkInstalled(manifest, sdkVersion, sdkDir))
+ {
+ manifest = manifest with
+ {
+ InstalledSdks = manifest.InstalledSdks.Add(new InstalledSdk
+ {
+ ReleaseVersion = release.ReleaseVersion,
+ RuntimeVersion = release.Runtime.Version,
+ AspNetVersion = release.AspNetCore.Version,
+ SdkVersion = sdkVersion,
+ SdkDirName = sdkDir,
+ })
+ };
+
+ env.WriteManifest(manifest);
+ }
+
+ return manifest;
+ }
+
+ internal static async Task InstallSdkToDir(
+ SemVersion? curMuxerVersion,
+ SemVersion runtimeVersion,
+ ScopedHttpClient httpClient,
+ string downloadUrl,
+ IFileSystem destFs,
+ UPath destPath,
+ IFileSystem tempFs,
+ Logger logger)
+ {
+ // The Release name does not contain a version
+ string archiveName = ConstructArchiveName(versionString: null, Utilities.CurrentRID, Utilities.ZipSuffix);
+
+ // Download and extract into a temp directory
+ using var tempDir = new DirectoryResource(Directory.CreateTempSubdirectory().FullName);
+ string archivePath = Path.Combine(tempDir.Path, archiveName);
+ logger.Info("Archive path: " + archivePath);
+
+ var downloadError = await logger.DownloadWithProgress(
+ httpClient,
+ archivePath,
+ downloadUrl,
+ "Downloading SDK");
+
+ if (downloadError is not null)
+ {
+ logger.Error(downloadError);
+ return InstallError.DownloadFailed;
+ }
+
+ logger.Log($"Installing to {destPath}");
+ string? extractResult = await Utilities.ExtractSdkToDir(
+ curMuxerVersion,
+ runtimeVersion,
+ archivePath,
+ tempFs,
+ destFs,
+ destPath);
+ File.Delete(archivePath);
+ if (extractResult != null)
+ {
+ logger.Error("Extract failed: " + extractResult);
+ return InstallError.ExtractFailed;
+ }
+
+ var dotnetExePath = destPath / Utilities.DotnetExeName;
+ if (!OperatingSystem.IsWindows())
+ {
+ logger.Info("chmoding downloaded host");
+ try
+ {
+ Utilities.ChmodExec(destFs, dotnetExePath);
+ }
+ catch (Exception e)
+ {
+ logger.Error("chmod failed: " + e.Message);
+ return InstallError.ExtractFailed;
+ }
+ }
+
+ return null;
+ }
+
+ internal static string ConstructArchiveName(
+ string? versionString,
+ RID rid,
+ string suffix)
+ {
+ return versionString is null
+ ? $"dotnet-sdk-{rid}{suffix}"
+ : $"dotnet-sdk-{versionString}-{rid}{suffix}";
+ }
+}
diff --git a/src/DNVM/ListCommand.cs b/src/DNVM/ListCommand.cs
new file mode 100644
index 000000000000..74592379d656
--- /dev/null
+++ b/src/DNVM/ListCommand.cs
@@ -0,0 +1,62 @@
+
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Spectre.Console;
+using Zio;
+
+namespace Microsoft.DotNet.DNVM;
+
+public static class ListCommand
+{
+ ///
+ /// Prints a list of installed SDK versions and their locations.
+ public static async Task Run(Logger logger, DnvmEnv home)
+ {
+ Manifest manifest;
+ try
+ {
+ manifest = await home.ReadManifest();
+ }
+ catch (Exception e)
+ {
+ Environment.FailFast("Error reading manifest: ", e);
+ // unreachable
+ return 1;
+ }
+
+ PrintSdks(logger, manifest, home.RealPath(UPath.Root));
+
+ return 0;
+ }
+
+ public static void PrintSdks(Logger logger, Manifest manifest, string homePath)
+ {
+ logger.Log($"DNVM_HOME: {homePath}");
+ logger.Log();
+ logger.Log("Installed SDKs:");
+ logger.Log();
+ var table = new Table();
+ table.AddColumn(new TableColumn(" "));
+ table.AddColumn("Version");
+ table.AddColumn("Channel");
+ table.AddColumn("Location");
+ foreach (var sdk in manifest.InstalledSdks)
+ {
+ string selected = manifest.CurrentSdkDir == sdk.SdkDirName ? "*" : " ";
+ var channels = manifest.RegisteredChannels
+ .Where(c => c.InstalledSdkVersions.Contains(sdk.SdkVersion))
+ .Select(c => c.ChannelName.GetLowerName());
+ table.AddRow(selected, sdk.SdkVersion.ToString(), string.Join(", ", channels), sdk.SdkDirName.Name);
+ }
+ logger.Console.Write(table);
+
+ logger.Log();
+ logger.Log("Tracked channels:");
+ logger.Log();
+ foreach (var c in manifest.TrackedChannels())
+ {
+ logger.Log($" • {c.ChannelName.GetLowerName()}");
+ }
+ }
+}
diff --git a/src/DNVM/Logger.cs b/src/DNVM/Logger.cs
new file mode 100644
index 000000000000..6234b930a5b0
--- /dev/null
+++ b/src/DNVM/Logger.cs
@@ -0,0 +1,58 @@
+using System;
+using Spectre.Console;
+
+namespace Microsoft.DotNet.DNVM;
+
+public enum LogLevel
+{
+ Error = 1,
+ Warn,
+ Normal,
+ Info,
+}
+
+public sealed record Logger(IAnsiConsole Console)
+{
+ // Mutable for now, should be immutable once the command line parser supports global options
+ public LogLevel LogLevel = LogLevel.Normal;
+
+ public void Error(string msg)
+ {
+ if (LogLevel >= LogLevel.Error)
+ {
+ Console.MarkupLineInterpolated($"{Environment.NewLine}[default on red]Error[/]: {msg}{Environment.NewLine}");
+ }
+ }
+
+ public void Info(string msg)
+ {
+ if (LogLevel >= LogLevel.Info)
+ {
+ Console.WriteLine($"Info({DateTime.UtcNow.TimeOfDay}): {msg}");
+ }
+ }
+
+ public void Warn(string msg)
+ {
+ if (LogLevel >= LogLevel.Warn)
+ {
+ Console.MarkupLineInterpolated($"{Environment.NewLine}[default on yellow]Warning[/]: {msg}{Environment.NewLine}");
+ }
+ }
+
+ public void Log()
+ {
+ if (LogLevel >= LogLevel.Normal)
+ {
+ Console.WriteLine();
+ }
+ }
+
+ public void Log(string msg)
+ {
+ if (LogLevel >= LogLevel.Normal)
+ {
+ Console.WriteLine(msg);
+ }
+ }
+}
diff --git a/src/DNVM/ManifestSchema/Manifest.cs b/src/DNVM/ManifestSchema/Manifest.cs
new file mode 100644
index 000000000000..e1363ad69f88
--- /dev/null
+++ b/src/DNVM/ManifestSchema/Manifest.cs
@@ -0,0 +1,117 @@
+
+using System;
+using System.Linq;
+using Semver;
+using Serde;
+using StaticCs.Collections;
+
+namespace Dnvm;
+
+[GenerateSerde]
+public sealed partial record Manifest
+{
+ public static readonly Manifest Empty = new();
+
+ // Serde doesn't serialize consts, so we have a separate property below for serialization.
+ public const int VersionField = 8;
+
+ [SerdeMemberOptions(SkipDeserialize = true)]
+ public int Version => VersionField;
+
+ public bool PreviewsEnabled { get; init; } = false;
+ public SdkDirName CurrentSdkDir { get; init; } = DnvmEnv.DefaultSdkDirName;
+ public EqArray InstalledSdks { get; init; } = [];
+ public EqArray RegisteredChannels { get; init; } = [];
+
+ public Manifest TrackChannel(RegisteredChannel channel)
+ {
+ var existing = RegisteredChannels.FirstOrNull(c =>
+ c.ChannelName == channel.ChannelName && c.SdkDirName == channel.SdkDirName);
+ if (existing is null)
+ {
+ return this with
+ {
+ RegisteredChannels = RegisteredChannels.Add(channel)
+ };
+ }
+ else if (existing is { Untracked: true })
+ {
+ var newVersions = existing.InstalledSdkVersions.Concat(channel.InstalledSdkVersions).Distinct().ToEq();
+ return this with
+ {
+ RegisteredChannels = RegisteredChannels.Replace(existing, existing with
+ {
+ InstalledSdkVersions = newVersions,
+ Untracked = false,
+ })
+ };
+ }
+ throw new InvalidOperationException("Channel already tracked");
+ }
+
+ internal Manifest UntrackChannel(Channel channel)
+ {
+ return this with
+ {
+ RegisteredChannels = RegisteredChannels.Select(c =>
+ {
+ if (c.ChannelName == channel)
+ {
+ return c with { Untracked = true };
+ }
+ return c;
+ }).ToEq()
+ };
+ }
+}
+
+[GenerateSerde]
+public partial record RegisteredChannel
+{
+ public required Channel ChannelName { get; init; }
+ public required SdkDirName SdkDirName { get; init; }
+ [SerdeMemberOptions(
+ SerializeProxy = typeof(EqArrayProxy.Ser),
+ DeserializeProxy = typeof(EqArrayProxy.De))]
+ public EqArray InstalledSdkVersions { get; init; } = EqArray.Empty;
+ public bool Untracked { get; init; } = false;
+}
+
+[GenerateSerde]
+public partial record InstalledSdk
+{
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion ReleaseVersion { get; init; }
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion SdkVersion { get; init; }
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion RuntimeVersion { get; init; }
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion AspNetVersion { get; init; }
+
+ public SdkDirName SdkDirName { get; init; } = DnvmEnv.DefaultSdkDirName;
+}
+
+public static partial class ManifestConvert
+{
+ public static Manifest Convert(this ManifestV7 v7) => new Manifest
+ {
+ InstalledSdks = v7.InstalledSdks.SelectAsArray(v => v.Convert()).ToEq(),
+ RegisteredChannels = v7.RegisteredChannels.SelectAsArray(c => c.Convert()).ToEq(),
+ };
+
+ public static InstalledSdk Convert(this InstalledSdkV7 v7) => new InstalledSdk {
+ ReleaseVersion = v7.ReleaseVersion,
+ SdkVersion = v7.SdkVersion,
+ RuntimeVersion = v7.RuntimeVersion,
+ AspNetVersion = v7.AspNetVersion,
+ SdkDirName = v7.SdkDirName,
+ };
+
+ public static RegisteredChannel Convert(this RegisteredChannelV7 v7) => new RegisteredChannel {
+ ChannelName = v7.ChannelName,
+ SdkDirName = v7.SdkDirName,
+ InstalledSdkVersions = v7.InstalledSdkVersions,
+ Untracked = v7.Untracked,
+ };
+}
\ No newline at end of file
diff --git a/src/DNVM/ManifestSchema/ManifestV1.cs b/src/DNVM/ManifestSchema/ManifestV1.cs
new file mode 100644
index 000000000000..576b95db2e5f
--- /dev/null
+++ b/src/DNVM/ManifestSchema/ManifestV1.cs
@@ -0,0 +1,14 @@
+
+using System.Collections.Immutable;
+using Serde;
+
+namespace Dnvm;
+
+[GenerateDeserialize]
+internal sealed partial record ManifestV1
+{
+ public ImmutableArray Workloads { get; init; } = ImmutableArray.Empty;
+
+ [GenerateSerde]
+ internal sealed partial record Workload(string Version);
+}
\ No newline at end of file
diff --git a/src/DNVM/ManifestSchema/ManifestV2.cs b/src/DNVM/ManifestSchema/ManifestV2.cs
new file mode 100644
index 000000000000..4e704892135b
--- /dev/null
+++ b/src/DNVM/ManifestSchema/ManifestV2.cs
@@ -0,0 +1,81 @@
+
+using System;
+using System.Collections.Immutable;
+using System.Linq;
+using Serde;
+
+namespace Dnvm;
+
+[GenerateSerde]
+internal sealed partial record ManifestV2
+{
+ // Serde doesn't serialize consts, so we have a separate property below for serialization.
+ public const int VersionField = 2;
+
+ [SerdeMemberOptions(SkipDeserialize = true)]
+ public int Version => VersionField;
+ public required ImmutableArray InstalledSdkVersions { get; init; }
+ public required ImmutableArray TrackedChannels { get; init; }
+
+ public override string ToString()
+ {
+ return $"Manifest {{ Version = {Version}, "
+ + $"InstalledSdkVersion = [{InstalledSdkVersions.SeqToString()}, "
+ + $"TrackedChannels = [{TrackedChannels.SeqToString()}] }}";
+ }
+
+ public bool Equals(ManifestV2? other)
+ {
+ return other is not null && this.InstalledSdkVersions.SequenceEqual(other.InstalledSdkVersions) &&
+ this.TrackedChannels.SequenceEqual(other.TrackedChannels);
+ }
+
+ public override int GetHashCode()
+ {
+ int code = 0;
+ foreach (var item in InstalledSdkVersions)
+ {
+ code = HashCode.Combine(code, item);
+ }
+ foreach (var item in TrackedChannels)
+ {
+ code = HashCode.Combine(code, item);
+ }
+ return code;
+ }
+}
+
+[GenerateSerde]
+internal sealed partial record TrackedChannelV2
+{
+ public required Channel ChannelName { get; init; }
+ public ImmutableArray InstalledSdkVersions { get; init; }
+
+ public bool Equals(TrackedChannelV2? other)
+ {
+ return other is not null &&
+ this.ChannelName == other.ChannelName &&
+ this.InstalledSdkVersions.SequenceEqual(other.InstalledSdkVersions);
+ }
+
+ public override int GetHashCode()
+ {
+ int code = ChannelName.GetHashCode();
+ foreach (var item in InstalledSdkVersions)
+ {
+ code = HashCode.Combine(code, item);
+ }
+ return code;
+ }
+}
+
+static partial class ManifestV2Convert
+{
+ public static ManifestV2 Convert(this ManifestV1 v1)
+ {
+ return new ManifestV2 {
+ InstalledSdkVersions = v1.Workloads.Select(w => w.Version).ToImmutableArray(),
+ TrackedChannels = ImmutableArray.Empty
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/DNVM/ManifestSchema/ManifestV3.cs b/src/DNVM/ManifestSchema/ManifestV3.cs
new file mode 100644
index 000000000000..29fab4edd692
--- /dev/null
+++ b/src/DNVM/ManifestSchema/ManifestV3.cs
@@ -0,0 +1,140 @@
+
+using System;
+using System.Collections.Immutable;
+using System.Linq;
+using Serde;
+using StaticCs.Collections;
+
+namespace Dnvm;
+
+[GenerateSerde]
+public sealed partial record ManifestV3
+{
+ public static readonly ManifestV3 Empty = new();
+
+ // Serde doesn't serialize consts, so we have a separate property below for serialization.
+ public const int VersionField = 3;
+
+ [SerdeMemberOptions(SkipDeserialize = true)]
+ public static int Version => VersionField;
+
+ public ImmutableArray InstalledSdkVersions { get; init; } = ImmutableArray.Empty;
+ public ImmutableArray TrackedChannels { get; init; } = ImmutableArray.Empty;
+
+ public override string ToString()
+ {
+ return $"Manifest {{ Version = {Version}, "
+ + $"InstalledSdkVersion = [{InstalledSdkVersions.SeqToString()}, "
+ + $"TrackedChannels = [{TrackedChannels.SeqToString()}] }}";
+ }
+
+ public bool Equals(ManifestV3? other)
+ {
+ return other is not null && InstalledSdkVersions.SequenceEqual(other.InstalledSdkVersions) &&
+ TrackedChannels.SequenceEqual(other.TrackedChannels);
+ }
+
+ public override int GetHashCode()
+ {
+ int code = 0;
+ foreach (var item in InstalledSdkVersions)
+ {
+ code = HashCode.Combine(code, item);
+ }
+ foreach (var item in TrackedChannels)
+ {
+ code = HashCode.Combine(code, item);
+ }
+ return code;
+ }
+}
+
+[GenerateSerde]
+public sealed partial record TrackedChannelV3
+{
+ public required Channel ChannelName { get; init; }
+ public required SdkDirName SdkDirName { get; init; }
+ public ImmutableArray InstalledSdkVersions { get; init; } = ImmutableArray.Empty;
+
+ public bool Equals(TrackedChannelV3? other)
+ {
+ return other is not null &&
+ ChannelName == other.ChannelName &&
+ SdkDirName == other.SdkDirName &&
+ InstalledSdkVersions.SequenceEqual(other.InstalledSdkVersions);
+ }
+
+ public override int GetHashCode()
+ {
+ int code = 0;
+ code = HashCode.Combine(code, ChannelName);
+ code = HashCode.Combine(code, SdkDirName);
+ foreach (string item in InstalledSdkVersions)
+ {
+ code = HashCode.Combine(code, item);
+ }
+ return code;
+ }
+}
+
+[GenerateSerde]
+public sealed partial record InstalledSdkV3
+{
+ public required string Version { get; init; }
+ public SdkDirName SdkDirName { get; init; } = DnvmEnv.DefaultSdkDirName;
+}
+
+static class ManifestConvertV3
+{
+ internal static ManifestV3 Convert(this ManifestV2 v2)
+ {
+ return new ManifestV3 {
+ InstalledSdkVersions = v2.InstalledSdkVersions.Select(v => new InstalledSdkV3 {
+ Version = v,
+ // Before V3, all SDKs were installed to the default dir
+ SdkDirName = DnvmEnv.DefaultSdkDirName
+ }).ToImmutableArray(),
+ TrackedChannels = v2.TrackedChannels.Select(c => new TrackedChannelV3 {
+ ChannelName = c.ChannelName,
+ SdkDirName = DnvmEnv.DefaultSdkDirName,
+ InstalledSdkVersions = c.InstalledSdkVersions
+ }).ToImmutableArray(),
+ };
+ }
+}
+
+public static partial class ManifestV3Utils
+{
+ public static ManifestV3 AddSdk(this ManifestV3 manifest, InstalledSdkV3 sdk, Channel c)
+ {
+ ManifestV3 newManifest;
+ if (manifest.TrackedChannels.FirstOrNull(x => x.ChannelName == c) is { } trackedChannel)
+ {
+ if (trackedChannel.InstalledSdkVersions.Contains(sdk.Version))
+ {
+ return manifest;
+ }
+ newManifest = manifest with
+ {
+ TrackedChannels = manifest.TrackedChannels.Select(x => x.ChannelName == c
+ ? x with { InstalledSdkVersions = x.InstalledSdkVersions.Add(sdk.Version) }
+ : x).ToImmutableArray(),
+ InstalledSdkVersions = manifest.InstalledSdkVersions.Add(sdk)
+ };
+ }
+ else
+ {
+ newManifest = manifest with
+ {
+ TrackedChannels = manifest.TrackedChannels.Add(new TrackedChannelV3()
+ {
+ ChannelName = c,
+ SdkDirName = sdk.SdkDirName,
+ InstalledSdkVersions = ImmutableArray.Create(sdk.Version)
+ }),
+ InstalledSdkVersions = manifest.InstalledSdkVersions.Add(sdk)
+ };
+ }
+ return newManifest;
+ }
+}
\ No newline at end of file
diff --git a/src/DNVM/ManifestSchema/ManifestV4.cs b/src/DNVM/ManifestSchema/ManifestV4.cs
new file mode 100644
index 000000000000..aae78318483d
--- /dev/null
+++ b/src/DNVM/ManifestSchema/ManifestV4.cs
@@ -0,0 +1,156 @@
+
+using System;
+using System.Collections.Immutable;
+using System.Linq;
+using Serde;
+using StaticCs.Collections;
+
+namespace Dnvm;
+
+[GenerateSerde]
+public sealed partial record ManifestV4
+{
+ public static readonly ManifestV4 Empty = new();
+
+ // Serde doesn't serialize consts, so we have a separate property below for serialization.
+ public const int VersionField = 4;
+
+ [SerdeMemberOptions(SkipDeserialize = true)]
+ public int Version => VersionField;
+
+ public SdkDirName CurrentSdkDir { get; init; } = DnvmEnv.DefaultSdkDirName;
+ public ImmutableArray InstalledSdkVersions { get; init; } = ImmutableArray.Empty;
+ public ImmutableArray TrackedChannels { get; init; } = ImmutableArray.Empty;
+
+ public override string ToString()
+ {
+ return $"ManifestV4 {{ Version = {Version}, "
+ + $"InstalledSdkV4Version = [{InstalledSdkVersions.SeqToString()}, "
+ + $"TrackedChannelV4s = [{TrackedChannels.SeqToString()}] }}";
+ }
+
+ public bool Equals(ManifestV4? other)
+ {
+ return other is not null && InstalledSdkVersions.SequenceEqual(other.InstalledSdkVersions) &&
+ TrackedChannels.SequenceEqual(other.TrackedChannels);
+ }
+
+ public override int GetHashCode()
+ {
+ int code = 0;
+ foreach (var item in InstalledSdkVersions)
+ {
+ code = HashCode.Combine(code, item);
+ }
+ foreach (var item in TrackedChannels)
+ {
+ code = HashCode.Combine(code, item);
+ }
+ return code;
+ }
+
+ internal ManifestV4 Untrack(Channel channel)
+ {
+ return this with
+ {
+ TrackedChannels = TrackedChannels.Where(c => c.ChannelName != channel).ToImmutableArray()
+ };
+ }
+}
+
+[GenerateSerde]
+public sealed partial record TrackedChannelV4
+{
+ public required Channel ChannelName { get; init; }
+ public required SdkDirName SdkDirName { get; init; }
+ public ImmutableArray InstalledSdkVersions { get; init; } = ImmutableArray.Empty;
+
+ public bool Equals(TrackedChannelV4? other)
+ {
+ return other is not null &&
+ ChannelName == other.ChannelName &&
+ SdkDirName == other.SdkDirName &&
+ InstalledSdkVersions.SequenceEqual(other.InstalledSdkVersions);
+ }
+
+ public override int GetHashCode()
+ {
+ int code = 0;
+ code = HashCode.Combine(code, ChannelName);
+ code = HashCode.Combine(code, SdkDirName);
+ foreach (string item in InstalledSdkVersions)
+ {
+ code = HashCode.Combine(code, item);
+ }
+ return code;
+ }
+}
+
+[GenerateSerde]
+public sealed partial record InstalledSdkV4
+{
+ public required string Version { get; init; }
+ public SdkDirName SdkDirName { get; init; } = DnvmEnv.DefaultSdkDirName;
+}
+
+public static partial class ManifestV4Convert
+{
+ public static ManifestV4 Convert(this ManifestV3 v3)
+ {
+ return new ManifestV4
+ {
+ InstalledSdkVersions = v3.InstalledSdkVersions.SelectAsArray(v => v.Convert()),
+ TrackedChannels = v3.TrackedChannels.SelectAsArray(c => c.Convert()),
+ };
+ }
+
+ public static InstalledSdkV4 Convert(this InstalledSdkV3 v3)
+ {
+ return new InstalledSdkV4 {
+ SdkDirName = v3.SdkDirName,
+ Version = v3.Version,
+ };
+ }
+
+ public static TrackedChannelV4 Convert(this TrackedChannelV3 v3) => new TrackedChannelV4 {
+ ChannelName = v3.ChannelName,
+ SdkDirName = v3.SdkDirName,
+ InstalledSdkVersions = v3.InstalledSdkVersions,
+ };
+}
+
+public static partial class ManifestV4Utils
+{
+ public static ManifestV4 AddSdk(this ManifestV4 manifest, InstalledSdkV4 sdk, Channel c)
+ {
+ ManifestV4 newManifest;
+ if (manifest.TrackedChannels.FirstOrNull(x => x.ChannelName == c) is { } trackedChannel)
+ {
+ if (trackedChannel.InstalledSdkVersions.Contains(sdk.Version))
+ {
+ return manifest;
+ }
+ newManifest = manifest with
+ {
+ TrackedChannels = manifest.TrackedChannels.Select(x => x.ChannelName == c
+ ? x with { InstalledSdkVersions = x.InstalledSdkVersions.Add(sdk.Version) }
+ : x).ToImmutableArray(),
+ InstalledSdkVersions = manifest.InstalledSdkVersions.Add(sdk)
+ };
+ }
+ else
+ {
+ newManifest = manifest with
+ {
+ TrackedChannels = manifest.TrackedChannels.Add(new TrackedChannelV4()
+ {
+ ChannelName = c,
+ SdkDirName = sdk.SdkDirName,
+ InstalledSdkVersions = ImmutableArray.Create(sdk.Version)
+ }),
+ InstalledSdkVersions = manifest.InstalledSdkVersions.Add(sdk)
+ };
+ }
+ return newManifest;
+ }
+}
\ No newline at end of file
diff --git a/src/DNVM/ManifestSchema/ManifestV5.cs b/src/DNVM/ManifestSchema/ManifestV5.cs
new file mode 100644
index 000000000000..38b8b45e6235
--- /dev/null
+++ b/src/DNVM/ManifestSchema/ManifestV5.cs
@@ -0,0 +1,135 @@
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Semver;
+using Serde;
+using Serde.Json;
+
+namespace Dnvm;
+
+[GenerateSerde]
+public sealed partial record ManifestV5
+{
+ public static readonly ManifestV5 Empty = new();
+
+ // Serde doesn't serialize consts, so we have a separate property below for serialization.
+ public const int VersionField = 5;
+
+ [SerdeMemberOptions(SkipDeserialize = true)]
+ public int Version => VersionField;
+
+ public SdkDirName CurrentSdkDir { get; init; } = DnvmEnv.DefaultSdkDirName;
+ public EqArray InstalledSdkVersions { get; init; } = [];
+ public EqArray TrackedChannels { get; init; } = [];
+
+ internal ManifestV5 Untrack(Channel channel)
+ {
+ return this with
+ {
+ TrackedChannels = TrackedChannels.Where(c => c.ChannelName != channel).ToEq()
+ };
+ }
+}
+
+[GenerateSerde]
+public partial record TrackedChannelV5
+{
+ public required Channel ChannelName { get; init; }
+ public required SdkDirName SdkDirName { get; init; }
+ [SerdeMemberOptions(
+ SerializeProxy = typeof(EqArrayProxy.Ser),
+ DeserializeProxy = typeof(EqArrayProxy.De))]
+ public EqArray InstalledSdkVersions { get; init; } = EqArray.Empty;
+}
+
+[GenerateSerde]
+public partial record InstalledSdkV5
+{
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion ReleaseVersion { get; init; }
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion SdkVersion { get; init; }
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion RuntimeVersion { get; init; }
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion AspNetVersion { get; init; }
+
+ public SdkDirName SdkDirName { get; init; } = DnvmEnv.DefaultSdkDirName;
+
+ ///
+ /// Indicates which channel this SDK was installed from, if any.
+ ///
+ public Channel? Channel { get; init; } = null;
+}
+
+public static partial class ManifestV5Convert
+{
+ public static async Task Convert(this ManifestV4 v4, ScopedHttpClient httpClient, DotnetReleasesIndex releasesIndex)
+ {
+ var channelMemo = new SortedDictionary(SemVersion.SortOrderComparer);
+
+ var getChannelIndex = async (SemVersion majorMinor) =>
+ {
+ if (channelMemo.TryGetValue(majorMinor, out var channelReleaseIndex))
+ {
+ return channelReleaseIndex;
+ }
+
+ var channelRelease = releasesIndex.ChannelIndices.Single(r => r.MajorMinorVersion == majorMinor.ToMajorMinor());
+ channelReleaseIndex = JsonSerializer.Deserialize(
+ await httpClient.GetStringAsync(channelRelease.ChannelReleaseIndexUrl));
+ channelMemo[majorMinor] = channelReleaseIndex;
+ return channelReleaseIndex;
+ };
+
+ return new ManifestV5
+ {
+ InstalledSdkVersions = (await v4.InstalledSdkVersions.SelectAsArray(v => v.Convert(v4, getChannelIndex))).ToEq(),
+ TrackedChannels = v4.TrackedChannels.SelectAsArray(c => c.Convert()).ToEq(),
+ };
+ }
+
+ public static async Task Convert(
+ this InstalledSdkV4 v4,
+ ManifestV4 manifestV4,
+ Func> getChannelIndex)
+ {
+ // Take the major and minor version from the installed SDK and use it to find the corresponding
+ // version in the releases index. Then grab the component versions from that release and fill
+ // in the remaining sections in the InstalledSdkV5
+ var v4Version = SemVersion.Parse(v4.Version, SemVersionStyles.Strict);
+ var majorMinorVersion = new SemVersion(v4Version.Major, v4Version.Minor);
+
+ var channelReleaseIndex = await getChannelIndex(majorMinorVersion);
+ var exactRelease = channelReleaseIndex.Releases
+ .Where(r => r.Sdks.Any(s => s.Version == v4Version))
+ .Single();
+
+
+ Channel? channel = (v4Version.Major, v4Version.Minor) switch {
+ (6, 0) => new Channel.Lts(),
+ (7, 0) => new Channel.Latest(),
+ (8, 0) => new Channel.Preview(),
+ _ => manifestV4.TrackedChannels
+ .SingleOrNull(c => c.SdkDirName == v4.SdkDirName)?.ChannelName
+ } ;
+
+ return new InstalledSdkV5()
+ {
+ ReleaseVersion = exactRelease.ReleaseVersion,
+ SdkVersion = v4Version,
+ RuntimeVersion = exactRelease.Runtime.Version,
+ AspNetVersion = exactRelease.AspNetCore.Version,
+ SdkDirName = v4.SdkDirName,
+ Channel = channel
+ };
+ }
+
+ public static TrackedChannelV5 Convert(this TrackedChannelV4 v3) => new TrackedChannelV5 {
+ ChannelName = v3.ChannelName,
+ SdkDirName = v3.SdkDirName,
+ InstalledSdkVersions = v3.InstalledSdkVersions.Select(v => SemVersion.Parse(v, SemVersionStyles.Strict)).ToEq(),
+ };
+}
\ No newline at end of file
diff --git a/src/DNVM/ManifestSchema/ManifestV6.cs b/src/DNVM/ManifestSchema/ManifestV6.cs
new file mode 100644
index 000000000000..dda99920deb0
--- /dev/null
+++ b/src/DNVM/ManifestSchema/ManifestV6.cs
@@ -0,0 +1,86 @@
+using System.Linq;
+using Semver;
+using Serde;
+
+namespace Dnvm;
+
+[GenerateSerde]
+public sealed partial record ManifestV6
+{
+ public static readonly ManifestV6 Empty = new();
+
+ // Serde doesn't serialize consts, so we have a separate property below for serialization.
+ public const int VersionField = 6;
+
+ [SerdeMemberOptions(SkipDeserialize = true)]
+ public int Version => VersionField;
+
+ public SdkDirName CurrentSdkDir { get; init; } = DnvmEnv.DefaultSdkDirName;
+ public EqArray InstalledSdks { get; init; } = [];
+ public EqArray TrackedChannels { get; init; } = [];
+
+ internal ManifestV6 Untrack(Channel channel)
+ {
+ return this with
+ {
+ TrackedChannels = TrackedChannels.Select(c =>
+ {
+ if (c.ChannelName == channel)
+ {
+ return c with { Untracked = true };
+ }
+ return c;
+ }).ToEq()
+ };
+ }
+}
+
+[GenerateSerde]
+public partial record TrackedChannelV6
+{
+ public required Channel ChannelName { get; init; }
+ public required SdkDirName SdkDirName { get; init; }
+ [SerdeMemberOptions(
+ SerializeProxy = typeof(EqArrayProxy.Ser),
+ DeserializeProxy = typeof(EqArrayProxy.De))]
+ public EqArray InstalledSdkVersions { get; init; } = EqArray.Empty;
+ public bool Untracked { get; init; } = false;
+}
+
+[GenerateSerde]
+public partial record InstalledSdkV6
+{
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion ReleaseVersion { get; init; }
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion SdkVersion { get; init; }
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion RuntimeVersion { get; init; }
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion AspNetVersion { get; init; }
+
+ public SdkDirName SdkDirName { get; init; } = DnvmEnv.DefaultSdkDirName;
+}
+
+public static partial class ManifestV6Convert
+{
+ public static ManifestV6 Convert(this ManifestV5 v5) => new ManifestV6
+ {
+ InstalledSdks = v5.InstalledSdkVersions.SelectAsArray(v => v.Convert()).ToEq(),
+ TrackedChannels = v5.TrackedChannels.SelectAsArray(c => c.Convert()).ToEq(),
+ };
+
+ public static InstalledSdkV6 Convert(this InstalledSdkV5 v5) => new InstalledSdkV6 {
+ ReleaseVersion = v5.ReleaseVersion,
+ SdkVersion = v5.SdkVersion,
+ RuntimeVersion = v5.RuntimeVersion,
+ AspNetVersion = v5.AspNetVersion,
+ SdkDirName = v5.SdkDirName,
+ };
+
+ public static TrackedChannelV6 Convert(this TrackedChannelV5 v5) => new TrackedChannelV6 {
+ ChannelName = v5.ChannelName,
+ SdkDirName = v5.SdkDirName,
+ InstalledSdkVersions = v5.InstalledSdkVersions
+ };
+}
\ No newline at end of file
diff --git a/src/DNVM/ManifestSchema/ManifestV7.cs b/src/DNVM/ManifestSchema/ManifestV7.cs
new file mode 100644
index 000000000000..72545d85aa7b
--- /dev/null
+++ b/src/DNVM/ManifestSchema/ManifestV7.cs
@@ -0,0 +1,116 @@
+
+using System;
+using System.Linq;
+using Semver;
+using Serde;
+using StaticCs.Collections;
+
+namespace Dnvm;
+
+[GenerateSerde]
+public sealed partial record ManifestV7
+{
+ public static readonly ManifestV7 Empty = new();
+
+ // Serde doesn't serialize consts, so we have a separate property below for serialization.
+ public const int VersionField = 7;
+
+ [SerdeMemberOptions(SkipDeserialize = true)]
+ public int Version => VersionField;
+
+ public SdkDirName CurrentSdkDir { get; init; } = DnvmEnv.DefaultSdkDirName;
+ public EqArray InstalledSdks { get; init; } = [];
+ public EqArray RegisteredChannels { get; init; } = [];
+
+ internal ManifestV7 TrackChannel(RegisteredChannelV7 channel)
+ {
+ var existing = RegisteredChannels.FirstOrNull(c =>
+ c.ChannelName == channel.ChannelName && c.SdkDirName == channel.SdkDirName);
+ if (existing is null)
+ {
+ return this with
+ {
+ RegisteredChannels = RegisteredChannels.Add(channel)
+ };
+ }
+ else if (existing is { Untracked: true })
+ {
+ var newVersions = existing.InstalledSdkVersions.Concat(channel.InstalledSdkVersions).Distinct().ToEq();
+ return this with
+ {
+ RegisteredChannels = RegisteredChannels.Replace(existing, existing with
+ {
+ InstalledSdkVersions = newVersions,
+ Untracked = false,
+ })
+ };
+ }
+ throw new InvalidOperationException("Channel already tracked");
+ }
+
+ internal ManifestV7 UntrackChannel(Channel channel)
+ {
+ return this with
+ {
+ RegisteredChannels = RegisteredChannels.Select(c =>
+ {
+ if (c.ChannelName == channel)
+ {
+ return c with { Untracked = true };
+ }
+ return c;
+ }).ToEq()
+ };
+ }
+}
+
+[GenerateSerde]
+public partial record RegisteredChannelV7
+{
+ public required Channel ChannelName { get; init; }
+ public required SdkDirName SdkDirName { get; init; }
+ [SerdeMemberOptions(
+ SerializeProxy = typeof(EqArrayProxy.Ser),
+ DeserializeProxy = typeof(EqArrayProxy.De))]
+ public EqArray InstalledSdkVersions { get; init; } = EqArray.Empty;
+ public bool Untracked { get; init; } = false;
+}
+
+[GenerateSerde]
+public partial record InstalledSdkV7
+{
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion ReleaseVersion { get; init; }
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion SdkVersion { get; init; }
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion RuntimeVersion { get; init; }
+ [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))]
+ public required SemVersion AspNetVersion { get; init; }
+
+ public SdkDirName SdkDirName { get; init; } = DnvmEnv.DefaultSdkDirName;
+}
+
+public static partial class ManifestV7Convert
+{
+ public static ManifestV7 Convert(this ManifestV6 v6) => new ManifestV7
+ {
+ InstalledSdks = v6.InstalledSdks.SelectAsArray(v => v.Convert()).ToEq(),
+ RegisteredChannels = v6.TrackedChannels.SelectAsArray(c => c.Convert()).ToEq(),
+ };
+
+ public static InstalledSdkV7 Convert(this InstalledSdkV6 v6) => new InstalledSdkV7 {
+ ReleaseVersion = v6.ReleaseVersion,
+ SdkVersion = v6.SdkVersion,
+ RuntimeVersion = v6.RuntimeVersion,
+ AspNetVersion = v6.AspNetVersion,
+ SdkDirName = v6.SdkDirName,
+ };
+
+ public static RegisteredChannelV7 Convert(this TrackedChannelV6 v6) => new RegisteredChannelV7 {
+ ChannelName = v6.ChannelName,
+ SdkDirName = v6.SdkDirName,
+ InstalledSdkVersions = v6.InstalledSdkVersions,
+ Untracked = v6.Untracked,
+ };
+}
\ No newline at end of file
diff --git a/src/DNVM/ManifestUtils.cs b/src/DNVM/ManifestUtils.cs
new file mode 100644
index 000000000000..e4a34fa7775f
--- /dev/null
+++ b/src/DNVM/ManifestUtils.cs
@@ -0,0 +1,162 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Semver;
+
+namespace Microsoft.DotNet.DNVM;
+
+///
+/// Holds the simple name of a directory that contains one or more SDKs and lives under DNVM_HOME.
+/// This is a wrapper to prevent being used directly as a path.
+///
+public sealed partial record SdkDirName(string Name)
+{
+ public string Name { get; init; } = Name.ToLower();
+}
+
+public static partial class ManifestUtils
+{
+ public static async Task ReadOrCreateManifest(DnvmEnv fs)
+ {
+ try
+ {
+ return await fs.ReadManifest();
+ }
+ // Not found is expected
+ catch (Exception e) when (e is DirectoryNotFoundException or FileNotFoundException) { }
+
+ return Manifest.Empty;
+ }
+
+ public static EqArray TrackedChannels(this Manifest manifest)
+ {
+ return manifest.RegisteredChannels.Where(x => !x.Untracked).ToEq();
+ }
+
+ ///
+ /// Calculates the version of the installed muxer. This is
+ /// Max().
+ /// If no SDKs are installed, returns null.
+ ///
+ public static SemVersion? MuxerVersion(this Manifest manifest, SdkDirName dir)
+ {
+ var installedSdks = manifest
+ .InstalledSdks
+ .Where(s => s.SdkDirName == dir)
+ .ToList();
+ if (installedSdks.Count == 0)
+ {
+ return null;
+ }
+ return installedSdks
+ .Select(s => s.RuntimeVersion)
+ .Max(SemVersion.SortOrderComparer);
+ }
+
+ public static Manifest AddSdk(
+ this Manifest manifest,
+ SemVersion semVersion,
+ Channel? c = null,
+ SdkDirName? sdkDirParam = null)
+ {
+ if (sdkDirParam is not {} sdkDir)
+ {
+ sdkDir = DnvmEnv.DefaultSdkDirName;
+ }
+ var installedSdk = new InstalledSdk() {
+ SdkDirName = sdkDir,
+ SdkVersion = semVersion,
+ RuntimeVersion = semVersion,
+ AspNetVersion = semVersion,
+ ReleaseVersion = semVersion,
+ };
+ return manifest.AddSdk(installedSdk, c);
+ }
+
+ public static Manifest AddSdk(this Manifest manifest,
+ InstalledSdk sdk,
+ Channel? c = null)
+ {
+ var installedSdks = manifest.InstalledSdks;
+ if (!installedSdks.Contains(sdk))
+ {
+ installedSdks = installedSdks.Add(sdk);
+ }
+ EqArray allChannels = manifest.RegisteredChannels;
+ if (allChannels.FirstOrNull(x => !x.Untracked && x.ChannelName == c && x.SdkDirName == sdk.SdkDirName) is { } oldTracked)
+ {
+ var installedSdkVersions = oldTracked.InstalledSdkVersions;
+ var newTracked = installedSdkVersions.Contains(sdk.SdkVersion)
+ ? oldTracked
+ : oldTracked with {
+ InstalledSdkVersions = installedSdkVersions.Add(sdk.SdkVersion)
+ };
+ allChannels = allChannels.Replace(oldTracked, newTracked);
+ }
+ else if (c is not null)
+ {
+ allChannels = allChannels.Add(new RegisteredChannel {
+ ChannelName = c,
+ SdkDirName = sdk.SdkDirName,
+ InstalledSdkVersions = [ sdk.SdkVersion ]
+ });
+ }
+ return manifest with {
+ InstalledSdks = installedSdks,
+ RegisteredChannels = allChannels,
+ };
+ }
+
+ public static bool IsSdkInstalled(Manifest manifest, SemVersion version, SdkDirName dirName)
+ {
+ return manifest.InstalledSdks.Any(s => s.SdkVersion == version && s.SdkDirName == dirName);
+ }
+
+ ///
+ /// Either reads a manifest in the current format, or reads a
+ /// manifest in the old format and converts it to the new format.
+ ///
+ public static async Task DeserializeNewOrOldManifest(
+ ScopedHttpClient httpClient,
+ string manifestSrc,
+ IEnumerable releasesUrls)
+ {
+ var version = JsonSerializer.Deserialize(manifestSrc).Version;
+ // Handle versions that don't need the release index to convert
+ Manifest? manifest = version switch {
+ ManifestV5.VersionField => JsonSerializer.Deserialize(manifestSrc).Convert().Convert().Convert(),
+ ManifestV6.VersionField => JsonSerializer.Deserialize(manifestSrc).Convert().Convert(),
+ ManifestV7.VersionField => JsonSerializer.Deserialize(manifestSrc).Convert(),
+ Manifest.VersionField => JsonSerializer.Deserialize(manifestSrc),
+ _ => null
+ };
+ if (manifest is not null)
+ {
+ return manifest;
+ }
+
+ // Retrieve release index and convert
+ var releasesIndex = await DotnetReleasesIndex.FetchLatestIndex(httpClient, releasesUrls);
+ return version switch
+ {
+ // The first version didn't have a version field
+ null => (await JsonSerializer.Deserialize(manifestSrc)
+ .Convert().Convert().Convert().Convert(httpClient, releasesIndex)).Convert().Convert().Convert(),
+ ManifestV2.VersionField => (await JsonSerializer.Deserialize(manifestSrc)
+ .Convert().Convert().Convert(httpClient, releasesIndex)).Convert().Convert().Convert(),
+ ManifestV3.VersionField => (await JsonSerializer.Deserialize(manifestSrc)
+ .Convert().Convert(httpClient, releasesIndex)).Convert().Convert().Convert(),
+ ManifestV4.VersionField => (await JsonSerializer.Deserialize(manifestSrc)
+ .Convert(httpClient, releasesIndex)).Convert().Convert().Convert(),
+ _ => throw new InvalidDataException("Unknown manifest version: " + version)
+ };
+ }
+
+ private sealed partial class ManifestVersionOnly
+ {
+ public int? Version { get; init; }
+ }
+}
diff --git a/src/DNVM/Program.cs b/src/DNVM/Program.cs
new file mode 100644
index 000000000000..330dd54db65a
--- /dev/null
+++ b/src/DNVM/Program.cs
@@ -0,0 +1,82 @@
+using System.Net.Http;
+using System.Threading.Tasks;
+using Semver;
+using Spectre.Console;
+
+namespace Microsoft.DotNet.DNVM;
+
+public static class Program
+{
+ public static readonly SemVersion SemVer = SemVersion.Parse(GitVersionInformation.SemVer, SemVersionStyles.Strict);
+
+ public static async Task Main(string[] args)
+ {
+ var console = AnsiConsole.Console;
+ if (!console.Profile.Out.IsTerminal)
+ {
+ // Set the width to a large, but reasonable, value to avoid wrapping.
+ console.Profile.Width = 255;
+ }
+ console.WriteLine("dnvm " + SemVer + " " + GitVersionInformation.ShortSha);
+ console.WriteLine();
+ var logger = new Logger(console);
+
+ var parsedArgs = CommandLineArguments.TryParse(console, args);
+ if (parsedArgs is null)
+ {
+ // Help was requested, exit with success.
+ return 0;
+ }
+
+ // Self-install is special, since we don't know the DNVM_HOME yet.
+ if (parsedArgs.SubCommand is DnvmSubCommand.SelfInstallArgs selfInstallArgs)
+ {
+ return (int)await SelfInstallCommand.Run(logger, selfInstallArgs);
+ }
+
+ using var env = DnvmEnv.CreateDefault();
+ if (parsedArgs.SubCommand is null)
+ {
+ if (parsedArgs.EnableDnvmPreviews == true)
+ {
+ return await EnableDnvmPreviews(env);
+ }
+ else
+ {
+ // Help was requested, exit with success.
+ return 0;
+ }
+ }
+
+ return await Dnvm(env, logger, parsedArgs);
+ }
+
+ public static async Task EnableDnvmPreviews(DnvmEnv env)
+ {
+ var manifest = await ManifestUtils.ReadOrCreateManifest(env);
+ manifest = manifest with { PreviewsEnabled = true };
+ env.WriteManifest(manifest);
+ return 0;
+ }
+
+ internal static async Task Dnvm(DnvmEnv env, Logger logger, DnvmArgs args)
+ {
+ return args.SubCommand switch
+ {
+ DnvmSubCommand.TrackArgs a => (int)await TrackCommand.Run(env, logger, a),
+ DnvmSubCommand.InstallArgs a => (int)await InstallCommand.Run(env, logger, a),
+ DnvmSubCommand.UpdateArgs a => (int)await UpdateCommand.Run(env, logger, a),
+ DnvmSubCommand.ListArgs => (int)await ListCommand.Run(logger, env),
+ DnvmSubCommand.SelectArgs a => (int)await SelectCommand.Run(env, logger, new(a.SdkDirName)),
+ DnvmSubCommand.UntrackArgs a => await UntrackCommand.Run(env, logger, a.Channel),
+ DnvmSubCommand.UninstallArgs a => await UninstallCommand.Run(env, logger, a.SdkVersion, a.SdkDir),
+ DnvmSubCommand.PruneArgs a => await PruneCommand.Run(env, logger, a),
+ DnvmSubCommand.RestoreArgs a => await RestoreCommand.Run(env, logger, a) switch {
+ Result.Ok => 0,
+ Result.Err x => (int)x.Value,
+ },
+
+ DnvmSubCommand.SelfInstallArgs => throw ExceptionUtilities.Unreachable,
+ };
+ }
+}
diff --git a/src/DNVM/Resources/env.sh b/src/DNVM/Resources/env.sh
new file mode 100644
index 000000000000..9f488e71d69b
--- /dev/null
+++ b/src/DNVM/Resources/env.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+# Prepend dnvm and SDK dirs to the path, unless already there
+case ":${PATH}:" in
+ *:"{install_loc}":*)
+ ;;
+ *)
+ export PATH="{install_loc}:$PATH"
+ ;;
+esac
+# Prepend global tools location
+case ":${PATH}:" in
+ *:"$HOME/.dotnet/tools":*)
+ ;;
+ *)
+ export PATH="$HOME/.dotnet/tools:$PATH"
+ ;;
+esac
+export DOTNET_ROOT="{sdk_install_loc}"
\ No newline at end of file
diff --git a/src/DNVM/ScalarDeserializer.cs b/src/DNVM/ScalarDeserializer.cs
new file mode 100644
index 000000000000..7f82bddb7865
--- /dev/null
+++ b/src/DNVM/ScalarDeserializer.cs
@@ -0,0 +1,52 @@
+
+using System;
+using System.Buffers;
+
+namespace Microsoft.DotNet.DNVM;
+
+public sealed class ScalarDeserializer(string s) : IDeserializer
+{
+ public bool ReadBool()
+ => bool.Parse(s);
+
+ public byte ReadU8()
+ => byte.Parse(s);
+
+ public char ReadChar()
+ => char.Parse(s);
+
+ public decimal ReadDecimal()
+ => decimal.Parse(s);
+
+ public double ReadF64() => double.Parse(s);
+
+ public float ReadF32() => float.Parse(s);
+
+ public short ReadI16() => short.Parse(s);
+
+ public int ReadI32() => int.Parse(s);
+
+ public long ReadI64() => long.Parse(s);
+
+ public sbyte ReadI8() => sbyte.Parse(s);
+
+ public string ReadString() => s;
+
+ public ushort ReadU16() => ushort.Parse(s);
+
+ public uint ReadU32() => uint.Parse(s);
+
+ public ulong ReadU64() => ulong.Parse(s);
+
+ public DateTime ReadDateTime()
+ => DateTime.Parse(s);
+ public void ReadBytes(IBufferWriter writer)
+ => throw new DeserializeException("Found bytes, expected scalar");
+
+ void IDisposable.Dispose() { }
+
+ T IDeserializer.ReadNullableRef(IDeserialize deserialize)
+ {
+ return deserialize.Deserialize(this);
+ }
+}
diff --git a/src/DNVM/SerdeWraps/SdkDirNameProxy.cs b/src/DNVM/SerdeWraps/SdkDirNameProxy.cs
new file mode 100644
index 000000000000..4c9bd4066f2f
--- /dev/null
+++ b/src/DNVM/SerdeWraps/SdkDirNameProxy.cs
@@ -0,0 +1,21 @@
+
+using Serde;
+
+namespace Dnvm;
+
+///
+/// Serializes the directly as a string.
+///
+internal sealed class SdkDirNameProxy : ISerde, ISerdeProvider
+{
+ public static SdkDirNameProxy Instance { get; } = new();
+ public ISerdeInfo SerdeInfo => StringProxy.SerdeInfo;
+
+ public SdkDirName Deserialize(IDeserializer deserializer)
+ => new SdkDirName(StringProxy.Instance.Deserialize(deserializer));
+
+ public void Serialize(SdkDirName value, ISerializer serializer)
+ {
+ serializer.WriteString(value.Name);
+ }
+}
\ No newline at end of file
diff --git a/src/DNVM/SerdeWraps/SemVersionProxy.cs b/src/DNVM/SerdeWraps/SemVersionProxy.cs
new file mode 100644
index 000000000000..c910672c90dd
--- /dev/null
+++ b/src/DNVM/SerdeWraps/SemVersionProxy.cs
@@ -0,0 +1,32 @@
+
+using System;
+using Semver;
+using Serde;
+
+namespace Dnvm;
+
+///
+/// Serializes as a string.
+///
+internal sealed class SemVersionProxy : ISerde, ISerdeProvider
+{
+ static SemVersionProxy ISerdeProvider.Instance { get; } = new SemVersionProxy();
+ private SemVersionProxy() { }
+
+ public ISerdeInfo SerdeInfo { get; } = StringProxy.SerdeInfo;
+
+ public SemVersion Deserialize(IDeserializer deserializer)
+ {
+ var str = deserializer.ReadString();
+ if (SemVersion.TryParse(str, SemVersionStyles.Strict, out var version))
+ {
+ return version;
+ }
+ throw new DeserializeException($"Version string '{str}' is not a valid SemVersion.");
+ }
+
+ public void Serialize(SemVersion value, ISerializer serializer)
+ {
+ serializer.WriteString(value.ToString());
+ }
+}
\ No newline at end of file
diff --git a/src/DNVM/UninstallCommand.cs b/src/DNVM/UninstallCommand.cs
new file mode 100644
index 000000000000..40fcbd869631
--- /dev/null
+++ b/src/DNVM/UninstallCommand.cs
@@ -0,0 +1,143 @@
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Semver;
+using Zio;
+
+namespace Microsoft.DotNet.DNVM;
+
+public sealed class UninstallCommand
+{
+ public static async Task Run(DnvmEnv env, Logger logger, SemVersion sdkVersion, SdkDirName? dir = null)
+ {
+ Manifest manifest;
+ try
+ {
+ manifest = await env.ReadManifest();
+ }
+ catch (Exception e)
+ {
+ logger.Error($"Error reading manifest: {e.Message}");
+ throw;
+ }
+
+ var runtimesToKeep = new HashSet<(SemVersion, SdkDirName)>();
+ var runtimesToRemove = new HashSet<(SemVersion, SdkDirName)>();
+ var sdksToRemove = new HashSet<(SemVersion, SdkDirName)>();
+ var aspnetToKeep = new HashSet<(SemVersion, SdkDirName)>();
+ var aspnetToRemove = new HashSet<(SemVersion, SdkDirName)>();
+ var winToKeep = new HashSet<(SemVersion, SdkDirName)>();
+ var winToRemove = new HashSet<(SemVersion, SdkDirName)>();
+
+ foreach (var installed in manifest.InstalledSdks)
+ {
+ if (installed.SdkVersion == sdkVersion && (dir is null || installed.SdkDirName == dir))
+ {
+ sdksToRemove.Add((installed.SdkVersion, installed.SdkDirName));
+ runtimesToRemove.Add((installed.RuntimeVersion, installed.SdkDirName));
+ aspnetToRemove.Add((installed.AspNetVersion, installed.SdkDirName));
+ winToRemove.Add((installed.ReleaseVersion, installed.SdkDirName));
+ }
+ else
+ {
+ runtimesToKeep.Add((installed.RuntimeVersion, installed.SdkDirName));
+ aspnetToKeep.Add((installed.AspNetVersion, installed.SdkDirName));
+ winToKeep.Add((installed.ReleaseVersion, installed.SdkDirName));
+ }
+ }
+
+ if (sdksToRemove.Count == 0)
+ {
+ logger.Error($"SDK version {sdkVersion} is not installed.");
+ return 1;
+ }
+
+ runtimesToRemove.ExceptWith(runtimesToKeep);
+ aspnetToRemove.ExceptWith(aspnetToKeep);
+ winToRemove.ExceptWith(winToKeep);
+
+ DeleteSdks(env, sdksToRemove, logger);
+ DeleteRuntimes(env, runtimesToRemove, logger);
+ DeleteAspnets(env, aspnetToRemove, logger);
+ DeleteWins(env, winToRemove, logger);
+
+ manifest = UninstallSdk(manifest, sdkVersion);
+ env.WriteManifest(manifest);
+
+ return 0;
+ }
+
+ private static void DeleteSdks(DnvmEnv env, IEnumerable<(SemVersion, SdkDirName)> sdks, Logger logger)
+ {
+ foreach (var (version, dir) in sdks)
+ {
+ var verString = version.ToString();
+ var sdkDir = DnvmEnv.GetSdkPath(dir) / "sdk" / verString;
+
+ logger.Log($"Deleting SDK {verString} from {dir.Name}");
+
+ env.DnvmHomeFs.DeleteDirectory(sdkDir, isRecursive: true);
+ }
+ }
+
+ private static void DeleteRuntimes(DnvmEnv env, IEnumerable<(SemVersion, SdkDirName)> runtimes, Logger logger)
+ {
+ foreach (var (version, dir) in runtimes)
+ {
+ var verString = version.ToString();
+ var netcoreappDir = DnvmEnv.GetSdkPath(dir) / "shared" / "Microsoft.NETCore.App" / verString;
+ var hostfxrDir = DnvmEnv.GetSdkPath(dir) / "host" / "fxr" / verString;
+ var packsHostDir = DnvmEnv.GetSdkPath(dir) / "packs" / $"Microsoft.NETCore.App.Host.{Utilities.CurrentRID}" / verString;
+
+ logger.Log($"Deleting Runtime {verString} from {dir.Name}");
+
+ env.DnvmHomeFs.DeleteDirectory(netcoreappDir, isRecursive: true);
+ env.DnvmHomeFs.DeleteDirectory(hostfxrDir, isRecursive: true);
+ env.DnvmHomeFs.DeleteDirectory(packsHostDir, isRecursive: true);
+ }
+ }
+
+ private static void DeleteAspnets(DnvmEnv env, IEnumerable<(SemVersion, SdkDirName)> aspnets, Logger logger)
+ {
+ foreach (var (version, dir) in aspnets)
+ {
+ var verString = version.ToString();
+ var aspnetDir = DnvmEnv.GetSdkPath(dir) / "shared" / "Microsoft.AspNetCore.App" / verString;
+ var templatesDir = DnvmEnv.GetSdkPath(dir) / "templates" / verString;
+
+ logger.Log($"Deleting ASP.NET pack {verString} from {dir.Name}");
+
+ env.DnvmHomeFs.DeleteDirectory(aspnetDir, isRecursive: true);
+ env.DnvmHomeFs.DeleteDirectory(templatesDir, isRecursive: true);
+ }
+ }
+
+ private static void DeleteWins(DnvmEnv env, IEnumerable<(SemVersion, SdkDirName)> wins, Logger logger)
+ {
+ foreach (var (version, dir) in wins)
+ {
+ var verString = version.ToString();
+ var winDir = DnvmEnv.GetSdkPath(dir) / "shared" / "Microsoft.WindowsDesktop.App" / verString;
+
+ if (env.DnvmHomeFs.DirectoryExists(winDir))
+ {
+ logger.Log($"Deleting Windows Desktop pack {verString} from {dir.Name}");
+
+ env.DnvmHomeFs.DeleteDirectory(winDir, isRecursive: true);
+ }
+ }
+ }
+
+ private static Manifest UninstallSdk(Manifest manifest, SemVersion sdkVersion)
+ {
+ // Delete SDK version from all directories
+ var newVersions = manifest.InstalledSdks
+ .Where(sdk => sdk.SdkVersion != sdkVersion)
+ .ToEq();
+ return manifest with {
+ InstalledSdks = newVersions,
+ };
+ }
+}
diff --git a/src/DNVM/Utilities/CancelScope.cs b/src/DNVM/Utilities/CancelScope.cs
new file mode 100644
index 000000000000..e071b28c0de7
--- /dev/null
+++ b/src/DNVM/Utilities/CancelScope.cs
@@ -0,0 +1,129 @@
+
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Dnvm;
+
+public sealed partial class CancelScope
+{
+ private readonly CancelScope? _parent;
+ private readonly CancellationTokenSource _cts;
+
+ private CancelScope(CancelScope? parent)
+ {
+ _parent = parent;
+ _cts = parent is null
+ ? new()
+ : CancellationTokenSource.CreateLinkedTokenSource(parent._cts.Token);
+ }
+}
+
+partial class CancelScope
+{
+ private static readonly AsyncLocal _current = new AsyncLocal();
+
+ public static CancelScope Current
+ {
+ get
+ {
+ if (_current.Value is null)
+ {
+ _current.Value = new CancelScope(null);
+ }
+ return _current.Value;
+ }
+ private set
+ {
+ if (value is null)
+ {
+ throw new ArgumentNullException(nameof(value));
+ }
+ _current.Value = value;
+ }
+ }
+
+ public static async Task WithCancelAfter(
+ TimeSpan delay,
+ Func func,
+ Action? onCanceled = null)
+ {
+ var parent = Current;
+ Debug.Assert(parent is not null);
+ var scope = new CancelScope(Current);
+ Current = scope;
+ try
+ {
+ scope._cts.CancelAfter(delay);
+ await func(scope);
+ }
+ catch (OperationCanceledException e) when (e.CancellationToken == scope._cts.Token)
+ {
+ onCanceled?.Invoke(e);
+ }
+ finally
+ {
+ scope._cts.Dispose();
+ Debug.Assert(parent is not null);
+ Current = parent;
+ }
+ }
+
+ public static void WithCancelAfter(
+ TimeSpan delay,
+ Action action,
+ Action? onCanceled = null)
+ {
+ WithCancelAfter(
+ delay,
+ scope => { action(scope); return Task.CompletedTask; },
+ onCanceled).GetAwaiter().GetResult();
+ }
+
+ public static T WithTimeoutAfter(
+ TimeSpan delay,
+ Func func,
+ Action? onCanceled = null)
+ {
+ return WithTimeoutAfter(
+ delay,
+ scope => Task.FromResult(func(scope)),
+ onCanceled).GetAwaiter().GetResult();
+ }
+
+ public static async Task WithTimeoutAfter(
+ TimeSpan delay,
+ Func> func,
+ Action? onCanceled = null)
+ {
+ var parent = Current;
+ Debug.Assert(parent is not null);
+ var scope = new CancelScope(Current);
+ Current = scope;
+ try
+ {
+ scope._cts.CancelAfter(delay);
+ return await func(scope);
+ }
+ catch (OperationCanceledException e) when (e.CancellationToken == scope._cts.Token)
+ {
+ onCanceled?.Invoke(e);
+ throw new TimeoutException("Operation timed out", e);
+ }
+ finally
+ {
+ scope._cts.Dispose();
+ Debug.Assert(parent is not null);
+ Current = parent;
+ }
+ }
+
+ public CancellationToken Token => _cts.Token;
+
+ public void Cancel()
+ {
+ _cts.Cancel();
+ _cts.Token.ThrowIfCancellationRequested();
+ }
+}
\ No newline at end of file
diff --git a/src/DNVM/Utilities/EqArray.cs b/src/DNVM/Utilities/EqArray.cs
new file mode 100644
index 000000000000..0e1b006b690e
--- /dev/null
+++ b/src/DNVM/Utilities/EqArray.cs
@@ -0,0 +1,90 @@
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using Serde;
+
+namespace Dnvm;
+
+public static class EqArray
+{
+ public static EqArray ToEq(this IEnumerable array) => new(array.ToImmutableArray());
+ public static EqArray ToEq(this ImmutableArray array) => new(array);
+ public static EqArray Create(params T[] array) => ImmutableArray.Create(array).ToEq();
+ public static EqArray Create(ReadOnlySpan span) => ImmutableArray.Create(span).ToEq();
+}
+
+[SerdeTypeOptions(Proxy = typeof(EqArrayProxy))]
+[CollectionBuilder(typeof(EqArray), nameof(EqArray.Create))]
+public readonly struct EqArray(ImmutableArray value) : IReadOnlyCollection, IEquatable>
+{
+ public ImmutableArray Array => value;
+
+ public static readonly EqArray Empty = ImmutableArray.Empty.ToEq();
+
+ public int Length => value.Length;
+
+ int IReadOnlyCollection.Count => value.Length;
+
+ public override bool Equals(object? obj) => obj is EqArray other && Equals(other);
+
+ public bool Equals(EqArray other) => value.SequenceEqual(other.Array);
+
+ public IEnumerator GetEnumerator() => ((IEnumerable)value).GetEnumerator();
+
+ public override int GetHashCode()
+ {
+ return value.Aggregate(0, (acc, item) => HashCode.Combine(acc, item));
+ }
+
+ public override string ToString()
+ {
+ return "[ " + string.Join(", ", value) + " ]";
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return ((IEnumerable)value).GetEnumerator();
+ }
+
+ public T this[int index] => value[index];
+
+ public EqArray Add(T item) => new(value.Add(item));
+
+ public EqArray Replace(T oldItem, T newItem) => new(value.Replace(oldItem, newItem));
+}
+
+public static class EqArrayProxy
+{
+ private static readonly ISerdeInfo s_typeInfo = Serde.SerdeInfo.MakeEnumerable(nameof(EqArray));
+ public sealed class Ser : ISerializeProvider>, ISerialize>
+ where TProvider : ISerializeProvider
+ {
+ public static readonly Ser Instance = new();
+ static ISerialize> ISerializeProvider>.Instance => Instance;
+
+ public ISerdeInfo SerdeInfo => s_typeInfo;
+
+ void ISerialize>.Serialize(EqArray value, ISerializer serializer)
+ {
+ ImmutableArrayProxy.Ser.Instance.Serialize(value.Array, serializer);
+ }
+ }
+
+ public sealed class De : IDeserializeProvider>, IDeserialize>
+ where TProvider : IDeserializeProvider
+ {
+ public static readonly De Instance = new();
+ static IDeserialize> IDeserializeProvider>.Instance => Instance;
+
+ public ISerdeInfo SerdeInfo => s_typeInfo;
+ EqArray IDeserialize>.Deserialize(IDeserializer deserializer)
+ {
+ return ImmutableArrayProxy.De.Instance.Deserialize(deserializer).ToEq();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/DNVM/Utilities/ExceptionUtilities.cs b/src/DNVM/Utilities/ExceptionUtilities.cs
new file mode 100644
index 000000000000..e3675097d1ff
--- /dev/null
+++ b/src/DNVM/Utilities/ExceptionUtilities.cs
@@ -0,0 +1,9 @@
+using System;
+
+namespace Dnvm
+{
+ internal class ExceptionUtilities
+ {
+ public static Exception Unreachable => new("This location is thought to be unreachable");
+ }
+}
\ No newline at end of file
diff --git a/src/DNVM/Utilities/Extensions.cs b/src/DNVM/Utilities/Extensions.cs
new file mode 100644
index 000000000000..64e17f6d4bf9
--- /dev/null
+++ b/src/DNVM/Utilities/Extensions.cs
@@ -0,0 +1,59 @@
+
+using System;
+using System.Collections.Immutable;
+using System.Net.Http;
+using System.Threading.Tasks;
+
+namespace Dnvm;
+
+internal static class ImmutableArrayExt
+{
+ public static async Task> SelectAsArray(this ImmutableArray e, Func> f)
+ {
+ var builder = ImmutableArray.CreateBuilder(e.Length);
+ foreach (var item in e)
+ {
+ builder.Add(await f(item));
+ }
+ return builder.MoveToImmutable();
+ }
+
+ public static T? SingleOrNull(this ImmutableArray e, Func func)
+ where T : class
+ {
+ T? result = null;
+ foreach (var elem in e)
+ {
+ if (func(elem))
+ {
+ if (result is not null)
+ {
+ return null;
+ }
+ result = elem;
+ }
+ }
+ return result;
+ }
+}
+
+internal static class ImmutableArrayExt2
+{
+ public static T? SingleOrNull(this ImmutableArray e, Func func)
+ where T : struct
+ {
+ T? result = null;
+ foreach (var elem in e)
+ {
+ if (func(elem))
+ {
+ if (result is not null)
+ {
+ return null;
+ }
+ result = elem;
+ }
+ }
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/src/DNVM/Utilities/ProcUtil.cs b/src/DNVM/Utilities/ProcUtil.cs
new file mode 100644
index 000000000000..00d7ecfba70c
--- /dev/null
+++ b/src/DNVM/Utilities/ProcUtil.cs
@@ -0,0 +1,72 @@
+
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Threading.Tasks;
+
+namespace Dnvm;
+
+public static class ProcUtil
+{
+ public static async Task RunWithOutput(string cmd, string args, Dictionary? envVars = null)
+ {
+ var psi = new ProcessStartInfo {
+ FileName = cmd,
+ Arguments = args,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ };
+ if (envVars is not null)
+ {
+ foreach (var (k, v) in envVars)
+ {
+ psi.Environment[k] = v;
+ }
+ }
+ var proc = Process.Start(psi);
+ if (proc is null)
+ {
+ throw new IOException($"Could not start process: " + cmd);
+ }
+
+ var @out = proc.StandardOutput.ReadToEndAsync();
+ var error = proc.StandardError.ReadToEndAsync();
+ await proc.WaitForExitAsync().ConfigureAwait(false);
+ return new ProcResult(
+ proc.ExitCode,
+ await @out.ConfigureAwait(false),
+ await error.ConfigureAwait(false));
+ }
+
+ public static async Task RunShell(
+ string scriptContent,
+ Dictionary? envVars = null)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = "/bin/bash",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ RedirectStandardInput = true
+ };
+ if (envVars is not null)
+ {
+ foreach (var (k, v) in envVars)
+ {
+ psi.Environment[k] = v;
+ }
+ }
+ var proc = Process.Start(psi)!;
+ await proc.StandardInput.WriteAsync(scriptContent);
+ proc.StandardInput.Close();
+ var @out = proc.StandardOutput.ReadToEndAsync();
+ var error = proc.StandardError.ReadToEndAsync();
+ await proc.WaitForExitAsync().ConfigureAwait(false);
+ return new ProcResult(
+ proc.ExitCode,
+ await @out.ConfigureAwait(false),
+ await error.ConfigureAwait(false));
+ }
+
+ public sealed record ProcResult(int ExitCode, string Out, string Error);
+}
\ No newline at end of file
diff --git a/src/DNVM/Utilities/ScopedHttpClient.cs b/src/DNVM/Utilities/ScopedHttpClient.cs
new file mode 100644
index 000000000000..5c786f377a59
--- /dev/null
+++ b/src/DNVM/Utilities/ScopedHttpClient.cs
@@ -0,0 +1,76 @@
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Dnvm;
+
+public sealed class ScopedHttpClient(HttpClient client)
+{
+ internal async Task GetAsync([StringSyntax("Uri")] string url)
+ {
+ return new ScopedHttpResponseMessage(
+ await client.GetAsync(url, CancelScope.Current.Token)
+ );
+ }
+
+ internal async Task GetAsync(
+ [StringSyntax("Uri")] string url,
+ HttpCompletionOption completionOption)
+ {
+ return new ScopedHttpResponseMessage(
+ await client.GetAsync(url, completionOption, CancelScope.Current.Token)
+ );
+ }
+
+ internal Task GetStringAsync(string url)
+ {
+ // This is a commonly used method for small requests, so we set a default timeout in case
+ // the caller forgets to set one
+ return CancelScope.WithTimeoutAfter(
+ DnvmEnv.DefaultTimeout,
+ _ => client.GetStringAsync(url, CancelScope.Current.Token)
+ );
+ }
+
+ internal Task GetStreamAsync(string uri)
+ => client.GetStreamAsync(uri, CancelScope.Current.Token);
+}
+
+public sealed class ScopedHttpResponseMessage(HttpResponseMessage response) : IDisposable
+{
+ public bool IsSuccessStatusCode => response.IsSuccessStatusCode;
+
+ public ScopedHttpContent Content => new(response.Content);
+
+ public void Dispose() => response.Dispose();
+
+ public void EnsureSuccessStatusCode() => response.EnsureSuccessStatusCode();
+}
+
+public sealed class ScopedHttpContent(HttpContent content)
+{
+ public HttpContentHeaders Headers => content.Headers;
+
+ public Task ReadAsStringAsync()
+ {
+ return content.ReadAsStringAsync(CancelScope.Current.Token);
+ }
+
+ public async Task ReadAsStreamAsync()
+ {
+ return new(await content.ReadAsStreamAsync(CancelScope.Current.Token));
+ }
+}
+
+public sealed class ScopedStream(Stream stream) : IDisposable
+{
+ public void Dispose() => stream.Dispose();
+
+ public ValueTask ReadAsync(Memory buffer)
+ => stream.ReadAsync(buffer, CancelScope.Current.Token);
+}
\ No newline at end of file
diff --git a/src/DNVM/Utilities/Utilities.cs b/src/DNVM/Utilities/Utilities.cs
new file mode 100644
index 000000000000..e8b4d4c84b2a
--- /dev/null
+++ b/src/DNVM/Utilities/Utilities.cs
@@ -0,0 +1,377 @@
+
+using System;
+using System.Buffers;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.IO.Compression;
+using System.IO.Enumeration;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Text;
+using System.Threading.Tasks;
+using Semver;
+using Serde;
+using Serde.Json;
+using Spectre.Console;
+using StaticCs;
+using Zio;
+using Zio.FileSystems;
+
+namespace Dnvm;
+
+///
+/// Deletes the given directory on disposal.
+///
+public sealed record DirectoryResource(
+ string Path,
+ bool Recursive = true) : IDisposable
+{
+ public void Dispose()
+ {
+ Directory.Delete(Path, recursive: Recursive);
+ }
+}
+
+public static class SpectreUtil
+{
+ public static Task DownloadWithProgress(
+ this Logger logger,
+ ScopedHttpClient client,
+ string filePath,
+ string url,
+ string description,
+ int? bufferSizeParam = null)
+ {
+ var console = logger.Console;
+ return console.Progress()
+ .AutoRefresh(true)
+ .Columns(
+ new TaskDescriptionColumn(),
+ new ProgressBarColumn(),
+ new PercentageColumn())
+ .StartAsync(async ctx =>
+ {
+ using var archiveResponse = await CancelScope.WithTimeoutAfter(DnvmEnv.DefaultTimeout,
+ _ => client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead));
+ if (!archiveResponse.IsSuccessStatusCode)
+ {
+ return await CancelScope.WithTimeoutAfter(DnvmEnv.DefaultTimeout,
+ _ => archiveResponse.Content.ReadAsStringAsync());
+ }
+
+ if (archiveResponse.Content.Headers.ContentLength is not { } contentLength)
+ {
+ throw new InvalidDataException("HTTP Content length is null");
+ }
+
+ // Use 1/100 of the file size as the buffer size, up to 1 MB.
+ const int oneMb = 1024 * 1024;
+ var bufferSize = bufferSizeParam ?? (int)Math.Min(contentLength / 100, oneMb);
+ logger.Info($"Buffer size: {bufferSize} bytes");
+
+ var progressTask = ctx.AddTask(description, maxValue: contentLength);
+ console.MarkupLine($"Starting download (size: {contentLength / oneMb:0.00} MB)");
+
+ using var tempArchiveFile = new FileStream(
+ filePath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None,
+ 64 * 1024, // 64kB
+ FileOptions.Asynchronous | FileOptions.SequentialScan);
+
+ using var archiveHttpStream = await CancelScope.WithTimeoutAfter(DnvmEnv.DefaultTimeout,
+ _ => archiveResponse.Content.ReadAsStreamAsync());
+
+ var buffer = ArrayPool.Shared.Rent(bufferSize);
+ while (true)
+ {
+ // We could have a timeout for downloading the file, but this is heavily dependent
+ // on the user's download speed and if they suspend/resume the process. Instead
+ // we'll rely on the user to cancel the download if it's taking too long.
+ var read = await archiveHttpStream.ReadAsync(buffer);
+ if (read == 0)
+ {
+ break;
+ }
+ // Writing to disk shouldn't time out, but we'll check for cancellation
+ await tempArchiveFile.WriteAsync(buffer.AsMemory(0, read), CancelScope.Current.Token);
+ progressTask.Increment(read);
+ ctx.Refresh();
+ }
+ await tempArchiveFile.FlushAsync(CancelScope.Current.Token);
+ ArrayPool.Shared.Return(buffer);
+ progressTask.StopTask();
+ return null;
+ });
+ }
+}
+
+public static class Utilities
+{
+ ///
+ /// Do our best to replace the target file with the source file. If the target file is locked,
+ /// we will try to delete it and then copy the source file over. If the target file cannot be
+ /// deleted, we will try to move it to the same folder with an additional ".old" suffix.
+ ///
+ public static void ForceReplaceFile(IFileSystem srcFs, UPath src, IFileSystem destFs, UPath dest)
+ {
+ try
+ {
+ // Does not throw if the file does not exist
+ destFs.DeleteFile(dest);
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ var destDir = dest.GetDirectory();
+ var destName = dest.GetName();
+ var destOld = destDir / (destName + ".old");
+ destFs.MoveFile(dest, destOld);
+ }
+ srcFs.MoveFileCross(src, destFs, dest);
+ }
+
+ public static readonly string ZipSuffix = Environment.OSVersion.Platform == PlatformID.Win32NT ? ".zip" : ".tar.gz";
+
+ [UnsupportedOSPlatform("windows")]
+ public static void ChmodExec(string path)
+ {
+ var mod = File.GetUnixFileMode(path);
+ File.SetUnixFileMode(path, mod | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute);
+ }
+
+ [UnsupportedOSPlatform("windows")]
+ public static void ChmodExec(IFileSystem vfs, UPath upath)
+ {
+ var realPath = vfs.ConvertPathToInternal(upath);
+ var mod = File.GetUnixFileMode(realPath);
+ File.SetUnixFileMode(realPath, mod | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute);
+ }
+
+ public static string ToMajorMinor(this SemVersion version) => $"{version.Major}.{version.Minor}";
+
+ public static string ToFeature(this SemVersion version)
+ {
+ int feature = version.Patch;
+ while (feature >= 10)
+ {
+ feature /= 10;
+ }
+
+ return $"{version.Major}.{version.Minor}.{feature}xx";
+ }
+
+ public static string SeqToString(this IEnumerable e)
+ {
+ return "[ " + string.Join(", ", e) + " ]";
+ }
+
+ public static ImmutableArray SelectAsArray(this ImmutableArray e, Func f)
+ {
+ var builder = ImmutableArray.CreateBuilder(e.Length);
+ foreach (var item in e)
+ {
+ builder.Add(f(item));
+ }
+ return builder.MoveToImmutable();
+ }
+
+ public static EqArray SelectAsArray(this EqArray e, Func f)
+ {
+ var builder = ImmutableArray.CreateBuilder(e.Length);
+ foreach (var item in e)
+ {
+ builder.Add(f(item));
+ }
+ return new(builder.MoveToImmutable());
+ }
+
+ public static readonly RID CurrentRID = new RID(
+ GetCurrentOSPlatform(),
+ RuntimeInformation.OSArchitecture,
+ RuntimeInformation.RuntimeIdentifier.Contains("musl") ? Libc.Musl : Libc.Default);
+
+ private static OSPlatform GetCurrentOSPlatform()
+ {
+ return RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? OSPlatform.OSX :
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? OSPlatform.Windows :
+ RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? OSPlatform.Linux :
+ throw new NotSupportedException("Current OS is not supported: " + RuntimeInformation.OSDescription);
+ }
+
+ [UnconditionalSuppressMessage("SingleFile", "IL3000", Justification = "Checks for empty location")]
+ public static bool IsSingleFile => Assembly.GetExecutingAssembly()?.Location == "";
+
+ public static string ProcessPath = Environment.ProcessPath
+ ?? throw new InvalidOperationException("Cannot find exe name");
+
+ public static string ExeSuffix = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? ".exe"
+ : "";
+
+ public static string DnvmExeName = "dnvm" + ExeSuffix;
+ public static string DotnetExeName = "dotnet" + ExeSuffix;
+
+ public static async Task ExtractArchiveToDir(string archivePath, string dirPath)
+ {
+ Directory.CreateDirectory(dirPath);
+ if (Utilities.CurrentRID.OS != OSPlatform.Windows)
+ {
+ var procResult = await ProcUtil.RunWithOutput("tar", $"-xzf \"{archivePath}\" -C \"{dirPath}\"");
+ return procResult.ExitCode == 0 ? null : procResult.Error;
+ }
+ else
+ {
+ try
+ {
+ ZipFile.ExtractToDirectory(archivePath, dirPath, overwriteFiles: true);
+ }
+ catch (Exception e)
+ {
+ return e.Message;
+ }
+ }
+ return null;
+ }
+
+ public static async Task ExtractSdkToDir(
+ SemVersion? existingMuxerVersion,
+ SemVersion runtimeVersion,
+ string archivePath,
+ IFileSystem tempFs,
+ IFileSystem destFs,
+ UPath destDir)
+ {
+ destFs.CreateDirectory(destDir);
+ var tempExtractDir = UPath.Root / Path.GetRandomFileName();
+ tempFs.CreateDirectory(tempExtractDir);
+ using var tempRealPath = new DirectoryResource(tempFs.ConvertPathToInternal(tempExtractDir));
+ if (Utilities.CurrentRID.OS != OSPlatform.Windows)
+ {
+ var procResult = await ProcUtil.RunWithOutput("tar", $"-xzf \"{archivePath}\" -C \"{tempRealPath.Path}\"");
+ if (procResult.ExitCode != 0)
+ {
+ return procResult.Error;
+ }
+ }
+ else
+ {
+ try
+ {
+ ZipFile.ExtractToDirectory(archivePath, tempRealPath.Path, overwriteFiles: true);
+ }
+ catch (Exception e)
+ {
+ return e.Message;
+ }
+ }
+
+ try
+ {
+ // We want to copy over all the files from the extraction directory to the target
+ // directory, with one exception: the top-level "dotnet exe" (muxer). That has special logic.
+ CopyMuxer(existingMuxerVersion, runtimeVersion, tempFs, tempExtractDir, destFs, destDir);
+
+ var extractFullName = tempExtractDir.FullName;
+ foreach (var dir in tempFs.EnumerateDirectories(tempExtractDir))
+ {
+ destFs.CreateDirectory(destDir / dir.GetName());
+ foreach (var fsItem in tempFs.EnumerateItems(dir, SearchOption.AllDirectories))
+ {
+ var relativePath = fsItem.Path.FullName[extractFullName.Length..].TrimStart('/');
+ var destPath = destDir / relativePath;
+
+ if (fsItem.IsDirectory)
+ {
+ destFs.CreateDirectory(destPath);
+ }
+ else
+ {
+ ForceReplaceFile(tempFs, fsItem.Path, destFs, destPath);
+ }
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ return e.Message;
+ }
+ return null;
+ }
+
+ private static void CopyMuxer(
+ SemVersion? existingMuxerVersion,
+ SemVersion newRuntimeVersion,
+ IFileSystem tempFs,
+ UPath tempExtractDir,
+ IFileSystem destFs,
+ UPath destDir)
+ { //The "dotnet" exe (muxer) is special in two ways:
+ // 1. It is shared between all SDKs, so it may be locked by another process.
+ // 2. It should always be the newest version, so we don't want to overwrite it if the SDK
+ // we're installing is older than the one already installed.
+ //
+ var muxerTargetPath = destDir / DotnetExeName;
+
+ if (newRuntimeVersion.CompareSortOrderTo(existingMuxerVersion) <= 0)
+ {
+ // The new SDK is older than the existing muxer, so we don't need to do anything.
+ return;
+ }
+
+ // The new SDK is newer than the existing muxer, so we need to replace it.
+ ForceReplaceFile(tempFs, tempExtractDir / DotnetExeName, destFs, muxerTargetPath);
+ }
+
+ public static T Unwrap(this T? t, [CallerArgumentExpression(parameterName: nameof(t))] string? expr = null) where T : class
+ {
+ if (t is null)
+ {
+ throw new NullReferenceException("Unexpected null value in expression: " + (expr ?? "(null)"));
+ }
+ return t;
+ }
+}
+
+[Closed]
+public enum Libc
+{
+ Default, // Not a real libc, refers to the most common platform libc
+ Musl
+}
+
+public sealed record RID(
+ OSPlatform OS,
+ Architecture Arch,
+ Libc Libc = Libc.Default)
+{
+ public override string ToString()
+ {
+ string os =
+ OS == OSPlatform.Windows ? "win" :
+ OS == OSPlatform.Linux ? "linux" :
+ OS == OSPlatform.OSX ? "osx" :
+ throw new NotSupportedException("Unsupported OS: " + OS);
+
+ string arch = Arch switch
+ {
+ Architecture.X64 => "x64",
+ Architecture.Arm64 => "arm64",
+ _ => throw new NotSupportedException("Unsupported architecture")
+ };
+ return Libc switch
+ {
+ Libc.Default => string.Join("-", os, arch),
+ Libc.Musl => string.Join('-', os, arch, "musl")
+ };
+ }
+}
diff --git a/src/DNVM/dnvm.csproj b/src/DNVM/dnvm.csproj
new file mode 100644
index 000000000000..a230ad234881
--- /dev/null
+++ b/src/DNVM/dnvm.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ true
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+