From 1350261230b3132c77b016ca4fe92d5844f06eb1 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Wed, 9 Jul 2025 13:06:40 -0400 Subject: [PATCH 01/58] Initial dotnet sdk install CLI definition --- src/Cli/dotnet/CliUsage.cs | 1 + .../Sdk/Install/SdkInstallCommandParser.cs | 64 +++++++++++++++++++ .../dotnet/Commands/Sdk/SdkCommandParser.cs | 2 + src/Cli/dotnet/Parser.cs | 2 + 4 files changed, 69 insertions(+) create mode 100644 src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs diff --git a/src/Cli/dotnet/CliUsage.cs b/src/Cli/dotnet/CliUsage.cs index 8e0870a86e84..c8caddcf8142 100644 --- a/src/Cli/dotnet/CliUsage.cs +++ b/src/Cli/dotnet/CliUsage.cs @@ -43,6 +43,7 @@ internal static class CliUsage clean {CliCommandStrings.CleanDefinition} format {CliCommandStrings.FormatDefinition} help {CliCommandStrings.HelpDefinition} + install Installs the .NET SDK msbuild {CliCommandStrings.MsBuildDefinition} new {CliCommandStrings.NewDefinition} nuget {CliCommandStrings.NugetDefinition} diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs new file mode 100644 index 000000000000..6ec1c79cbbc1 --- /dev/null +++ b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.DotNet.Cli.Commands.Sdk.Install; + +internal static class SdkInstallCommandParser +{ + + + public static readonly DynamicArgument VersionOrChannelArgument = new("versionOrChannel") + { + HelpName = "VERSION|CHANNEL", + Description = "The version or channel of the .NET SDK to install. For example: latest, 10, 9.0.3xx, 9.0.304", + Arity = ArgumentArity.ZeroOrOne, + }; + + public static readonly Option InstallPathOption = new("--install-path") + { + HelpName = "INSTALL_PATH", + Description = "The path to install the .NET SDK to", + }; + + // TODO: Ideally you could just specify --set-default-root, as well as --set-default-root true or --set-default-root false + // This would help for interactivity + public static readonly Option SetDefaultRootOption = new("--set-default-root") + { + Description = "Add installation path to PATH and set DOTNET_ROOT", + Arity = ArgumentArity.Zero + }; + + private static readonly Command SdkInstallCommand = ConstructCommand(); + + public static Command GetSdkInstallCommand() + { + return SdkInstallCommand; + } + + // Trying to use the same command object for both "dotnet install" and "dotnet sdk install" causes the following exception: + // System.InvalidOperationException: Command install has more than one child named "versionOrChannel". + // So we create a separate instance for each case + private static readonly Command RootInstallCommand = ConstructCommand(); + + public static Command GetRootInstallCommand() + { + return RootInstallCommand; + } + + private static Command ConstructCommand() + { + Command command = new("install", "Installs the .NET SDK"); + + command.Arguments.Add(VersionOrChannelArgument); + + command.Options.Add(InstallPathOption); + command.Options.Add(SetDefaultRootOption); + + command.SetAction(parseResult => 0); + + return command; + } +} diff --git a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs index 061eed3dbdf2..d01a8128b5f1 100644 --- a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs @@ -5,6 +5,7 @@ using System.CommandLine; using Microsoft.DotNet.Cli.Commands.Sdk.Check; +using Microsoft.DotNet.Cli.Commands.Sdk.Install; using Microsoft.DotNet.Cli.Extensions; namespace Microsoft.DotNet.Cli.Commands.Sdk; @@ -24,6 +25,7 @@ private static Command ConstructCommand() { DocumentedCommand command = new("sdk", DocsLink, CliCommandStrings.SdkAppFullName); command.Subcommands.Add(SdkCheckCommandParser.GetCommand()); + command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); command.SetAction((parseResult) => parseResult.HandleMissingCommand()); diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index 4bddb07f976e..8a99a184ff3e 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -35,6 +35,7 @@ using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Commands.Run.Api; using Microsoft.DotNet.Cli.Commands.Sdk; +using Microsoft.DotNet.Cli.Commands.Sdk.Install; using Microsoft.DotNet.Cli.Commands.Solution; using Microsoft.DotNet.Cli.Commands.Store; using Microsoft.DotNet.Cli.Commands.Test; @@ -87,6 +88,7 @@ public static class Parser VSTestCommandParser.GetCommand(), HelpCommandParser.GetCommand(), SdkCommandParser.GetCommand(), + SdkInstallCommandParser.GetRootInstallCommand(), InstallSuccessCommand, WorkloadCommandParser.GetCommand(), new System.CommandLine.StaticCompletions.CompletionsCommand() From 413e698c47e0176426fb5bada1b13a9c35108e1a Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 13 Jul 2025 22:14:08 -0400 Subject: [PATCH 02/58] Add SDK update command --- src/Cli/dotnet/CliUsage.cs | 1 + .../dotnet/Commands/Sdk/SdkCommandParser.cs | 2 + .../Sdk/Update/SdkUpdateCommandParser.cs | 52 +++++++++++++++++++ src/Cli/dotnet/Parser.cs | 2 + 4 files changed, 57 insertions(+) create mode 100644 src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs diff --git a/src/Cli/dotnet/CliUsage.cs b/src/Cli/dotnet/CliUsage.cs index c8caddcf8142..50b77bf0351f 100644 --- a/src/Cli/dotnet/CliUsage.cs +++ b/src/Cli/dotnet/CliUsage.cs @@ -58,6 +58,7 @@ install Installs the .NET SDK store {CliCommandStrings.StoreDefinition} test {CliCommandStrings.TestDefinition} tool {CliCommandStrings.ToolDefinition} + update Updates the .NET SDK vstest {CliCommandStrings.VsTestDefinition} workload {CliCommandStrings.WorkloadDefinition} diff --git a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs index d01a8128b5f1..80d665052003 100644 --- a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs @@ -6,6 +6,7 @@ using System.CommandLine; using Microsoft.DotNet.Cli.Commands.Sdk.Check; using Microsoft.DotNet.Cli.Commands.Sdk.Install; +using Microsoft.DotNet.Cli.Commands.Sdk.Update; using Microsoft.DotNet.Cli.Extensions; namespace Microsoft.DotNet.Cli.Commands.Sdk; @@ -26,6 +27,7 @@ private static Command ConstructCommand() DocumentedCommand command = new("sdk", DocsLink, CliCommandStrings.SdkAppFullName); command.Subcommands.Add(SdkCheckCommandParser.GetCommand()); command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); + command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); command.SetAction((parseResult) => parseResult.HandleMissingCommand()); diff --git a/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs new file mode 100644 index 000000000000..6dc0198a522a --- /dev/null +++ b/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.DotNet.Cli.Commands.Sdk.Update; + +internal static class SdkUpdateCommandParser +{ + + public static readonly Option UpdateAllOption = new("--all") + { + Description = "Update all installed SDKs", + Arity = ArgumentArity.Zero + }; + + public static readonly Option UpdateGlobalJsonOption = new("--update-global-json") + { + Description = "Update the sdk version in global.json files to the updated SDK version", + Arity = ArgumentArity.Zero + }; + + private static readonly Command SdkUpdateCommand = ConstructCommand(); + + public static Command GetSdkUpdateCommand() + { + return SdkUpdateCommand; + } + + // Trying to use the same command object for both "dotnet udpate" and "dotnet sdk update" causes the following exception: + // System.InvalidOperationException: Command install has more than one child named "versionOrChannel". + // So we create a separate instance for each case + private static readonly Command RootUpdateCommand = ConstructCommand(); + + public static Command GetRootUpdateCommand() + { + return RootUpdateCommand; + } + + private static Command ConstructCommand() + { + Command command = new("update", "Updates the .NET SDK"); + + command.Options.Add(UpdateAllOption); + command.Options.Add(UpdateGlobalJsonOption); + + command.SetAction(parseResult => 0); + + return command; + } +} diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index 8a99a184ff3e..d7a24ffdab71 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -36,6 +36,7 @@ using Microsoft.DotNet.Cli.Commands.Run.Api; using Microsoft.DotNet.Cli.Commands.Sdk; using Microsoft.DotNet.Cli.Commands.Sdk.Install; +using Microsoft.DotNet.Cli.Commands.Sdk.Update; using Microsoft.DotNet.Cli.Commands.Solution; using Microsoft.DotNet.Cli.Commands.Store; using Microsoft.DotNet.Cli.Commands.Test; @@ -89,6 +90,7 @@ public static class Parser HelpCommandParser.GetCommand(), SdkCommandParser.GetCommand(), SdkInstallCommandParser.GetRootInstallCommand(), + SdkUpdateCommandParser.GetRootUpdateCommand(), InstallSuccessCommand, WorkloadCommandParser.GetCommand(), new System.CommandLine.StaticCompletions.CompletionsCommand() From 61e48aaed1740b0bf7f8766eecb0bbd3f1145ceb Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 13 Jul 2025 22:27:57 -0400 Subject: [PATCH 03/58] Rename VersionOrChannel argument to just Channel --- .../Commands/Sdk/Install/SdkInstallCommandParser.cs | 12 ++++++++---- .../Commands/Sdk/Update/SdkUpdateCommandParser.cs | 7 +++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs index 6ec1c79cbbc1..697f924a6f99 100644 --- a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -10,10 +10,10 @@ internal static class SdkInstallCommandParser { - public static readonly DynamicArgument VersionOrChannelArgument = new("versionOrChannel") + public static readonly DynamicArgument ChannelArgument = new("channel") { - HelpName = "VERSION|CHANNEL", - Description = "The version or channel of the .NET SDK to install. For example: latest, 10, 9.0.3xx, 9.0.304", + HelpName = "CHANNEL", + Description = "The channel of the .NET SDK to install. For example: latest, 10, or 9.0.3xx. A specific version (for example 9.0.304) can also be specified.", Arity = ArgumentArity.ZeroOrOne, }; @@ -31,6 +31,8 @@ internal static class SdkInstallCommandParser Arity = ArgumentArity.Zero }; + public static readonly Option InteractiveOption = CommonOptions.InteractiveOption(); + private static readonly Command SdkInstallCommand = ConstructCommand(); public static Command GetSdkInstallCommand() @@ -57,7 +59,9 @@ private static Command ConstructCommand() command.Options.Add(InstallPathOption); command.Options.Add(SetDefaultRootOption); - command.SetAction(parseResult => 0); + command.Options.Add(InteractiveOption); + + command.SetAction(parseResult => new SdkInstallCommand(parseResult).Execute()); return command; } diff --git a/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs index 6dc0198a522a..a32e4db68a8f 100644 --- a/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs @@ -21,6 +21,8 @@ internal static class SdkUpdateCommandParser Arity = ArgumentArity.Zero }; + public static readonly Option InteractiveOption = CommonOptions.InteractiveOption(); + private static readonly Command SdkUpdateCommand = ConstructCommand(); public static Command GetSdkUpdateCommand() @@ -28,8 +30,7 @@ public static Command GetSdkUpdateCommand() return SdkUpdateCommand; } - // Trying to use the same command object for both "dotnet udpate" and "dotnet sdk update" causes the following exception: - // System.InvalidOperationException: Command install has more than one child named "versionOrChannel". + // Trying to use the same command object for both "dotnet udpate" and "dotnet sdk update" causes an InvalidOperationException // So we create a separate instance for each case private static readonly Command RootUpdateCommand = ConstructCommand(); @@ -45,6 +46,8 @@ private static Command ConstructCommand() command.Options.Add(UpdateAllOption); command.Options.Add(UpdateGlobalJsonOption); + command.Options.Add(InteractiveOption); + command.SetAction(parseResult => 0); return command; From 51ffe3dd01648b43b025246900ac246dc85c9682 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sat, 19 Jul 2025 20:36:59 -0400 Subject: [PATCH 04/58] Implement part of UI for install command --- Directory.Packages.props | 1 + .../Commands/Sdk/Install/SdkInstallCommand.cs | 197 ++++++++++++++++++ .../Sdk/Install/SdkInstallCommandParser.cs | 23 +- .../Sdk/Update/SdkUpdateCommandParser.cs | 2 +- src/Cli/dotnet/dotnet.csproj | 1 + 5 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index ae06797fdda4..5c8e2c7e23c9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -104,6 +104,7 @@ + diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs new file mode 100644 index 000000000000..b1d9b32f67aa --- /dev/null +++ b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs @@ -0,0 +1,197 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Microsoft.DotNet.Cli.Utils; +using Spectre.Console; + +using SpectreAnsiConsole = Spectre.Console.AnsiConsole; + +namespace Microsoft.DotNet.Cli.Commands.Sdk.Install; + +internal class SdkInstallCommand(ParseResult result) : CommandBase(result) +{ + private readonly string? _versionOrChannel = result.GetValue(SdkInstallCommandParser.ChannelArgument); + private readonly string? _installPath = result.GetValue(SdkInstallCommandParser.InstallPathOption); + private readonly bool? _setDefaultInstall = result.GetValue(SdkInstallCommandParser.SetDefaultInstallOption); + private readonly bool? _updateGlobalJson = result.GetValue(SdkInstallCommandParser.UpdateGlobalJsonOption); + private readonly bool _interactive = result.GetValue(SdkInstallCommandParser.InteractiveOption); + + + + public override int Execute() + { + //bool? updateGlobalJson = null; + + //var updateGlobalJsonOption = _parseResult.GetResult(SdkInstallCommandParser.UpdateGlobalJsonOption)!; + //if (updateGlobalJsonOption.Implicit) + //{ + + //} + + //Reporter.Output.WriteLine($"Update global.json: {_updateGlobalJson}"); + + string? globalJsonPath = FindGlobalJson(); + + string? currentUserInstallPath; + DefaultInstall defaultInstallState = GetDefaultInstallState(out currentUserInstallPath); + + string? resolvedInstallPath = null; + + if (globalJsonPath != null) + { + string? installPathFromGlobalJson = ResolveInstallPathFromGlobalJson(globalJsonPath); + + if (installPathFromGlobalJson != null && _installPath != null && + // TODO: Is there a better way to compare paths that takes into account whether the file system is case-sensitive? + !installPathFromGlobalJson.Equals(_installPath, StringComparison.OrdinalIgnoreCase)) + { + // TODO: Add parameter to override error + Reporter.Error.WriteLine($"Error: The install path specified in global.json ({installPathFromGlobalJson}) does not match the install path provided ({_installPath})."); + return 1; + } + + resolvedInstallPath = installPathFromGlobalJson; + } + + if (resolvedInstallPath == null) + { + resolvedInstallPath = _installPath; + } + + if (resolvedInstallPath == null && defaultInstallState == DefaultInstall.User) + { + // If a user installation is already set up, we don't need to prompt for the install path + resolvedInstallPath = currentUserInstallPath; + } + + if (resolvedInstallPath == null) + { + if (_interactive) + { + resolvedInstallPath = SpectreAnsiConsole.Prompt( + new TextPrompt("Where should we install the .NET SDK to?)") + .DefaultValue(GetDefaultInstallPath())); + } + else + { + // If no install path is specified, use the default install path + resolvedInstallPath = GetDefaultInstallPath(); + } + } + + string? channelFromGlobalJson = null; + if (globalJsonPath != null) + { + channelFromGlobalJson = ResolveChannelFromGlobalJson(globalJsonPath); + } + + bool? resolvedUpdateGlobalJson = null; + + if (channelFromGlobalJson != null && _versionOrChannel != null && + // TODO: Should channel comparison be case-sensitive? + !channelFromGlobalJson.Equals(_versionOrChannel, StringComparison.OrdinalIgnoreCase)) + { + if (_interactive && _updateGlobalJson == null) + { + resolvedUpdateGlobalJson = SpectreAnsiConsole.Confirm( + $"The channel specified in global.json ({channelFromGlobalJson}) does not match the channel specified ({_versionOrChannel}). Do you want to update global.json to match the specified channel?", + defaultValue: true); + } + } + + string? resolvedChannel = null; + + if (channelFromGlobalJson != null) + { + resolvedChannel = channelFromGlobalJson; + } + else if (_versionOrChannel != null) + { + resolvedChannel = _versionOrChannel; + } + else + { + if (_interactive) + { + + Console.WriteLine("Available supported channels: " + string.Join(' ', GetAvailableChannels())); + Console.WriteLine("You can also specify a specific version (for example 9.0.304)."); + + resolvedChannel = SpectreAnsiConsole.Prompt( + new TextPrompt("Which channel of the .NET SDK do you want to install?") + .DefaultValue("latest")); + } + else + { + resolvedChannel = "latest"; // Default to latest if no channel is specified + } + } + + bool? resolvedSetDefaultInstall = _setDefaultInstall; + + if (resolvedSetDefaultInstall == null) + { + if (_interactive) + { + resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( + "Do you want to set this install path as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", + defaultValue: true); + } + else + { + resolvedSetDefaultInstall = false; // Default to not setting the default install path if not specified + } + } + + + + + Console.WriteLine($"Installing .NET SDK '{resolvedChannel}' to '{resolvedInstallPath}'..."); + + return 0; + } + + + string? FindGlobalJson() + { + //return null; + return @"d:\git\dotnet-sdk\global.json"; + } + + string? ResolveInstallPathFromGlobalJson(string globalJsonPath) + { + return null; + } + + string? ResolveChannelFromGlobalJson(string globalJsonPath) + { + //return null; + return "9.0"; + } + + string GetDefaultInstallPath() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); + } + + List GetAvailableChannels() + { + return ["latest", "preview", "10", "10.0.1xx", "9", "9.0.3xx", "9.0.2xx", "9.0.1xx"]; + } + + enum DefaultInstall + { + None, + // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to + Inconsistent, + Admin, + User + } + + DefaultInstall GetDefaultInstallState(out string? userInstallPath) + { + userInstallPath = null; + return DefaultInstall.None; + } +} diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs index 697f924a6f99..b8d3b542c837 100644 --- a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -23,12 +23,18 @@ internal static class SdkInstallCommandParser Description = "The path to install the .NET SDK to", }; - // TODO: Ideally you could just specify --set-default-root, as well as --set-default-root true or --set-default-root false - // This would help for interactivity - public static readonly Option SetDefaultRootOption = new("--set-default-root") + public static readonly Option SetDefaultInstallOption = new("--set-default-install") { - Description = "Add installation path to PATH and set DOTNET_ROOT", - Arity = ArgumentArity.Zero + Description = "Set the install path as the default dotnet install. This will update the PATH and DOTNET_ROOT environhment variables.", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = r => null + }; + + public static readonly Option UpdateGlobalJsonOption = new("--update-global-json") + { + Description = "Update the sdk version in applicable global.json files to the installed SDK version", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = r => null }; public static readonly Option InteractiveOption = CommonOptions.InteractiveOption(); @@ -41,7 +47,7 @@ public static Command GetSdkInstallCommand() } // Trying to use the same command object for both "dotnet install" and "dotnet sdk install" causes the following exception: - // System.InvalidOperationException: Command install has more than one child named "versionOrChannel". + // System.InvalidOperationException: Command install has more than one child named "channel". // So we create a separate instance for each case private static readonly Command RootInstallCommand = ConstructCommand(); @@ -54,10 +60,11 @@ private static Command ConstructCommand() { Command command = new("install", "Installs the .NET SDK"); - command.Arguments.Add(VersionOrChannelArgument); + command.Arguments.Add(ChannelArgument); command.Options.Add(InstallPathOption); - command.Options.Add(SetDefaultRootOption); + command.Options.Add(SetDefaultInstallOption); + command.Options.Add(UpdateGlobalJsonOption); command.Options.Add(InteractiveOption); diff --git a/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs index a32e4db68a8f..6cd55766d9c9 100644 --- a/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs @@ -17,7 +17,7 @@ internal static class SdkUpdateCommandParser public static readonly Option UpdateGlobalJsonOption = new("--update-global-json") { - Description = "Update the sdk version in global.json files to the updated SDK version", + Description = "Update the sdk version in applicable global.json files to the updated SDK version", Arity = ArgumentArity.Zero }; diff --git a/src/Cli/dotnet/dotnet.csproj b/src/Cli/dotnet/dotnet.csproj index 156dc1027688..f133ad234ca9 100644 --- a/src/Cli/dotnet/dotnet.csproj +++ b/src/Cli/dotnet/dotnet.csproj @@ -61,6 +61,7 @@ + From 5834280e189877cfc06e612221832fb938cf14c8 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 21 Jul 2025 14:50:33 -0400 Subject: [PATCH 05/58] Add more installer UI --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 102 +++++++++++++++--- 1 file changed, 90 insertions(+), 12 deletions(-) diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs index b1d9b32f67aa..6a205e714712 100644 --- a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs @@ -33,14 +33,15 @@ public override int Execute() string? globalJsonPath = FindGlobalJson(); - string? currentUserInstallPath; - DefaultInstall defaultInstallState = GetDefaultInstallState(out currentUserInstallPath); + string? currentInstallPath; + DefaultInstall defaultInstallState = GetDefaultInstallState(out currentInstallPath); string? resolvedInstallPath = null; + string? installPathFromGlobalJson = null; if (globalJsonPath != null) { - string? installPathFromGlobalJson = ResolveInstallPathFromGlobalJson(globalJsonPath); + installPathFromGlobalJson = ResolveInstallPathFromGlobalJson(globalJsonPath); if (installPathFromGlobalJson != null && _installPath != null && // TODO: Is there a better way to compare paths that takes into account whether the file system is case-sensitive? @@ -62,7 +63,7 @@ public override int Execute() if (resolvedInstallPath == null && defaultInstallState == DefaultInstall.User) { // If a user installation is already set up, we don't need to prompt for the install path - resolvedInstallPath = currentUserInstallPath; + resolvedInstallPath = currentInstallPath; } if (resolvedInstallPath == null) @@ -104,6 +105,8 @@ public override int Execute() if (channelFromGlobalJson != null) { + Console.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonPath} specifies that version."); + resolvedChannel = channelFromGlobalJson; } else if (_versionOrChannel != null) @@ -132,11 +135,43 @@ public override int Execute() if (resolvedSetDefaultInstall == null) { - if (_interactive) + // If global.json specified an install path, we don't prompt for setting the default install path (since you probably don't want to do that for a repo-local path) + if (_interactive && installPathFromGlobalJson == null) { - resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( - "Do you want to set this install path as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", - defaultValue: true); + if (defaultInstallState == DefaultInstall.None) + { + resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( + "Do you want to set this install path as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", + defaultValue: true); + } + else if (defaultInstallState == DefaultInstall.User) + { + // Another case where we need to compare paths and the comparison may or may not need to be case-sensitive + if (resolvedInstallPath.Equals(currentInstallPath, StringComparison.OrdinalIgnoreCase)) + { + // No need to prompt here, the default install is already set up. + } + else + { + resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( + $"The default dotnet install is currently set to {currentInstallPath}. Do you want to change it to {resolvedInstallPath}?", + defaultValue: false); + } + } + else if (defaultInstallState == DefaultInstall.Admin) + { + Console.WriteLine($"You have an existing admin install of .NET in {currentInstallPath}. We can configure your system to use the new install of .NET " + + "in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); + Console.WriteLine("You can change this later with the \"dotnet defaultinstall\" command."); + resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( + "Do you want to set this install path as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", + defaultValue: true); + } + else if (defaultInstallState == DefaultInstall.Inconsistent) + { + // TODO: Figure out what to do here + resolvedSetDefaultInstall = false; + } } else { @@ -149,6 +184,43 @@ public override int Execute() Console.WriteLine($"Installing .NET SDK '{resolvedChannel}' to '{resolvedInstallPath}'..."); + string downloadLink = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.303/dotnet-sdk-9.0.303-win-x64.exe"; + + // Download the file to a temp path with progress + string tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(downloadLink)); + using (var httpClient = new System.Net.Http.HttpClient()) + { + SpectreAnsiConsole.Progress() + .Start(ctx => + { + var task = ctx.AddTask($"Downloading {Path.GetFileName(downloadLink)}"); + using (var response = httpClient.GetAsync(downloadLink, System.Net.Http.HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult()) + { + response.EnsureSuccessStatusCode(); + var contentLength = response.Content.Headers.ContentLength ?? 0; + using (var stream = response.Content.ReadAsStream()) + using (var fileStream = File.Create(tempFilePath)) + { + var buffer = new byte[81920]; + long totalRead = 0; + int read; + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + { + fileStream.Write(buffer, 0, read); + totalRead += read; + if (contentLength > 0) + { + task.Value = (double)totalRead / contentLength * 100; + } + } + task.Value = 100; + } + } + }); + } + Console.WriteLine($"Downloaded to: {tempFilePath}"); + + return 0; } @@ -161,13 +233,14 @@ public override int Execute() string? ResolveInstallPathFromGlobalJson(string globalJsonPath) { - return null; + return Env.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_INSTALL_PATH"); } string? ResolveChannelFromGlobalJson(string globalJsonPath) { //return null; - return "9.0"; + //return "9.0"; + return Env.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_CHANNEL"); } string GetDefaultInstallPath() @@ -189,9 +262,14 @@ enum DefaultInstall User } - DefaultInstall GetDefaultInstallState(out string? userInstallPath) + DefaultInstall GetDefaultInstallState(out string? currentInstallPath) { - userInstallPath = null; + currentInstallPath = null; return DefaultInstall.None; } + + bool IsElevated() + { + return false; + } } From 1bf3e57bc29ccae1b0a1e0bfc4141918fbecc0fd Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 21 Jul 2025 17:47:50 -0400 Subject: [PATCH 06/58] UI improvements and add dnup shims for demo --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 21 +++++++++++-------- src/Layout/redist/dnup | 6 ++++++ src/Layout/redist/dnup.cmd | 6 ++++++ .../targets/GenerateInstallerLayout.targets | 2 ++ 4 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 src/Layout/redist/dnup create mode 100644 src/Layout/redist/dnup.cmd diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs index 6a205e714712..9c1423fbf58e 100644 --- a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs @@ -161,7 +161,7 @@ public override int Execute() else if (defaultInstallState == DefaultInstall.Admin) { Console.WriteLine($"You have an existing admin install of .NET in {currentInstallPath}. We can configure your system to use the new install of .NET " + - "in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); + $"in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); Console.WriteLine("You can change this later with the \"dotnet defaultinstall\" command."); resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( "Do you want to set this install path as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", @@ -179,10 +179,7 @@ public override int Execute() } } - - - - Console.WriteLine($"Installing .NET SDK '{resolvedChannel}' to '{resolvedInstallPath}'..."); + SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannel}[/] to [blue]{resolvedInstallPath}[/]..."); string downloadLink = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.303/dotnet-sdk-9.0.303-win-x64.exe"; @@ -193,7 +190,7 @@ public override int Execute() SpectreAnsiConsole.Progress() .Start(ctx => { - var task = ctx.AddTask($"Downloading {Path.GetFileName(downloadLink)}"); + var task = ctx.AddTask($"Downloading"); using (var response = httpClient.GetAsync(downloadLink, System.Net.Http.HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult()) { response.EnsureSuccessStatusCode(); @@ -218,7 +215,7 @@ public override int Execute() } }); } - Console.WriteLine($"Downloaded to: {tempFilePath}"); + Console.WriteLine($"Complete!"); return 0; @@ -264,8 +261,14 @@ enum DefaultInstall DefaultInstall GetDefaultInstallState(out string? currentInstallPath) { - currentInstallPath = null; - return DefaultInstall.None; + var testHookDefaultInstall = Env.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); + DefaultInstall returnValue = DefaultInstall.None; + if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) + { + returnValue = DefaultInstall.None; + } + currentInstallPath = Env.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); + return returnValue; } bool IsElevated() diff --git a/src/Layout/redist/dnup b/src/Layout/redist/dnup new file mode 100644 index 000000000000..04758bf9f8d4 --- /dev/null +++ b/src/Layout/redist/dnup @@ -0,0 +1,6 @@ +#!/bin/sh +if [ $# -eq 0 ]; then + "$(dirname "$0")/dotnet" install +else + "$(dirname "$0")/dotnet" "$@" +fi diff --git a/src/Layout/redist/dnup.cmd b/src/Layout/redist/dnup.cmd new file mode 100644 index 000000000000..7d8cb5bc7af0 --- /dev/null +++ b/src/Layout/redist/dnup.cmd @@ -0,0 +1,6 @@ +@echo off +if "%~1"=="" ( + "%~dp0dotnet.exe" install +) else ( + "%~dp0dotnet.exe" %* +) diff --git a/src/Layout/redist/targets/GenerateInstallerLayout.targets b/src/Layout/redist/targets/GenerateInstallerLayout.targets index 4c7aa7749e3b..14adead05b41 100644 --- a/src/Layout/redist/targets/GenerateInstallerLayout.targets +++ b/src/Layout/redist/targets/GenerateInstallerLayout.targets @@ -68,7 +68,9 @@ + + From 225a5cf3c25e9b6f9b52c9c86e8c468c44233ee8 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 21 Jul 2025 18:59:29 -0400 Subject: [PATCH 07/58] More install experience updates --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 163 ++++++++++++++---- 1 file changed, 132 insertions(+), 31 deletions(-) diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs index 9c1423fbf58e..607b40ac9e35 100644 --- a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs @@ -2,9 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.Net.Http; +using Microsoft.Deployment.DotNet.Releases; using Microsoft.DotNet.Cli.Utils; using Spectre.Console; + using SpectreAnsiConsole = Spectre.Console.AnsiConsole; namespace Microsoft.DotNet.Cli.Commands.Sdk.Install; @@ -105,7 +108,7 @@ public override int Execute() if (channelFromGlobalJson != null) { - Console.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonPath} specifies that version."); + SpectreAnsiConsole.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonPath} specifies that version."); resolvedChannel = channelFromGlobalJson; } @@ -118,8 +121,8 @@ public override int Execute() if (_interactive) { - Console.WriteLine("Available supported channels: " + string.Join(' ', GetAvailableChannels())); - Console.WriteLine("You can also specify a specific version (for example 9.0.304)."); + SpectreAnsiConsole.WriteLine("Available supported channels: " + string.Join(' ', GetAvailableChannels())); + SpectreAnsiConsole.WriteLine("You can also specify a specific version (for example 9.0.304)."); resolvedChannel = SpectreAnsiConsole.Prompt( new TextPrompt("Which channel of the .NET SDK do you want to install?") @@ -141,7 +144,7 @@ public override int Execute() if (defaultInstallState == DefaultInstall.None) { resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( - "Do you want to set this install path as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", + $"Do you want to set the install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } else if (defaultInstallState == DefaultInstall.User) @@ -160,11 +163,11 @@ public override int Execute() } else if (defaultInstallState == DefaultInstall.Admin) { - Console.WriteLine($"You have an existing admin install of .NET in {currentInstallPath}. We can configure your system to use the new install of .NET " + + SpectreAnsiConsole.WriteLine($"You have an existing admin install of .NET in {currentInstallPath}. We can configure your system to use the new install of .NET " + $"in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); - Console.WriteLine("You can change this later with the \"dotnet defaultinstall\" command."); + SpectreAnsiConsole.WriteLine("You can change this later with the \"dotnet defaultinstall\" command."); resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( - "Do you want to set this install path as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", + $"Do you want to set the user install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } else if (defaultInstallState == DefaultInstall.Inconsistent) @@ -179,48 +182,105 @@ public override int Execute() } } - SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannel}[/] to [blue]{resolvedInstallPath}[/]..."); + List additionalVersionsToInstall = new(); + + var resolvedChannelVersion = ResolveChannelVersion(resolvedChannel); + + if (resolvedSetDefaultInstall == true && defaultInstallState == DefaultInstall.Admin) + { + if (_interactive) + { + var latestAdminVersion = GetLatestInstalledAdminVersion(); + if (new ReleaseVersion(resolvedChannelVersion) < new ReleaseVersion(latestAdminVersion)) + { + SpectreAnsiConsole.WriteLine($"Since the admin installs of the .NET SDK will no longer be accessible, we recommend installing the latest admin installed " + + $"version ({latestAdminVersion}) to the new user install location. This will make sure this version of the .NET SDK continues to be used for projects that don't specify a .NET SDK version in global.json."); + + if (SpectreAnsiConsole.Confirm($"Also install .NET SDK {latestAdminVersion}?", + defaultValue: true)) + { + additionalVersionsToInstall.Add(latestAdminVersion); + } + } + } + else + { + // TODO: Add command-linen option for installing admin versions locally + } + } + + + + SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); string downloadLink = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.303/dotnet-sdk-9.0.303-win-x64.exe"; // Download the file to a temp path with progress - string tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(downloadLink)); using (var httpClient = new System.Net.Http.HttpClient()) { SpectreAnsiConsole.Progress() .Start(ctx => { - var task = ctx.AddTask($"Downloading"); - using (var response = httpClient.GetAsync(downloadLink, System.Net.Http.HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult()) + var task = ctx.AddTask($"Downloading .NET SDK {resolvedChannelVersion}"); + + List additionalDownloads = additionalVersionsToInstall.Select(version => { - response.EnsureSuccessStatusCode(); - var contentLength = response.Content.Headers.ContentLength ?? 0; - using (var stream = response.Content.ReadAsStream()) - using (var fileStream = File.Create(tempFilePath)) + var additionalTask = ctx.AddTask($"Downloading .NET SDK {version}"); + return (Action) (() => { - var buffer = new byte[81920]; - long totalRead = 0; - int read; - while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) - { - fileStream.Write(buffer, 0, read); - totalRead += read; - if (contentLength > 0) - { - task.Value = (double)totalRead / contentLength * 100; - } - } - task.Value = 100; - } + Download(downloadLink, httpClient, additionalTask); + }); + }).ToList(); + + Download(downloadLink, httpClient, task); + + + foreach (var additionalDownload in additionalDownloads) + { + additionalDownload(); } }); } - Console.WriteLine($"Complete!"); + SpectreAnsiConsole.WriteLine($"Complete!"); return 0; } + void Download(string url, HttpClient httpClient, ProgressTask task) + { + //string tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(url)); + //using (var response = httpClient.GetAsync(url, System.Net.Http.HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult()) + //{ + // response.EnsureSuccessStatusCode(); + // var contentLength = response.Content.Headers.ContentLength ?? 0; + // using (var stream = response.Content.ReadAsStream()) + // using (var fileStream = File.Create(tempFilePath)) + // { + // var buffer = new byte[81920]; + // long totalRead = 0; + // int read; + // while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + // { + // fileStream.Write(buffer, 0, read); + // totalRead += read; + // if (contentLength > 0) + // { + // task.Value = (double)totalRead / contentLength * 100; + // } + // } + // task.Value = 100; + // } + //} + + for (int i=0; i < 100; i++) + { + task.Increment(1); + Thread.Sleep(20); // Simulate some work + } + task.Value = 100; + } + string? FindGlobalJson() { @@ -247,7 +307,38 @@ string GetDefaultInstallPath() List GetAvailableChannels() { - return ["latest", "preview", "10", "10.0.1xx", "9", "9.0.3xx", "9.0.2xx", "9.0.1xx"]; + return ["latest", "preview", "10", "10.0.1xx", "10.0.2xx", "9", "9.0.3xx", "9.0.2xx", "9.0.1xx"]; + } + + string ResolveChannelVersion(string channel) + { + if (channel == "preview") + { + return "11.0.100-preview.1.42424"; + } + else if (channel == "latest" || channel == "10" || channel == "10.0.2xx") + { + return "10.0.203"; + } + else if (channel == "10.0.1xx") + { + return "10.0.106"; + } + else if (channel == "9" || channel == "9.0.3xx") + { + return "9.0.309"; + } + else if (channel == "9.0.2xx") + { + return "9.0.212"; + } + else if (channel == "9.0.1xx") + { + return "9.0.115"; + } + + return channel; + } enum DefaultInstall @@ -271,6 +362,16 @@ DefaultInstall GetDefaultInstallState(out string? currentInstallPath) return returnValue; } + string GetLatestInstalledAdminVersion() + { + var latestAdminVersion = Env.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); + if (string.IsNullOrEmpty(latestAdminVersion)) + { + latestAdminVersion = "10.0.203"; + } + return latestAdminVersion; + } + bool IsElevated() { return false; From 9f6340c8417b78e7081920035db01fe1e5c52df4 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 15 Aug 2025 09:46:54 -0400 Subject: [PATCH 08/58] Add SdkInstallCommand code to dnup --- sdk.slnx | 4 ++- .../dotnet/Commands/Sdk/SdkCommandParser.cs | 4 +-- src/Cli/dotnet/Parser.cs | 4 +-- src/Installer/dnup/CommandBase.cs | 29 +++++++++++++++++++ .../Commands/Sdk/Install/SdkInstallCommand.cs | 15 +++++----- .../Sdk/Install/SdkInstallCommandParser.cs | 7 ++--- src/Installer/dnup/CommonOptions.cs | 23 +++++++++++++++ src/Installer/dnup/Program.cs | 2 ++ src/Installer/dnup/dnup.csproj | 26 +++++++++++++++++ 9 files changed, 97 insertions(+), 17 deletions(-) create mode 100644 src/Installer/dnup/CommandBase.cs rename src/{Cli/dotnet => Installer/dnup}/Commands/Sdk/Install/SdkInstallCommand.cs (94%) rename src/{Cli/dotnet => Installer/dnup}/Commands/Sdk/Install/SdkInstallCommandParser.cs (92%) create mode 100644 src/Installer/dnup/CommonOptions.cs create mode 100644 src/Installer/dnup/Program.cs create mode 100644 src/Installer/dnup/dnup.csproj diff --git a/sdk.slnx b/sdk.slnx index a2a04af45101..4726827cd086 100644 --- a/sdk.slnx +++ b/sdk.slnx @@ -3,7 +3,6 @@ - @@ -86,6 +85,9 @@ + + + diff --git a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs index 80d665052003..9a5ba2627eb7 100644 --- a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs @@ -5,7 +5,7 @@ using System.CommandLine; using Microsoft.DotNet.Cli.Commands.Sdk.Check; -using Microsoft.DotNet.Cli.Commands.Sdk.Install; +//using Microsoft.DotNet.Cli.Commands.Sdk.Install; using Microsoft.DotNet.Cli.Commands.Sdk.Update; using Microsoft.DotNet.Cli.Extensions; @@ -26,7 +26,7 @@ private static Command ConstructCommand() { DocumentedCommand command = new("sdk", DocsLink, CliCommandStrings.SdkAppFullName); command.Subcommands.Add(SdkCheckCommandParser.GetCommand()); - command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); + //command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); command.SetAction((parseResult) => parseResult.HandleMissingCommand()); diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index d7a24ffdab71..8fcc522bcf30 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -35,7 +35,7 @@ using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Commands.Run.Api; using Microsoft.DotNet.Cli.Commands.Sdk; -using Microsoft.DotNet.Cli.Commands.Sdk.Install; +//using Microsoft.DotNet.Cli.Commands.Sdk.Install; using Microsoft.DotNet.Cli.Commands.Sdk.Update; using Microsoft.DotNet.Cli.Commands.Solution; using Microsoft.DotNet.Cli.Commands.Store; @@ -89,7 +89,7 @@ public static class Parser VSTestCommandParser.GetCommand(), HelpCommandParser.GetCommand(), SdkCommandParser.GetCommand(), - SdkInstallCommandParser.GetRootInstallCommand(), + //SdkInstallCommandParser.GetRootInstallCommand(), SdkUpdateCommandParser.GetRootUpdateCommand(), InstallSuccessCommand, WorkloadCommandParser.GetCommand(), diff --git a/src/Installer/dnup/CommandBase.cs b/src/Installer/dnup/CommandBase.cs new file mode 100644 index 000000000000..e1b27c8efc9f --- /dev/null +++ b/src/Installer/dnup/CommandBase.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.Text; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public abstract class CommandBase +{ + protected ParseResult _parseResult; + + protected CommandBase(ParseResult parseResult) + { + _parseResult = parseResult; + //ShowHelpOrErrorIfAppropriate(parseResult); + } + + //protected CommandBase() { } + + //protected virtual void ShowHelpOrErrorIfAppropriate(ParseResult parseResult) + //{ + // parseResult.ShowHelpOrErrorIfAppropriate(); + //} + + public abstract int Execute(); +} diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs similarity index 94% rename from src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs rename to src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 607b40ac9e35..2a73b140bf4f 100644 --- a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -4,13 +4,12 @@ using System.CommandLine; using System.Net.Http; using Microsoft.Deployment.DotNet.Releases; -using Microsoft.DotNet.Cli.Utils; using Spectre.Console; using SpectreAnsiConsole = Spectre.Console.AnsiConsole; -namespace Microsoft.DotNet.Cli.Commands.Sdk.Install; +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; internal class SdkInstallCommand(ParseResult result) : CommandBase(result) { @@ -51,7 +50,7 @@ public override int Execute() !installPathFromGlobalJson.Equals(_installPath, StringComparison.OrdinalIgnoreCase)) { // TODO: Add parameter to override error - Reporter.Error.WriteLine($"Error: The install path specified in global.json ({installPathFromGlobalJson}) does not match the install path provided ({_installPath})."); + Console.Error.WriteLine($"Error: The install path specified in global.json ({installPathFromGlobalJson}) does not match the install path provided ({_installPath})."); return 1; } @@ -290,14 +289,14 @@ void Download(string url, HttpClient httpClient, ProgressTask task) string? ResolveInstallPathFromGlobalJson(string globalJsonPath) { - return Env.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_INSTALL_PATH"); + return Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_INSTALL_PATH"); } string? ResolveChannelFromGlobalJson(string globalJsonPath) { //return null; //return "9.0"; - return Env.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_CHANNEL"); + return Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_CHANNEL"); } string GetDefaultInstallPath() @@ -352,19 +351,19 @@ enum DefaultInstall DefaultInstall GetDefaultInstallState(out string? currentInstallPath) { - var testHookDefaultInstall = Env.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); + var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); DefaultInstall returnValue = DefaultInstall.None; if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) { returnValue = DefaultInstall.None; } - currentInstallPath = Env.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); + currentInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); return returnValue; } string GetLatestInstalledAdminVersion() { - var latestAdminVersion = Env.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); + var latestAdminVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); if (string.IsNullOrEmpty(latestAdminVersion)) { latestAdminVersion = "10.0.203"; diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs similarity index 92% rename from src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs rename to src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs index b8d3b542c837..7d4418fa0ec9 100644 --- a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -2,15 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace Microsoft.DotNet.Cli.Commands.Sdk.Install; +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; internal static class SdkInstallCommandParser { - public static readonly DynamicArgument ChannelArgument = new("channel") + public static readonly Argument ChannelArgument = new("channel") { HelpName = "CHANNEL", Description = "The channel of the .NET SDK to install. For example: latest, 10, or 9.0.3xx. A specific version (for example 9.0.304) can also be specified.", @@ -37,7 +36,7 @@ internal static class SdkInstallCommandParser DefaultValueFactory = r => null }; - public static readonly Option InteractiveOption = CommonOptions.InteractiveOption(); + public static readonly Option InteractiveOption = CommonOptions.InteractiveOption; private static readonly Command SdkInstallCommand = ConstructCommand(); diff --git a/src/Installer/dnup/CommonOptions.cs b/src/Installer/dnup/CommonOptions.cs new file mode 100644 index 000000000000..659acc433e47 --- /dev/null +++ b/src/Installer/dnup/CommonOptions.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.Text; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class CommonOptions +{ + public static Option InteractiveOption = new("--interactive") + { + Description = "Allows the command to stop and wait for user input or action (for example to complete authentication).", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = _ => !IsCIEnvironmentOrRedirected() + }; + + + private static bool IsCIEnvironmentOrRedirected() => + new Cli.Telemetry.CIEnvironmentDetectorForTelemetry().IsCIEnvironment() || Console.IsOutputRedirected; +} diff --git a/src/Installer/dnup/Program.cs b/src/Installer/dnup/Program.cs new file mode 100644 index 000000000000..3751555cbd32 --- /dev/null +++ b/src/Installer/dnup/Program.cs @@ -0,0 +1,2 @@ +// See https://aka.ms/new-console-template for more information +Console.WriteLine("Hello, World!"); diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj new file mode 100644 index 000000000000..a8a91ba4f464 --- /dev/null +++ b/src/Installer/dnup/dnup.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + enable + enable + true + + + + Microsoft.DotNet.Tools.Bootstrapper + + + + + + + + + + + + + + From eaffa9457585b4408ee6fc4fa95e990052e4cf1e Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 15 Aug 2025 10:29:24 -0400 Subject: [PATCH 09/58] Wire up sdk install command for dnup --- .../dotnet/Commands/Sdk/SdkCommandParser.cs | 2 +- .../dnup/Commands/Sdk/SdkCommandParser.cs | 33 +++++++++++++++ src/Installer/dnup/Parser.cs | 41 +++++++++++++++++++ src/Installer/dnup/Program.cs | 8 +++- src/Installer/dnup/dnup.csproj | 3 ++ 5 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs create mode 100644 src/Installer/dnup/Parser.cs diff --git a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs index 9a5ba2627eb7..e030768e14ab 100644 --- a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs @@ -29,7 +29,7 @@ private static Command ConstructCommand() //command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); - command.SetAction((parseResult) => parseResult.HandleMissingCommand()); + //command.SetAction((parseResult) => parseResult.HandleMissingCommand()); return command; } diff --git a/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs b/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs new file mode 100644 index 000000000000..eb525222fd02 --- /dev/null +++ b/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.Text; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk +{ + internal class SdkCommandParser + { + private static readonly Command Command = ConstructCommand(); + + public static Command GetCommand() + { + return Command; + } + + private static Command ConstructCommand() + { + Command command = new("sdk"); + //command.Subcommands.Add(SdkCheckCommandParser.GetCommand()); + command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); + //command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); + + //command.SetAction((parseResult) => parseResult.HandleMissingCommand()); + + return command; + } + } +} diff --git a/src/Installer/dnup/Parser.cs b/src/Installer/dnup/Parser.cs new file mode 100644 index 000000000000..286a8a7d255d --- /dev/null +++ b/src/Installer/dnup/Parser.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Completions; +using System.Text; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal class Parser + { + public static ParserConfiguration ParserConfiguration { get; } = new() + { + EnablePosixBundling = false, + //ResponseFileTokenReplacer = TokenPerLine + }; + + public static InvocationConfiguration InvocationConfiguration { get; } = new() + { + //EnableDefaultExceptionHandler = false, + }; + + public static ParseResult Parse(string[] args) => RootCommand.Parse(args, ParserConfiguration); + public static int Invoke(ParseResult parseResult) => parseResult.Invoke(InvocationConfiguration); + + private static RootCommand RootCommand { get; } = ConfigureCommandLine(new() + { + Directives = { new DiagramDirective(), new SuggestDirective(), new EnvironmentVariablesDirective() } + }); + + private static RootCommand ConfigureCommandLine(RootCommand rootCommand) + { + rootCommand.Subcommands.Add(SdkCommandParser.GetCommand()); + + return rootCommand; + } + } +} diff --git a/src/Installer/dnup/Program.cs b/src/Installer/dnup/Program.cs index 3751555cbd32..e0c8b17b1476 100644 --- a/src/Installer/dnup/Program.cs +++ b/src/Installer/dnup/Program.cs @@ -1,2 +1,6 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); + +using Microsoft.DotNet.Tools.Bootstrapper; + +var parseResult = Parser.Parse(args); + +return Parser.Invoke(parseResult); diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index a8a91ba4f464..e0e183a715de 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -6,6 +6,9 @@ enable enable true + + + $(NoWarn);CS8002 From 4394b54c4c926724936d8a5ca8aaf287cbd0d74f Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 18 Aug 2025 20:18:11 -0400 Subject: [PATCH 10/58] Add string resources to dnup project --- src/Installer/dnup/CommonOptions.cs | 2 +- src/Installer/dnup/Strings.resx | 123 +++++++++++++++++++++ src/Installer/dnup/dnup.csproj | 4 + src/Installer/dnup/xlf/Strings.cs.xlf | 12 ++ src/Installer/dnup/xlf/Strings.de.xlf | 12 ++ src/Installer/dnup/xlf/Strings.es.xlf | 12 ++ src/Installer/dnup/xlf/Strings.fr.xlf | 12 ++ src/Installer/dnup/xlf/Strings.it.xlf | 12 ++ src/Installer/dnup/xlf/Strings.ja.xlf | 12 ++ src/Installer/dnup/xlf/Strings.ko.xlf | 12 ++ src/Installer/dnup/xlf/Strings.pl.xlf | 12 ++ src/Installer/dnup/xlf/Strings.pt-BR.xlf | 12 ++ src/Installer/dnup/xlf/Strings.ru.xlf | 12 ++ src/Installer/dnup/xlf/Strings.tr.xlf | 12 ++ src/Installer/dnup/xlf/Strings.zh-Hans.xlf | 12 ++ src/Installer/dnup/xlf/Strings.zh-Hant.xlf | 12 ++ 16 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 src/Installer/dnup/Strings.resx create mode 100644 src/Installer/dnup/xlf/Strings.cs.xlf create mode 100644 src/Installer/dnup/xlf/Strings.de.xlf create mode 100644 src/Installer/dnup/xlf/Strings.es.xlf create mode 100644 src/Installer/dnup/xlf/Strings.fr.xlf create mode 100644 src/Installer/dnup/xlf/Strings.it.xlf create mode 100644 src/Installer/dnup/xlf/Strings.ja.xlf create mode 100644 src/Installer/dnup/xlf/Strings.ko.xlf create mode 100644 src/Installer/dnup/xlf/Strings.pl.xlf create mode 100644 src/Installer/dnup/xlf/Strings.pt-BR.xlf create mode 100644 src/Installer/dnup/xlf/Strings.ru.xlf create mode 100644 src/Installer/dnup/xlf/Strings.tr.xlf create mode 100644 src/Installer/dnup/xlf/Strings.zh-Hans.xlf create mode 100644 src/Installer/dnup/xlf/Strings.zh-Hant.xlf diff --git a/src/Installer/dnup/CommonOptions.cs b/src/Installer/dnup/CommonOptions.cs index 659acc433e47..c643593ed8d6 100644 --- a/src/Installer/dnup/CommonOptions.cs +++ b/src/Installer/dnup/CommonOptions.cs @@ -12,7 +12,7 @@ internal class CommonOptions { public static Option InteractiveOption = new("--interactive") { - Description = "Allows the command to stop and wait for user input or action (for example to complete authentication).", + Description = Strings.CommandInteractiveOptionDescription, Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = _ => !IsCIEnvironmentOrRedirected() }; diff --git a/src/Installer/dnup/Strings.resx b/src/Installer/dnup/Strings.resx new file mode 100644 index 000000000000..b522f258c0d5 --- /dev/null +++ b/src/Installer/dnup/Strings.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Allows the command to stop and wait for user input or action. + + \ No newline at end of file diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index e0e183a715de..8408f8dfefcc 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -25,5 +25,9 @@ + + + + diff --git a/src/Installer/dnup/xlf/Strings.cs.xlf b/src/Installer/dnup/xlf/Strings.cs.xlf new file mode 100644 index 000000000000..583703c00e49 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.cs.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.de.xlf b/src/Installer/dnup/xlf/Strings.de.xlf new file mode 100644 index 000000000000..02601d0c046b --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.de.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.es.xlf b/src/Installer/dnup/xlf/Strings.es.xlf new file mode 100644 index 000000000000..4e14bffe08d7 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.es.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.fr.xlf b/src/Installer/dnup/xlf/Strings.fr.xlf new file mode 100644 index 000000000000..c34156a2faa2 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.fr.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.it.xlf b/src/Installer/dnup/xlf/Strings.it.xlf new file mode 100644 index 000000000000..056f4ac60a30 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.it.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.ja.xlf b/src/Installer/dnup/xlf/Strings.ja.xlf new file mode 100644 index 000000000000..d6a0e83e1bf1 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.ja.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.ko.xlf b/src/Installer/dnup/xlf/Strings.ko.xlf new file mode 100644 index 000000000000..02e4bfaa7562 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.ko.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.pl.xlf b/src/Installer/dnup/xlf/Strings.pl.xlf new file mode 100644 index 000000000000..b5f83b4e62e9 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.pl.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.pt-BR.xlf b/src/Installer/dnup/xlf/Strings.pt-BR.xlf new file mode 100644 index 000000000000..e3f001a9a86b --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.pt-BR.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.ru.xlf b/src/Installer/dnup/xlf/Strings.ru.xlf new file mode 100644 index 000000000000..2b09b5339f71 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.ru.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.tr.xlf b/src/Installer/dnup/xlf/Strings.tr.xlf new file mode 100644 index 000000000000..50a5749de51b --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.tr.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.zh-Hans.xlf b/src/Installer/dnup/xlf/Strings.zh-Hans.xlf new file mode 100644 index 000000000000..95c76c2608e3 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.zh-Hans.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.zh-Hant.xlf b/src/Installer/dnup/xlf/Strings.zh-Hant.xlf new file mode 100644 index 000000000000..6780ad69c7e8 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.zh-Hant.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file From 37c42aa6052996e3d57700d1f1c5fa02b9c4ce72 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Tue, 19 Aug 2025 21:21:23 -0400 Subject: [PATCH 11/58] Add initial installer interfaces for dnup --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 184 +++++++++--------- src/Installer/dnup/IDotnetInstaller.cs | 49 +++++ src/Installer/dnup/dnup.csproj | 4 +- 3 files changed, 144 insertions(+), 93 deletions(-) create mode 100644 src/Installer/dnup/IDotnetInstaller.cs diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 2a73b140bf4f..35b46922c827 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -19,7 +19,8 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) private readonly bool? _updateGlobalJson = result.GetValue(SdkInstallCommandParser.UpdateGlobalJsonOption); private readonly bool _interactive = result.GetValue(SdkInstallCommandParser.InteractiveOption); - + private readonly IDotnetInstaller _dotnetInstaller = new EnvironmentVariableMockDotnetInstaller(); + private readonly IReleaseInfoProvider _releaseInfoProvider = new EnvironmentVariableMockReleaseInfoProvider(); public override int Execute() { @@ -33,17 +34,17 @@ public override int Execute() //Reporter.Output.WriteLine($"Update global.json: {_updateGlobalJson}"); - string? globalJsonPath = FindGlobalJson(); + var globalJsonInfo = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory); string? currentInstallPath; - DefaultInstall defaultInstallState = GetDefaultInstallState(out currentInstallPath); + SdkInstallType defaultInstallState = _dotnetInstaller.GetConfiguredInstallType(out currentInstallPath); string? resolvedInstallPath = null; string? installPathFromGlobalJson = null; - if (globalJsonPath != null) + if (globalJsonInfo?.GlobalJsonPath != null) { - installPathFromGlobalJson = ResolveInstallPathFromGlobalJson(globalJsonPath); + installPathFromGlobalJson = globalJsonInfo.SdkPath; if (installPathFromGlobalJson != null && _installPath != null && // TODO: Is there a better way to compare paths that takes into account whether the file system is case-sensitive? @@ -62,7 +63,7 @@ public override int Execute() resolvedInstallPath = _installPath; } - if (resolvedInstallPath == null && defaultInstallState == DefaultInstall.User) + if (resolvedInstallPath == null && defaultInstallState == SdkInstallType.User) { // If a user installation is already set up, we don't need to prompt for the install path resolvedInstallPath = currentInstallPath; @@ -74,19 +75,19 @@ public override int Execute() { resolvedInstallPath = SpectreAnsiConsole.Prompt( new TextPrompt("Where should we install the .NET SDK to?)") - .DefaultValue(GetDefaultInstallPath())); + .DefaultValue(_dotnetInstaller.GetDefaultDotnetInstallPath())); } else { // If no install path is specified, use the default install path - resolvedInstallPath = GetDefaultInstallPath(); + resolvedInstallPath = _dotnetInstaller.GetDefaultDotnetInstallPath(); } } string? channelFromGlobalJson = null; - if (globalJsonPath != null) + if (globalJsonInfo?.GlobalJsonPath != null) { - channelFromGlobalJson = ResolveChannelFromGlobalJson(globalJsonPath); + channelFromGlobalJson = ResolveChannelFromGlobalJson(globalJsonInfo.GlobalJsonPath); } bool? resolvedUpdateGlobalJson = null; @@ -107,7 +108,7 @@ public override int Execute() if (channelFromGlobalJson != null) { - SpectreAnsiConsole.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonPath} specifies that version."); + SpectreAnsiConsole.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonInfo?.GlobalJsonPath} specifies that version."); resolvedChannel = channelFromGlobalJson; } @@ -120,7 +121,7 @@ public override int Execute() if (_interactive) { - SpectreAnsiConsole.WriteLine("Available supported channels: " + string.Join(' ', GetAvailableChannels())); + SpectreAnsiConsole.WriteLine("Available supported channels: " + string.Join(' ', _releaseInfoProvider.GetAvailableChannels())); SpectreAnsiConsole.WriteLine("You can also specify a specific version (for example 9.0.304)."); resolvedChannel = SpectreAnsiConsole.Prompt( @@ -140,13 +141,13 @@ public override int Execute() // If global.json specified an install path, we don't prompt for setting the default install path (since you probably don't want to do that for a repo-local path) if (_interactive && installPathFromGlobalJson == null) { - if (defaultInstallState == DefaultInstall.None) + if (defaultInstallState == SdkInstallType.None) { resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( $"Do you want to set the install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } - else if (defaultInstallState == DefaultInstall.User) + else if (defaultInstallState == SdkInstallType.User) { // Another case where we need to compare paths and the comparison may or may not need to be case-sensitive if (resolvedInstallPath.Equals(currentInstallPath, StringComparison.OrdinalIgnoreCase)) @@ -160,7 +161,7 @@ public override int Execute() defaultValue: false); } } - else if (defaultInstallState == DefaultInstall.Admin) + else if (defaultInstallState == SdkInstallType.Admin) { SpectreAnsiConsole.WriteLine($"You have an existing admin install of .NET in {currentInstallPath}. We can configure your system to use the new install of .NET " + $"in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); @@ -169,7 +170,7 @@ public override int Execute() $"Do you want to set the user install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } - else if (defaultInstallState == DefaultInstall.Inconsistent) + else if (defaultInstallState == SdkInstallType.Inconsistent) { // TODO: Figure out what to do here resolvedSetDefaultInstall = false; @@ -183,14 +184,14 @@ public override int Execute() List additionalVersionsToInstall = new(); - var resolvedChannelVersion = ResolveChannelVersion(resolvedChannel); + var resolvedChannelVersion = _releaseInfoProvider.GetLatestVersion(resolvedChannel); - if (resolvedSetDefaultInstall == true && defaultInstallState == DefaultInstall.Admin) + if (resolvedSetDefaultInstall == true && defaultInstallState == SdkInstallType.Admin) { if (_interactive) { - var latestAdminVersion = GetLatestInstalledAdminVersion(); - if (new ReleaseVersion(resolvedChannelVersion) < new ReleaseVersion(latestAdminVersion)) + var latestAdminVersion = _dotnetInstaller.GetLatestInstalledAdminVersion(); + if (latestAdminVersion != null && new ReleaseVersion(resolvedChannelVersion) < new ReleaseVersion(latestAdminVersion)) { SpectreAnsiConsole.WriteLine($"Since the admin installs of the .NET SDK will no longer be accessible, we recommend installing the latest admin installed " + $"version ({latestAdminVersion}) to the new user install location. This will make sure this version of the .NET SDK continues to be used for projects that don't specify a .NET SDK version in global.json."); @@ -208,7 +209,7 @@ public override int Execute() } } - + SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); @@ -225,12 +226,12 @@ public override int Execute() List additionalDownloads = additionalVersionsToInstall.Select(version => { var additionalTask = ctx.AddTask($"Downloading .NET SDK {version}"); - return (Action) (() => + return (Action)(() => { Download(downloadLink, httpClient, additionalTask); }); }).ToList(); - + Download(downloadLink, httpClient, task); @@ -272,7 +273,7 @@ void Download(string url, HttpClient httpClient, ProgressTask task) // } //} - for (int i=0; i < 100; i++) + for (int i = 0; i < 100; i++) { task.Increment(1); Thread.Sleep(20); // Simulate some work @@ -280,18 +281,6 @@ void Download(string url, HttpClient httpClient, ProgressTask task) task.Value = 100; } - - string? FindGlobalJson() - { - //return null; - return @"d:\git\dotnet-sdk\global.json"; - } - - string? ResolveInstallPathFromGlobalJson(string globalJsonPath) - { - return Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_INSTALL_PATH"); - } - string? ResolveChannelFromGlobalJson(string globalJsonPath) { //return null; @@ -299,80 +288,93 @@ void Download(string url, HttpClient httpClient, ProgressTask task) return Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_CHANNEL"); } - string GetDefaultInstallPath() - { - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); - } - - List GetAvailableChannels() + bool IsElevated() { - return ["latest", "preview", "10", "10.0.1xx", "10.0.2xx", "9", "9.0.3xx", "9.0.2xx", "9.0.1xx"]; + return false; } - string ResolveChannelVersion(string channel) + class EnvironmentVariableMockDotnetInstaller : IDotnetInstaller { - if (channel == "preview") - { - return "11.0.100-preview.1.42424"; - } - else if (channel == "latest" || channel == "10" || channel == "10.0.2xx") + public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) { - return "10.0.203"; - } - else if (channel == "10.0.1xx") - { - return "10.0.106"; - } - else if (channel == "9" || channel == "9.0.3xx") - { - return "9.0.309"; + return new GlobalJsonInfo + { + GlobalJsonPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_PATH"), + SdkVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_VERSION"), + AllowPrerelease = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_ALLOW_PRERELEASE"), + RollForward = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_ROLLFORWARD"), + SdkPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_INSTALL_PATH") + }; } - else if (channel == "9.0.2xx") + + public string GetDefaultDotnetInstallPath() { - return "9.0.212"; + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); } - else if (channel == "9.0.1xx") + + public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) { - return "9.0.115"; + var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); + SdkInstallType returnValue = SdkInstallType.None; + if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) + { + returnValue = SdkInstallType.None; + } + currentInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); + return returnValue; } - return channel; - - } - - enum DefaultInstall - { - None, - // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to - Inconsistent, - Admin, - User - } - DefaultInstall GetDefaultInstallState(out string? currentInstallPath) - { - var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); - DefaultInstall returnValue = DefaultInstall.None; - if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) + public string? GetLatestInstalledAdminVersion() { - returnValue = DefaultInstall.None; + var latestAdminVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); + if (string.IsNullOrEmpty(latestAdminVersion)) + { + latestAdminVersion = "10.0.203"; + } + return latestAdminVersion; } - currentInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); - return returnValue; } - string GetLatestInstalledAdminVersion() + class EnvironmentVariableMockReleaseInfoProvider : IReleaseInfoProvider { - var latestAdminVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); - if (string.IsNullOrEmpty(latestAdminVersion)) + public List GetAvailableChannels() { - latestAdminVersion = "10.0.203"; + var channels = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_AVAILABLE_CHANNELS"); + if (string.IsNullOrEmpty(channels)) + { + return ["latest", "preview", "10", "10.0.1xx", "10.0.2xx", "9", "9.0.3xx", "9.0.2xx", "9.0.1xx"]; + } + return channels.Split(',').ToList(); } - return latestAdminVersion; - } + public string GetLatestVersion(string channel) + { + if (channel == "preview") + { + return "11.0.100-preview.1.42424"; + } + else if (channel == "latest" || channel == "10" || channel == "10.0.2xx") + { + return "10.0.203"; + } + else if (channel == "10.0.1xx") + { + return "10.0.106"; + } + else if (channel == "9" || channel == "9.0.3xx") + { + return "9.0.309"; + } + else if (channel == "9.0.2xx") + { + return "9.0.212"; + } + else if (channel == "9.0.1xx") + { + return "9.0.115"; + } - bool IsElevated() - { - return false; + return channel; + } } } diff --git a/src/Installer/dnup/IDotnetInstaller.cs b/src/Installer/dnup/IDotnetInstaller.cs new file mode 100644 index 000000000000..39de21a6d0b3 --- /dev/null +++ b/src/Installer/dnup/IDotnetInstaller.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public interface IDotnetInstaller +{ + GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory); + + string GetDefaultDotnetInstallPath(); + + SdkInstallType GetConfiguredInstallType(out string? currentInstallPath); + + string? GetLatestInstalledAdminVersion(); +} + +public enum SdkInstallType +{ + None, + // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to + Inconsistent, + Admin, + User +} + +public class GlobalJsonInfo +{ + public string? GlobalJsonPath { get; set; } + + public string? SdkVersion { get; set; } + + public string? AllowPrerelease { get; set; } + + public string? RollForward { get; set; } + + // The sdk.path specified in the global.json, if any + public string? SdkPath { get; set; } + +} + +public interface IReleaseInfoProvider +{ + List GetAvailableChannels(); + string GetLatestVersion(string channel); +} diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index 8408f8dfefcc..e7d769ab218f 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -16,8 +16,8 @@ - - + + From 1641070f5264522649e8d99ef2c3a58ea2c27b07 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 10:43:13 -0400 Subject: [PATCH 12/58] Start implementation of DotnetInstaller Copilot prompt: Implement the GetConfiguredInstallType method in #file:'DotnetInstaler.cs' . This method should look in the PATH and resolve "dotnet" or "dotnet.exe", depending on the current OS. If it is not found, the return value should be SdkInstallType.None. If it is found in Program Files, the install type should be SdkInstallType.Admin. If it is found in another folder, the install type should be SdkInsntallType.User. However, the method should also check the value of DOTNET_ROOT environment variable. For a user install, DOTNET_ROOT should be set to the folder where the dotnet executable is found. For an admin install, DOTNET_ROOT should not be set, but if it is set to the install path under program files, that is OK. If the DOTNET_ROOT value doesn't match the install location, the method should return SdkInstallType.Inconsistent. --- src/Installer/dnup/DotnetInstaler.cs | 89 ++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/Installer/dnup/DotnetInstaler.cs diff --git a/src/Installer/dnup/DotnetInstaler.cs b/src/Installer/dnup/DotnetInstaler.cs new file mode 100644 index 000000000000..66d6db98da78 --- /dev/null +++ b/src/Installer/dnup/DotnetInstaler.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public class DotnetInstaler : IDotnetInstaller +{ + public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) + { + currentInstallPath = null; + string? pathEnv = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(pathEnv)) + { + return SdkInstallType.None; + } + + string exeName = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + string[] paths = pathEnv.Split(Path.PathSeparator); + string? foundDotnet = null; + foreach (var dir in paths) + { + try + { + string candidate = Path.Combine(dir.Trim(), exeName); + if (File.Exists(candidate)) + { + foundDotnet = Path.GetFullPath(candidate); + break; + } + } + catch { } + } + + if (foundDotnet == null) + { + return SdkInstallType.None; + } + + string installDir = Path.GetDirectoryName(foundDotnet)!; + currentInstallPath = installDir; + + string? dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT"); + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + string programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + bool isAdminInstall = installDir.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) + || installDir.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase); + + if (isAdminInstall) + { + // Admin install: DOTNET_ROOT should not be set, or if set, should match installDir + if (!string.IsNullOrEmpty(dotnetRoot) && !PathsEqual(dotnetRoot, installDir) && !dotnetRoot.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) && !dotnetRoot.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase)) + { + return SdkInstallType.Inconsistent; + } + return SdkInstallType.Admin; + } + else + { + // User install: DOTNET_ROOT must be set and match installDir + if (string.IsNullOrEmpty(dotnetRoot) || !PathsEqual(dotnetRoot, installDir)) + { + return SdkInstallType.Inconsistent; + } + return SdkInstallType.User; + } + } + + private static bool PathsEqual(string a, string b) + { + return string.Equals(Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase); + } + + public string GetDefaultDotnetInstallPath() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); + } + public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) => throw new NotImplementedException(); + public string? GetLatestInstalledAdminVersion() + { + // TODO: Implement this + return null; + } +} From 0b39a6e8843c90b7a39cbb3495de07556c1a02b4 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 10:58:51 -0400 Subject: [PATCH 13/58] Add dnup solution filter and add Environment provider to project --- dnup.slnf | 8 ++++++++ src/Installer/dnup/dnup.csproj | 7 +++++-- 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 dnup.slnf diff --git a/dnup.slnf b/dnup.slnf new file mode 100644 index 000000000000..f2557db6ee09 --- /dev/null +++ b/dnup.slnf @@ -0,0 +1,8 @@ +{ + "solution": { + "path": "sdk.slnx", + "projects": [ + "src\\Installer\\dnup\\dnup.csproj", + ] + } +} \ No newline at end of file diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index e7d769ab218f..c95bd4902909 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -16,8 +16,11 @@ - - + + + + + From e05b2ad05383062c5a7ce5781cdae606093fbe11 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 11:03:13 -0400 Subject: [PATCH 14/58] Switch to common implementation of finding dotnet on path Copilot prompt: In #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaler.GetConfiguredInstallType':301-2793 , use #method:'Microsoft.DotNet.Cli.Utils.EnvironmentProvider.GetCommandPath':2399-2843 to search for dotnet on the path. --- src/Installer/dnup/DotnetInstaler.cs | 34 ++++++++-------------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/src/Installer/dnup/DotnetInstaler.cs b/src/Installer/dnup/DotnetInstaler.cs index 66d6db98da78..1b4729a9a7bf 100644 --- a/src/Installer/dnup/DotnetInstaler.cs +++ b/src/Installer/dnup/DotnetInstaler.cs @@ -4,38 +4,24 @@ using System; using System.IO; using System.Linq; +using Microsoft.DotNet.Cli.Utils; namespace Microsoft.DotNet.Tools.Bootstrapper; public class DotnetInstaler : IDotnetInstaller { + private readonly IEnvironmentProvider _environmentProvider; + + public DotnetInstaler(IEnvironmentProvider? environmentProvider = null) + { + _environmentProvider = environmentProvider ?? new EnvironmentProvider(); + } + public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) { currentInstallPath = null; - string? pathEnv = Environment.GetEnvironmentVariable("PATH"); - if (string.IsNullOrEmpty(pathEnv)) - { - return SdkInstallType.None; - } - - string exeName = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; - string[] paths = pathEnv.Split(Path.PathSeparator); - string? foundDotnet = null; - foreach (var dir in paths) - { - try - { - string candidate = Path.Combine(dir.Trim(), exeName); - if (File.Exists(candidate)) - { - foundDotnet = Path.GetFullPath(candidate); - break; - } - } - catch { } - } - - if (foundDotnet == null) + string? foundDotnet = _environmentProvider.GetCommandPath("dotnet"); + if (string.IsNullOrEmpty(foundDotnet)) { return SdkInstallType.None; } From c78f19896803b5df0745da394e7a5a860bc4d295 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 11:10:46 -0400 Subject: [PATCH 15/58] Fix typo --- src/Installer/dnup/{DotnetInstaler.cs => DotnetInstaller.cs} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/Installer/dnup/{DotnetInstaler.cs => DotnetInstaller.cs} (96%) diff --git a/src/Installer/dnup/DotnetInstaler.cs b/src/Installer/dnup/DotnetInstaller.cs similarity index 96% rename from src/Installer/dnup/DotnetInstaler.cs rename to src/Installer/dnup/DotnetInstaller.cs index 1b4729a9a7bf..2c9f55f7b4ed 100644 --- a/src/Installer/dnup/DotnetInstaler.cs +++ b/src/Installer/dnup/DotnetInstaller.cs @@ -8,11 +8,11 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; -public class DotnetInstaler : IDotnetInstaller +public class DotnetInstaller : IDotnetInstaller { private readonly IEnvironmentProvider _environmentProvider; - public DotnetInstaler(IEnvironmentProvider? environmentProvider = null) + public DotnetInstaller(IEnvironmentProvider? environmentProvider = null) { _environmentProvider = environmentProvider ?? new EnvironmentProvider(); } From e06b134f9f1a48d4cffa98082b871b7a2b229cf2 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 11:52:41 -0400 Subject: [PATCH 16/58] Add class for global.json contents Copilot prompts: Create a class to represent the contents of a global.json file, and create a corresponding JsonSerializerContext type. Can you refer to https://learn.microsoft.com/en-us/dotnet/core/tools/global-json to update the schema you are using for the global.json file? AllowPrerelease should be a booleann, and instead of Path there should be a Paths property which is an array of strings. --- src/Installer/dnup/GlobalJsonFile.cs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/Installer/dnup/GlobalJsonFile.cs diff --git a/src/Installer/dnup/GlobalJsonFile.cs b/src/Installer/dnup/GlobalJsonFile.cs new file mode 100644 index 000000000000..e22fb8ef92a3 --- /dev/null +++ b/src/Installer/dnup/GlobalJsonFile.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public class GlobalJsonFile +{ + public SdkSection? Sdk { get; set; } + + public class SdkSection + { + public string? Version { get; set; } + public bool? AllowPrerelease { get; set; } + public string? RollForward { get; set; } + public string[]? Paths { get; set; } + } +} + +[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(GlobalJsonFile))] +public partial class GlobalJsonFileJsonContext : JsonSerializerContext +{ +} From cd575a235b2857019118f6ae34cbb6165233c1ed Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 11:58:05 -0400 Subject: [PATCH 17/58] Rename class and more --- .../dnup/{GlobalJsonFile.cs => GlobalJsonContents.cs} | 6 +++--- src/Installer/dnup/IDotnetInstaller.cs | 9 +-------- 2 files changed, 4 insertions(+), 11 deletions(-) rename src/Installer/dnup/{GlobalJsonFile.cs => GlobalJsonContents.cs} (75%) diff --git a/src/Installer/dnup/GlobalJsonFile.cs b/src/Installer/dnup/GlobalJsonContents.cs similarity index 75% rename from src/Installer/dnup/GlobalJsonFile.cs rename to src/Installer/dnup/GlobalJsonContents.cs index e22fb8ef92a3..d55b7af24c23 100644 --- a/src/Installer/dnup/GlobalJsonFile.cs +++ b/src/Installer/dnup/GlobalJsonContents.cs @@ -2,7 +2,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; -public class GlobalJsonFile +public class GlobalJsonContents { public SdkSection? Sdk { get; set; } @@ -16,7 +16,7 @@ public class SdkSection } [JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -[JsonSerializable(typeof(GlobalJsonFile))] -public partial class GlobalJsonFileJsonContext : JsonSerializerContext +[JsonSerializable(typeof(GlobalJsonContents))] +public partial class GlobalJsonContentsJsonContext : JsonSerializerContext { } diff --git a/src/Installer/dnup/IDotnetInstaller.cs b/src/Installer/dnup/IDotnetInstaller.cs index 39de21a6d0b3..dd2217e2c87a 100644 --- a/src/Installer/dnup/IDotnetInstaller.cs +++ b/src/Installer/dnup/IDotnetInstaller.cs @@ -31,14 +31,7 @@ public class GlobalJsonInfo { public string? GlobalJsonPath { get; set; } - public string? SdkVersion { get; set; } - - public string? AllowPrerelease { get; set; } - - public string? RollForward { get; set; } - - // The sdk.path specified in the global.json, if any - public string? SdkPath { get; set; } + public GlobalJsonContents? GlobalJsonContents { get; set; } } From 3ac40152813030b55cf961cb7d8c1bcbd696a6c6 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 12:06:21 -0400 Subject: [PATCH 18/58] Implement GetGlobalJsonInfo Copilot prompts: Implement #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaller.GetDefaultDotnetInstallPath':2814-2986 . It look for a global.json file starting in the specified directory and walking up the directory chain. If it finds one, it should deserialize its contents using System.Text.Json APIs and #class:'Microsoft.DotNet.Tools.Bootstrapper.GlobalJsonContentsJsonContext':413-650 , and use that for the return value. Sorry, I meant to ask you to implement #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaller.GetGlobalJsonInfo':4307-4411 instead of #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaller.GetDefaultDotnetInstallPath':2839-2947 . Can you revert your changes and then implement #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaller.GetGlobalJsonInfo':4307-4411 using the instructions I previously provided? --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 6 +--- src/Installer/dnup/DotnetInstaller.cs | 36 ++++++++++++++++++- src/Installer/dnup/IDotnetInstaller.cs | 6 +++- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 35b46922c827..680f10c3a70a 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -300,10 +300,7 @@ public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) return new GlobalJsonInfo { GlobalJsonPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_PATH"), - SdkVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_VERSION"), - AllowPrerelease = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_ALLOW_PRERELEASE"), - RollForward = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_ROLLFORWARD"), - SdkPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_INSTALL_PATH") + GlobalJsonContents = null // Set to null for test mock; update as needed for tests }; } @@ -324,7 +321,6 @@ public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) return returnValue; } - public string? GetLatestInstalledAdminVersion() { var latestAdminVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); diff --git a/src/Installer/dnup/DotnetInstaller.cs b/src/Installer/dnup/DotnetInstaller.cs index 2c9f55f7b4ed..f57bdf384abd 100644 --- a/src/Installer/dnup/DotnetInstaller.cs +++ b/src/Installer/dnup/DotnetInstaller.cs @@ -4,6 +4,7 @@ using System; using System.IO; using System.Linq; +using System.Text.Json; using Microsoft.DotNet.Cli.Utils; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -66,7 +67,40 @@ public string GetDefaultDotnetInstallPath() { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); } - public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) => throw new NotImplementedException(); + + public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) + { + string? directory = initialDirectory; + while (!string.IsNullOrEmpty(directory)) + { + string globalJsonPath = Path.Combine(directory, "global.json"); + if (File.Exists(globalJsonPath)) + { + try + { + using var stream = File.OpenRead(globalJsonPath); + var contents = JsonSerializer.Deserialize( + stream, + GlobalJsonContentsJsonContext.Default.GlobalJsonContents); + return new GlobalJsonInfo + { + GlobalJsonPath = globalJsonPath, + GlobalJsonContents = contents + }; + } + catch + { + // Ignore errors and continue up the directory tree + } + } + var parent = Directory.GetParent(directory); + if (parent == null) + break; + directory = parent.FullName; + } + return new GlobalJsonInfo(); + } + public string? GetLatestInstalledAdminVersion() { // TODO: Implement this diff --git a/src/Installer/dnup/IDotnetInstaller.cs b/src/Installer/dnup/IDotnetInstaller.cs index dd2217e2c87a..443c9a12666c 100644 --- a/src/Installer/dnup/IDotnetInstaller.cs +++ b/src/Installer/dnup/IDotnetInstaller.cs @@ -30,9 +30,13 @@ public enum SdkInstallType public class GlobalJsonInfo { public string? GlobalJsonPath { get; set; } - public GlobalJsonContents? GlobalJsonContents { get; set; } + // Convenience properties for compatibility + public string? SdkVersion => GlobalJsonContents?.Sdk?.Version; + public bool? AllowPrerelease => GlobalJsonContents?.Sdk?.AllowPrerelease; + public string? RollForward => GlobalJsonContents?.Sdk?.RollForward; + public string? SdkPath => (GlobalJsonContents?.Sdk?.Paths != null && GlobalJsonContents.Sdk.Paths.Length > 0) ? GlobalJsonContents.Sdk.Paths[0] : null; } public interface IReleaseInfoProvider From 6fcbe4324144a5f1e816a4815fb97b2568b78526 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 12:13:12 -0400 Subject: [PATCH 19/58] Remove try/catch for loading global.json Copilot prompts: Can you remove the try catch block around reading the global.json file in #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaller.GetGlobalJsonInfo':3019-4216 ? Also remove the convenience properties you added to GlobalJsonInfo for now. OK, go ahead and add those convencience properties back. --- src/Installer/dnup/DotnetInstaller.cs | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/Installer/dnup/DotnetInstaller.cs b/src/Installer/dnup/DotnetInstaller.cs index f57bdf384abd..34b104dba6cc 100644 --- a/src/Installer/dnup/DotnetInstaller.cs +++ b/src/Installer/dnup/DotnetInstaller.cs @@ -76,22 +76,15 @@ public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) string globalJsonPath = Path.Combine(directory, "global.json"); if (File.Exists(globalJsonPath)) { - try + using var stream = File.OpenRead(globalJsonPath); + var contents = JsonSerializer.Deserialize( + stream, + GlobalJsonContentsJsonContext.Default.GlobalJsonContents); + return new GlobalJsonInfo { - using var stream = File.OpenRead(globalJsonPath); - var contents = JsonSerializer.Deserialize( - stream, - GlobalJsonContentsJsonContext.Default.GlobalJsonContents); - return new GlobalJsonInfo - { - GlobalJsonPath = globalJsonPath, - GlobalJsonContents = contents - }; - } - catch - { - // Ignore errors and continue up the directory tree - } + GlobalJsonPath = globalJsonPath, + GlobalJsonContents = contents + }; } var parent = Directory.GetParent(directory); if (parent == null) From bd28700ca011b3cb33fd5e509f31444f571e7349 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 16:46:21 -0400 Subject: [PATCH 20/58] Add more installer methods --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 123 ++++++++++-------- src/Installer/dnup/DotnetInstaller.cs | 5 + src/Installer/dnup/IDotnetInstaller.cs | 9 ++ 3 files changed, 80 insertions(+), 57 deletions(-) diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 680f10c3a70a..f81ceee91d42 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -213,73 +213,22 @@ public override int Execute() SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); - string downloadLink = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.303/dotnet-sdk-9.0.303-win-x64.exe"; - // Download the file to a temp path with progress - using (var httpClient = new System.Net.Http.HttpClient()) - { - SpectreAnsiConsole.Progress() - .Start(ctx => - { - var task = ctx.AddTask($"Downloading .NET SDK {resolvedChannelVersion}"); - - List additionalDownloads = additionalVersionsToInstall.Select(version => - { - var additionalTask = ctx.AddTask($"Downloading .NET SDK {version}"); - return (Action)(() => - { - Download(downloadLink, httpClient, additionalTask); - }); - }).ToList(); - Download(downloadLink, httpClient, task); + SpectreAnsiConsole.Progress() + .Start(ctx => + { + _dotnetInstaller.InstallSdks(resolvedInstallPath, ctx, new[] { resolvedChannelVersion }.Concat(additionalVersionsToInstall)); + }); - foreach (var additionalDownload in additionalDownloads) - { - additionalDownload(); - } - }); - } SpectreAnsiConsole.WriteLine($"Complete!"); return 0; } - void Download(string url, HttpClient httpClient, ProgressTask task) - { - //string tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(url)); - //using (var response = httpClient.GetAsync(url, System.Net.Http.HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult()) - //{ - // response.EnsureSuccessStatusCode(); - // var contentLength = response.Content.Headers.ContentLength ?? 0; - // using (var stream = response.Content.ReadAsStream()) - // using (var fileStream = File.Create(tempFilePath)) - // { - // var buffer = new byte[81920]; - // long totalRead = 0; - // int read; - // while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) - // { - // fileStream.Write(buffer, 0, read); - // totalRead += read; - // if (contentLength > 0) - // { - // task.Value = (double)totalRead / contentLength * 100; - // } - // } - // task.Value = 100; - // } - //} - - for (int i = 0; i < 100; i++) - { - task.Increment(1); - Thread.Sleep(20); // Simulate some work - } - task.Value = 100; - } + string? ResolveChannelFromGlobalJson(string globalJsonPath) { @@ -330,6 +279,66 @@ public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) } return latestAdminVersion; } + + public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) + { + //var task = progressContext.AddTask($"Downloading .NET SDK {resolvedChannelVersion}"); + using (var httpClient = new System.Net.Http.HttpClient()) + { + List downloads = sdkVersions.Select(version => + { + string downloadLink = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.303/dotnet-sdk-9.0.303-win-x64.exe"; + var task = progressContext.AddTask($"Downloading .NET SDK {version}"); + return (Action)(() => + { + Download(downloadLink, httpClient, task); + }); + }).ToList(); + + + foreach (var download in downloads) + { + download(); + } + } + } + + void Download(string url, HttpClient httpClient, ProgressTask task) + { + //string tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(url)); + //using (var response = httpClient.GetAsync(url, System.Net.Http.HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult()) + //{ + // response.EnsureSuccessStatusCode(); + // var contentLength = response.Content.Headers.ContentLength ?? 0; + // using (var stream = response.Content.ReadAsStream()) + // using (var fileStream = File.Create(tempFilePath)) + // { + // var buffer = new byte[81920]; + // long totalRead = 0; + // int read; + // while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + // { + // fileStream.Write(buffer, 0, read); + // totalRead += read; + // if (contentLength > 0) + // { + // task.Value = (double)totalRead / contentLength * 100; + // } + // } + // task.Value = 100; + // } + //} + + for (int i = 0; i < 100; i++) + { + task.Increment(1); + Thread.Sleep(20); // Simulate some work + } + task.Value = 100; + } + + public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); + public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) => throw new NotImplementedException(); } class EnvironmentVariableMockReleaseInfoProvider : IReleaseInfoProvider diff --git a/src/Installer/dnup/DotnetInstaller.cs b/src/Installer/dnup/DotnetInstaller.cs index 34b104dba6cc..120412b1e8ab 100644 --- a/src/Installer/dnup/DotnetInstaller.cs +++ b/src/Installer/dnup/DotnetInstaller.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text.Json; using Microsoft.DotNet.Cli.Utils; +using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -99,4 +100,8 @@ public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) // TODO: Implement this return null; } + + public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) => throw new NotImplementedException(); + public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); + public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) => throw new NotImplementedException(); } diff --git a/src/Installer/dnup/IDotnetInstaller.cs b/src/Installer/dnup/IDotnetInstaller.cs index 443c9a12666c..47affcc51390 100644 --- a/src/Installer/dnup/IDotnetInstaller.cs +++ b/src/Installer/dnup/IDotnetInstaller.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Text; +using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -16,6 +17,14 @@ public interface IDotnetInstaller SdkInstallType GetConfiguredInstallType(out string? currentInstallPath); string? GetLatestInstalledAdminVersion(); + + void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions); + + void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null); + + void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null); + + } public enum SdkInstallType From 8e2bdf9b8f963724f28623e8a425a1041bf3368f Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 22 Aug 2025 08:50:10 -0400 Subject: [PATCH 21/58] Add calls to new methods --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index f81ceee91d42..590eab9f3f48 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -210,10 +210,11 @@ public override int Execute() } + // TODO: Implement transaction / rollback? + // TODO: Use Mutex to avoid concurrent installs? - SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); - // Download the file to a temp path with progress + SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); SpectreAnsiConsole.Progress() .Start(ctx => @@ -221,6 +222,16 @@ public override int Execute() _dotnetInstaller.InstallSdks(resolvedInstallPath, ctx, new[] { resolvedChannelVersion }.Concat(additionalVersionsToInstall)); }); + if (resolvedSetDefaultInstall == true) + { + _dotnetInstaller.ConfigureInstallType(SdkInstallType.User, resolvedInstallPath); + } + + if (resolvedUpdateGlobalJson == true) + { + _dotnetInstaller.UpdateGlobalJson(globalJsonInfo!.GlobalJsonPath!, resolvedChannelVersion, globalJsonInfo.AllowPrerelease, globalJsonInfo.RollForward); + } + SpectreAnsiConsole.WriteLine($"Complete!"); @@ -337,8 +348,14 @@ void Download(string url, HttpClient httpClient, ProgressTask task) task.Value = 100; } - public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); - public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) => throw new NotImplementedException(); + public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) + { + SpectreAnsiConsole.WriteLine($"Updating {globalJsonPath} to SDK version {sdkVersion} (AllowPrerelease={allowPrerelease}, RollForward={rollForward})"); + } + public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) + { + SpectreAnsiConsole.WriteLine($"Configuring install type to {installType} (dotnetRoot={dotnetRoot})"); + } } class EnvironmentVariableMockReleaseInfoProvider : IReleaseInfoProvider From 19f42c85c366e15e9133a4f55347d1468d9b0810 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 22 Aug 2025 09:15:53 -0400 Subject: [PATCH 22/58] Initial ConfigureInstallType implementation Copilot prompt: Implement the #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaller.ConfigureInstallType':4479-4606 method. If the install type is user, remove any other folder with dotnet in it from the PATH and add the dotnetRoot to the PATH. Also set the DOTNET_ROOT environment variable to dotnetRoot. If the install type is Admin, unset DOTNET_ROOT, and add dotnetRoot to the path (removing any other dotnet folder from the path). If the install type is None, unset DOTNET_ROOT and remove any dotnet folder from the path. For any other install type, throw an ArgumentException. --- src/Installer/dnup/DotnetInstaller.cs | 39 ++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/Installer/dnup/DotnetInstaller.cs b/src/Installer/dnup/DotnetInstaller.cs index 120412b1e8ab..2ce205a030ea 100644 --- a/src/Installer/dnup/DotnetInstaller.cs +++ b/src/Installer/dnup/DotnetInstaller.cs @@ -103,5 +103,42 @@ public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) => throw new NotImplementedException(); public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); - public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) => throw new NotImplementedException(); + + public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) + { + // Get current PATH + var path = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; + var pathEntries = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); + // Remove all entries containing "dotnet" (case-insensitive) + pathEntries = pathEntries.Where(p => !p.Contains("dotnet", StringComparison.OrdinalIgnoreCase)).ToList(); + + switch (installType) + { + case SdkInstallType.User: + if (string.IsNullOrEmpty(dotnetRoot)) + throw new ArgumentNullException(nameof(dotnetRoot)); + // Add dotnetRoot to PATH + pathEntries.Insert(0, dotnetRoot); + // Set DOTNET_ROOT + Environment.SetEnvironmentVariable("DOTNET_ROOT", dotnetRoot, EnvironmentVariableTarget.User); + break; + case SdkInstallType.Admin: + if (string.IsNullOrEmpty(dotnetRoot)) + throw new ArgumentNullException(nameof(dotnetRoot)); + // Add dotnetRoot to PATH + pathEntries.Insert(0, dotnetRoot); + // Unset DOTNET_ROOT + Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); + break; + case SdkInstallType.None: + // Unset DOTNET_ROOT + Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); + break; + default: + throw new ArgumentException($"Unknown install type: {installType}", nameof(installType)); + } + // Update PATH + var newPath = string.Join(Path.PathSeparator, pathEntries); + Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.User); + } } From a559c31c9c677d5b8498cee12aeaec4a5937e44f Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 22 Aug 2025 09:19:22 -0400 Subject: [PATCH 23/58] Fix check for dotnet folder in PATH Copilot prompt: In #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaller.ConfigureInstallType':4481-6459 what I meant by removing a folder from the path if it has dotnet in it was that you should check the contents of each folder and if it is a dotnet installation folder then it should be removed from the path. A simple way to check if it's a dotnet installation folder is if it has a dotnet executable in it. --- src/Installer/dnup/DotnetInstaller.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Installer/dnup/DotnetInstaller.cs b/src/Installer/dnup/DotnetInstaller.cs index 2ce205a030ea..5c4a7ccce83f 100644 --- a/src/Installer/dnup/DotnetInstaller.cs +++ b/src/Installer/dnup/DotnetInstaller.cs @@ -109,8 +109,9 @@ public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot // Get current PATH var path = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; var pathEntries = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); - // Remove all entries containing "dotnet" (case-insensitive) - pathEntries = pathEntries.Where(p => !p.Contains("dotnet", StringComparison.OrdinalIgnoreCase)).ToList(); + string exeName = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + // Remove only actual dotnet installation folders from PATH + pathEntries = pathEntries.Where(p => !File.Exists(Path.Combine(p, exeName))).ToList(); switch (installType) { From c0075f81265f77cd5f4c332b3f5dbae0d082c6ec Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 25 Aug 2025 14:19:53 -0700 Subject: [PATCH 24/58] Add boilerplate types and interfaces to do installs I renamed the InstallType as the Runtime installs would similarly hold the same properties. --- src/Installer/dnup/DotnetInstall.cs | 38 +++++++++++++++++++++++ src/Installer/dnup/InstallArchitecture.cs | 16 ++++++++++ src/Installer/dnup/InstallMode.cs | 13 ++++++++ src/Installer/dnup/InstallType.cs | 14 +++++++++ 4 files changed, 81 insertions(+) create mode 100644 src/Installer/dnup/DotnetInstall.cs create mode 100644 src/Installer/dnup/InstallArchitecture.cs create mode 100644 src/Installer/dnup/InstallMode.cs create mode 100644 src/Installer/dnup/InstallType.cs diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/dnup/DotnetInstall.cs new file mode 100644 index 000000000000..46fac7f5a1ec --- /dev/null +++ b/src/Installer/dnup/DotnetInstall.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +/// +/// Base record for .NET installation information with common properties. +/// +internal record DotnetInstallBase( + string ResolvedDirectory, + InstallType Type, + InstallMode Mode, + InstallArchitecture Architecture) +{ + public Guid Id { get; } = Guid.NewGuid(); +} + +/// +/// Represents a .NET installation with a fully specified version. +/// +internal record DotnetInstall( + string FullySpecifiedVersion, + string ResolvedDirectory, + InstallType Type, + InstallMode Mode, + InstallArchitecture Architecture) : DotnetInstallBase(ResolvedDirectory, Type, Mode, Architecture); + +/// +/// Represents a request for a .NET installation with a channel version. +/// +internal record DotnetInstallRequest( + string ChannelVersion, + string ResolvedDirectory, + InstallType Type, + InstallMode Mode, + InstallArchitecture Architecture) : DotnetInstallBase(ResolvedDirectory, Type, Mode, Architecture); diff --git a/src/Installer/dnup/InstallArchitecture.cs b/src/Installer/dnup/InstallArchitecture.cs new file mode 100644 index 000000000000..67dbe11a2156 --- /dev/null +++ b/src/Installer/dnup/InstallArchitecture.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal enum InstallArchitecture + { + x86, + x64, + arm64 + } +} diff --git a/src/Installer/dnup/InstallMode.cs b/src/Installer/dnup/InstallMode.cs new file mode 100644 index 000000000000..14cbfd8e5ab8 --- /dev/null +++ b/src/Installer/dnup/InstallMode.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal enum InstallMode + { + SDK, + Runtime, + ASPNETCore, + WindowsDesktop + } +} diff --git a/src/Installer/dnup/InstallType.cs b/src/Installer/dnup/InstallType.cs new file mode 100644 index 000000000000..065b520e7e6b --- /dev/null +++ b/src/Installer/dnup/InstallType.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + public enum InstallType + { + None, + // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to + Inconsistent, + Admin, + User + } +} From 060a91b16eb9a68938ee47266108ef37f001f099 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 25 Aug 2025 14:28:53 -0700 Subject: [PATCH 25/58] Add base version class --- src/Installer/dnup/DotnetVersion.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/Installer/dnup/DotnetVersion.cs diff --git a/src/Installer/dnup/DotnetVersion.cs b/src/Installer/dnup/DotnetVersion.cs new file mode 100644 index 000000000000..fa438537d83a --- /dev/null +++ b/src/Installer/dnup/DotnetVersion.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal class DotnetVersion + { + } +} From a09f30f927522bd80bb21fc5636905d1bc2ec1fa Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 09:25:09 -0700 Subject: [PATCH 26/58] Add dotnetVersion class for version parsing --- src/Installer/dnup/DotnetVersion.cs | 353 +++++++++++++++++++++++++++- 1 file changed, 348 insertions(+), 5 deletions(-) diff --git a/src/Installer/dnup/DotnetVersion.cs b/src/Installer/dnup/DotnetVersion.cs index fa438537d83a..c25561ceac16 100644 --- a/src/Installer/dnup/DotnetVersion.cs +++ b/src/Installer/dnup/DotnetVersion.cs @@ -1,13 +1,356 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Text; +using System.Diagnostics; +using Microsoft.Deployment.DotNet.Releases; -namespace Microsoft.DotNet.Tools.Bootstrapper +namespace Microsoft.DotNet.Tools.Bootstrapper; + +/// +/// Represents the type of .NET version (SDK or Runtime). +/// +internal enum DotnetVersionType +{ + /// Automatically detect based on version format. + Auto, + /// SDK version (has feature bands, e.g., 8.0.301). + Sdk, + /// Runtime version (no feature bands, e.g., 8.0.7). + Runtime +} + +/// +/// Represents a .NET version string with specialized parsing, comparison, and manipulation capabilities. +/// Acts like a string but provides version-specific operations like feature band extraction and semantic comparisons. +/// Supports both SDK versions (with feature bands) and Runtime versions, and handles build hashes and preview versions. +/// +[DebuggerDisplay("{Value} ({VersionType})")] +internal readonly record struct DotnetVersion : IComparable, IComparable, IEquatable { - internal class DotnetVersion + private readonly ReleaseVersion? _releaseVersion; + + /// Gets the original version string value. + public string Value { get; } + + /// Gets the version type (SDK or Runtime). + public DotnetVersionType VersionType { get; } + + /// Gets the major version component (e.g., "8" from "8.0.301"). + public int Major => _releaseVersion?.Major ?? 0; + + /// Gets the minor version component (e.g., "0" from "8.0.301"). + public int Minor => _releaseVersion?.Minor ?? 0; + + /// Gets the patch version component (e.g., "301" from "8.0.301"). + public int Patch => _releaseVersion?.Patch ?? 0; + + /// Gets the major.minor version string (e.g., "8.0" from "8.0.301"). + public string MajorMinor => $"{Major}.{Minor}"; + + /// Gets whether this version represents a preview version (contains '-preview'). + public bool IsPreview => Value.Contains("-preview", StringComparison.OrdinalIgnoreCase); + + /// Gets whether this version represents a prerelease (contains '-' but not just build hash). + public bool IsPrerelease => Value.Contains('-') && !IsOnlyBuildHash(); + + /// Gets whether this is an SDK version (has feature bands). + public bool IsSdkVersion => VersionType == DotnetVersionType.Sdk || + (VersionType == DotnetVersionType.Auto && DetectVersionType() == DotnetVersionType.Sdk); + + /// Gets whether this is a Runtime version (no feature bands). + public bool IsRuntimeVersion => VersionType == DotnetVersionType.Runtime || + (VersionType == DotnetVersionType.Auto && DetectVersionType() == DotnetVersionType.Runtime); + + /// Gets whether this version contains a build hash. + public bool HasBuildHash => GetBuildHash() is not null; + + /// Gets whether this version is fully specified (e.g., "8.0.301" vs "8.0" or "8.0.3xx"). + public bool IsFullySpecified => _releaseVersion is not null && + !Value.Contains('x') && + Value.Split('.').Length >= 3; + + /// Gets whether this version uses a non-specific feature band pattern (e.g., "8.0.3xx"). + public bool IsNonSpecificFeatureBand => Value.EndsWith('x') && Value.Split('.').Length == 3; + + /// Gets whether this is just a major or major.minor version (e.g., "8" or "8.0"). + public bool IsNonSpecificMajorMinor => Value.Split('.').Length <= 2 && + Value.Split('.').All(x => int.TryParse(x, out _)); + + /// + /// Initializes a new instance with the specified version string. + /// + /// The version string to parse. + /// The type of version (SDK or Runtime). Auto-detects if not specified. + public DotnetVersion(string? value, DotnetVersionType versionType = DotnetVersionType.Auto) + { + Value = value ?? string.Empty; + VersionType = versionType; + _releaseVersion = ReleaseVersion.TryParse(GetVersionWithoutBuildHash(), out var version) ? version : null; + } + + /// + /// Gets the feature band number from the SDK version (e.g., "3" from "8.0.301"). + /// Returns null if this is not an SDK version or doesn't contain a feature band. + /// + public string? GetFeatureBand() + { + if (!IsSdkVersion) return null; + + var parts = GetVersionWithoutBuildHash().Split('.'); + if (parts.Length < 3) return null; + + var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix + return patchPart.Length > 0 ? patchPart[0].ToString() : null; + } + + /// + /// Gets the feature band patch version (e.g., "01" from "8.0.301"). + /// Returns null if this is not an SDK version or doesn't contain a feature band. + /// + public string? GetFeatureBandPatch() { + if (!IsSdkVersion) return null; + + var parts = GetVersionWithoutBuildHash().Split('.'); + if (parts.Length < 3) return null; + + var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix + return patchPart.Length > 1 ? patchPart[1..] : null; } + + /// + /// Gets the complete feature band including patch (e.g., "301" from "8.0.301"). + /// Returns null if this is not an SDK version or doesn't contain a feature band. + /// + public string? GetCompleteBandAndPatch() + { + if (!IsSdkVersion) return null; + + var parts = GetVersionWithoutBuildHash().Split('.'); + if (parts.Length < 3) return null; + + return parts[2].Split('-')[0]; // Remove prerelease suffix if present + } + + /// + /// Gets the prerelease identifier if this is a prerelease version. + /// + public string? GetPrereleaseIdentifier() + { + var dashIndex = Value.IndexOf('-'); + return dashIndex >= 0 ? Value[(dashIndex + 1)..] : null; + } + + /// + /// Gets the build hash from the version if present (typically after a '+' or at the end of prerelease). + /// Examples: "8.0.301+abc123" -> "abc123", "8.0.301-preview.1.abc123" -> "abc123" + /// + public string? GetBuildHash() + { + // Build hash after '+' + var plusIndex = Value.IndexOf('+'); + if (plusIndex >= 0) + return Value[(plusIndex + 1)..]; + + // Build hash in prerelease (look for hex-like string at the end) + var prerelease = GetPrereleaseIdentifier(); + if (prerelease is null) return null; + + var parts = prerelease.Split('.'); + var lastPart = parts[^1]; + + // Check if last part looks like a build hash (hex string, 6+ chars) + if (lastPart.Length >= 6 && lastPart.All(c => char.IsAsciiHexDigit(c))) + return lastPart; + + return null; + } + + /// + /// Gets the version string without any build hash component. + /// + public string GetVersionWithoutBuildHash() + { + var buildHash = GetBuildHash(); + if (buildHash is null) return Value; + + // Remove build hash after '+' + var plusIndex = Value.IndexOf('+'); + if (plusIndex >= 0) + return Value[..plusIndex]; + + // Remove build hash from prerelease + return Value.Replace($".{buildHash}", ""); + } + + /// + /// Detects whether this is an SDK or Runtime version based on the version format. + /// SDK versions typically have 3-digit patch numbers (feature bands), Runtime versions have 1-2 digit patch numbers. + /// + private DotnetVersionType DetectVersionType() + { + var parts = GetVersionWithoutBuildHash().Split('.', '-'); + if (parts.Length < 3) return DotnetVersionType.Runtime; + + var patchPart = parts[2]; + + // SDK versions typically have 3-digit patch numbers (e.g., 301, 201) + // Runtime versions have 1-2 digit patch numbers (e.g., 7, 12) + if (patchPart.Length >= 3 && patchPart.All(char.IsDigit)) + return DotnetVersionType.Sdk; + + return DotnetVersionType.Runtime; + } + + /// + /// Checks if the version only contains a build hash (no other prerelease identifiers). + /// + private bool IsOnlyBuildHash() + { + var dashIndex = Value.IndexOf('-'); + if (dashIndex < 0) return false; + + var afterDash = Value[(dashIndex + 1)..]; + + // Check if what follows the dash is just a build hash + return afterDash.Length >= 6 && afterDash.All(c => char.IsAsciiHexDigit(c)); + } + + /// + /// Creates a new version with the specified patch version while preserving other components. + /// + public DotnetVersion WithPatch(int patch) + { + var parts = Value.Split('.'); + if (parts.Length < 3) + return new DotnetVersion($"{Major}.{Minor}.{patch:D3}"); + + var prereleaseAndBuild = GetPrereleaseAndBuildSuffix(); + return new DotnetVersion($"{Major}.{Minor}.{patch:D3}{prereleaseAndBuild}"); + } + + /// + /// Creates a new version with the specified feature band while preserving other components. + /// + public DotnetVersion WithFeatureBand(int featureBand) + { + var currentPatch = GetFeatureBandPatch(); + var patch = $"{featureBand}{currentPatch ?? "00"}"; + var prereleaseAndBuild = GetPrereleaseAndBuildSuffix(); + return new DotnetVersion($"{Major}.{Minor}.{patch}{prereleaseAndBuild}"); + } + + private string GetPrereleaseAndBuildSuffix() + { + var dashIndex = Value.IndexOf('-'); + return dashIndex >= 0 ? Value[dashIndex..] : string.Empty; + } + + /// + /// Validates that this version string represents a well-formed, fully specified version. + /// + public bool IsValidFullySpecifiedVersion() + { + if (!IsFullySpecified) return false; + + var parts = Value.Split('.', '-')[0].Split('.'); + if (parts.Length < 3 || Value.Length > 20) return false; + + // Check that patch version is reasonable (1-2 digits for feature band, 1-2 for patch) + return parts.All(p => int.TryParse(p, out _)) && parts[2].Length is >= 2 and <= 3; + } + + #region String-like behavior + + public static implicit operator string(DotnetVersion version) => version.Value; + public static implicit operator DotnetVersion(string version) => new(version); + + /// + /// Creates an SDK version from a string. + /// + public static DotnetVersion FromSdk(string version) => new(version, DotnetVersionType.Sdk); + + /// + /// Creates a Runtime version from a string. + /// + public static DotnetVersion FromRuntime(string version) => new(version, DotnetVersionType.Runtime); + + public override string ToString() => Value; + + public bool Equals(string? other) => string.Equals(Value, other, StringComparison.Ordinal); + + #endregion + + #region IComparable implementations + + public int CompareTo(DotnetVersion other) + { + // Use semantic version comparison if both are valid release versions + if (_releaseVersion is not null && other._releaseVersion is not null) + return _releaseVersion.CompareTo(other._releaseVersion); + + // Fall back to string comparison + return string.Compare(Value, other.Value, StringComparison.Ordinal); + } + + public int CompareTo(string? other) + { + if (other is null) return 1; + return CompareTo(new DotnetVersion(other)); + } + + #endregion + + #region Static utility methods + + /// + /// Determines whether the specified string represents a valid .NET version format. + /// + public static bool IsValidFormat(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + return new DotnetVersion(value).IsValidFullySpecifiedVersion() || + new DotnetVersion(value).IsNonSpecificFeatureBand || + new DotnetVersion(value).IsNonSpecificMajorMinor; + } + + /// + /// Tries to parse a version string into a DotnetVersion. + /// + /// The version string to parse. + /// The parsed version if successful. + /// The type of version to parse. Auto-detects if not specified. + public static bool TryParse(string? value, out DotnetVersion version, DotnetVersionType versionType = DotnetVersionType.Auto) + { + version = new DotnetVersion(value, versionType); + return IsValidFormat(value); + } + + /// + /// Parses a version string into a DotnetVersion, throwing on invalid format. + /// + /// The version string to parse. + /// The type of version to parse. Auto-detects if not specified. + public static DotnetVersion Parse(string value, DotnetVersionType versionType = DotnetVersionType.Auto) + { + if (!TryParse(value, out var version, versionType)) + throw new ArgumentException($"'{value}' is not a valid .NET version format.", nameof(value)); + return version; + } + + #endregion + + #region String comparison operators + + public static bool operator <(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) < 0; + public static bool operator <=(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) <= 0; + public static bool operator >(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) > 0; + public static bool operator >=(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) >= 0; + + public static bool operator ==(DotnetVersion left, string? right) => left.Equals(right); + public static bool operator !=(DotnetVersion left, string? right) => !left.Equals(right); + public static bool operator ==(string? left, DotnetVersion right) => right.Equals(left); + public static bool operator !=(string? left, DotnetVersion right) => !right.Equals(left); + + #endregion } From 3ad43e42095cd25b35166aaa275999f344c80689 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 10:22:14 -0700 Subject: [PATCH 27/58] Add basic tests --- sdk.slnx | 1 + src/Installer/dnup/DotnetVersion.cs | 99 ++++++++++-- src/Installer/dnup/InstallType.cs | 2 +- src/Installer/dnup/Program.cs | 14 +- src/Installer/dnup/dnup.csproj | 6 +- test/dnup.Tests/DotnetInstallTests.cs | 75 +++++++++ test/dnup.Tests/DotnetVersionTests.cs | 212 ++++++++++++++++++++++++++ test/dnup.Tests/ParserTests.cs | 65 ++++++++ test/dnup.Tests/dnup.Tests.csproj | 17 +++ 9 files changed, 470 insertions(+), 21 deletions(-) create mode 100644 test/dnup.Tests/DotnetInstallTests.cs create mode 100644 test/dnup.Tests/DotnetVersionTests.cs create mode 100644 test/dnup.Tests/ParserTests.cs create mode 100644 test/dnup.Tests/dnup.Tests.csproj diff --git a/sdk.slnx b/sdk.slnx index 4726827cd086..2ea3064bed5f 100644 --- a/sdk.slnx +++ b/sdk.slnx @@ -294,6 +294,7 @@ + diff --git a/src/Installer/dnup/DotnetVersion.cs b/src/Installer/dnup/DotnetVersion.cs index c25561ceac16..b0a89fa7fffb 100644 --- a/src/Installer/dnup/DotnetVersion.cs +++ b/src/Installer/dnup/DotnetVersion.cs @@ -36,10 +36,10 @@ internal enum DotnetVersionType public DotnetVersionType VersionType { get; } /// Gets the major version component (e.g., "8" from "8.0.301"). - public int Major => _releaseVersion?.Major ?? 0; + public int Major => _releaseVersion?.Major ?? ParseMajorDirect(); /// Gets the minor version component (e.g., "0" from "8.0.301"). - public int Minor => _releaseVersion?.Minor ?? 0; + public int Minor => _releaseVersion?.Minor ?? ParseMinorDirect(); /// Gets the patch version component (e.g., "301" from "8.0.301"). public int Patch => _releaseVersion?.Patch ?? 0; @@ -47,14 +47,17 @@ internal enum DotnetVersionType /// Gets the major.minor version string (e.g., "8.0" from "8.0.301"). public string MajorMinor => $"{Major}.{Minor}"; - /// Gets whether this version represents a preview version (contains '-preview'). - public bool IsPreview => Value.Contains("-preview", StringComparison.OrdinalIgnoreCase); + /// Gets whether this version represents a preview version (contains preview, rc, alpha, beta, etc.). + public bool IsPreview => Value.Contains("-preview", StringComparison.OrdinalIgnoreCase) || + Value.Contains("-rc", StringComparison.OrdinalIgnoreCase) || + Value.Contains("-alpha", StringComparison.OrdinalIgnoreCase) || + Value.Contains("-beta", StringComparison.OrdinalIgnoreCase); /// Gets whether this version represents a prerelease (contains '-' but not just build hash). public bool IsPrerelease => Value.Contains('-') && !IsOnlyBuildHash(); /// Gets whether this is an SDK version (has feature bands). - public bool IsSdkVersion => VersionType == DotnetVersionType.Sdk || + public bool IsSdkVersion => VersionType == DotnetVersionType.Sdk || (VersionType == DotnetVersionType.Auto && DetectVersionType() == DotnetVersionType.Sdk); /// Gets whether this is a Runtime version (no feature bands). @@ -95,11 +98,16 @@ public DotnetVersion(string? value, DotnetVersionType versionType = DotnetVersio public string? GetFeatureBand() { if (!IsSdkVersion) return null; - + var parts = GetVersionWithoutBuildHash().Split('.'); if (parts.Length < 3) return null; var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix + + // For SDK versions, feature band is the hundreds digit + // Runtime versions like "8.0.7" should return null, not "7" + if (patchPart.Length < 3) return null; + return patchPart.Length > 0 ? patchPart[0].ToString() : null; } @@ -110,11 +118,15 @@ public DotnetVersion(string? value, DotnetVersionType versionType = DotnetVersio public string? GetFeatureBandPatch() { if (!IsSdkVersion) return null; - + var parts = GetVersionWithoutBuildHash().Split('.'); if (parts.Length < 3) return null; var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix + + // For SDK versions, patch is the last two digits + if (patchPart.Length < 3) return null; + return patchPart.Length > 1 ? patchPart[1..] : null; } @@ -125,11 +137,16 @@ public DotnetVersion(string? value, DotnetVersionType versionType = DotnetVersio public string? GetCompleteBandAndPatch() { if (!IsSdkVersion) return null; - + var parts = GetVersionWithoutBuildHash().Split('.'); if (parts.Length < 3) return null; - return parts[2].Split('-')[0]; // Remove prerelease suffix if present + var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix + + // For SDK versions, complete band is 3-digit patch + if (patchPart.Length < 3) return null; + + return patchPart; } /// @@ -158,7 +175,7 @@ public DotnetVersion(string? value, DotnetVersionType versionType = DotnetVersio var parts = prerelease.Split('.'); var lastPart = parts[^1]; - + // Check if last part looks like a build hash (hex string, 6+ chars) if (lastPart.Length >= 6 && lastPart.All(c => char.IsAsciiHexDigit(c))) return lastPart; @@ -193,12 +210,12 @@ private DotnetVersionType DetectVersionType() if (parts.Length < 3) return DotnetVersionType.Runtime; var patchPart = parts[2]; - + // SDK versions typically have 3-digit patch numbers (e.g., 301, 201) // Runtime versions have 1-2 digit patch numbers (e.g., 7, 12) if (patchPart.Length >= 3 && patchPart.All(char.IsDigit)) return DotnetVersionType.Sdk; - + return DotnetVersionType.Runtime; } @@ -211,7 +228,7 @@ private bool IsOnlyBuildHash() if (dashIndex < 0) return false; var afterDash = Value[(dashIndex + 1)..]; - + // Check if what follows the dash is just a build hash return afterDash.Length >= 6 && afterDash.All(c => char.IsAsciiHexDigit(c)); } @@ -260,6 +277,24 @@ public bool IsValidFullySpecifiedVersion() return parts.All(p => int.TryParse(p, out _)) && parts[2].Length is >= 2 and <= 3; } + /// + /// Parses major version directly from string for cases where ReleaseVersion parsing fails. + /// + private int ParseMajorDirect() + { + var parts = Value.Split('.'); + return parts.Length > 0 && int.TryParse(parts[0], out var major) ? major : 0; + } + + /// + /// Parses minor version directly from string for cases where ReleaseVersion parsing fails. + /// + private int ParseMinorDirect() + { + var parts = Value.Split('.'); + return parts.Length > 1 && int.TryParse(parts[1], out var minor) ? minor : 0; + } + #region String-like behavior public static implicit operator string(DotnetVersion version) => version.Value; @@ -309,9 +344,41 @@ public int CompareTo(string? other) public static bool IsValidFormat(string? value) { if (string.IsNullOrWhiteSpace(value)) return false; - return new DotnetVersion(value).IsValidFullySpecifiedVersion() || - new DotnetVersion(value).IsNonSpecificFeatureBand || - new DotnetVersion(value).IsNonSpecificMajorMinor; + + var version = new DotnetVersion(value); + + // Valid formats: + // - Fully specified versions: "8.0.301", "7.0.201" + // - Non-specific feature bands: "7.0.2xx" + // - Major.minor versions: "8.0", "7.0" + // - Major only versions: "8", "7" + // - Exclude unreasonable versions like high patch numbers or runtime-like versions with small patch + + if (version.IsFullySpecified) + { + var parts = value.Split('.'); + if (parts.Length >= 3 && int.TryParse(parts[2], out var patch)) + { + // Unreasonably high patch numbers are invalid (e.g., 7.0.1999) + if (patch > 999) return false; + + // Small patch numbers (1-2 digits) are runtime versions and should be valid + // but versions like "7.1.10" are questionable since .NET 7.1 doesn't exist + if (patch < 100 && version.Major <= 8 && version.Minor > 0) return false; + } + return true; + } + + if (version.IsNonSpecificFeatureBand) return true; + + if (version.IsNonSpecificMajorMinor) + { + // Allow reasonable major.minor combinations + // Exclude things like "10.10" which don't make sense for .NET versioning + if (version.Major <= 20 && version.Minor <= 9) return true; + } + + return false; } /// diff --git a/src/Installer/dnup/InstallType.cs b/src/Installer/dnup/InstallType.cs index 065b520e7e6b..688ac1692440 100644 --- a/src/Installer/dnup/InstallType.cs +++ b/src/Installer/dnup/InstallType.cs @@ -3,7 +3,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper { - public enum InstallType + internal enum InstallType { None, // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to diff --git a/src/Installer/dnup/Program.cs b/src/Installer/dnup/Program.cs index e0c8b17b1476..ee656bfa6003 100644 --- a/src/Installer/dnup/Program.cs +++ b/src/Installer/dnup/Program.cs @@ -1,6 +1,14 @@  using Microsoft.DotNet.Tools.Bootstrapper; -var parseResult = Parser.Parse(args); - -return Parser.Invoke(parseResult); +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal class DnupProgram + { + public static int Main(string[] args) + { + var parseResult = Parser.Parse(args); + return Parser.Invoke(parseResult); + } + } +} diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index c95bd4902909..3d343aae531e 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -15,6 +15,10 @@ Microsoft.DotNet.Tools.Bootstrapper + + + + @@ -32,5 +36,5 @@ - + diff --git a/test/dnup.Tests/DotnetInstallTests.cs b/test/dnup.Tests/DotnetInstallTests.cs new file mode 100644 index 000000000000..5e4e90812974 --- /dev/null +++ b/test/dnup.Tests/DotnetInstallTests.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Dnup.Tests; + +public class DotnetInstallTests +{ + [Fact] + public void DotnetInstallBase_ShouldInitializeCorrectly() + { + var directory = "/test/directory"; + var type = InstallType.User; + var mode = InstallMode.SDK; + var architecture = InstallArchitecture.x64; + + var install = new DotnetInstallBase(directory, type, mode, architecture); + + install.ResolvedDirectory.Should().Be(directory); + install.Type.Should().Be(type); + install.Mode.Should().Be(mode); + install.Architecture.Should().Be(architecture); + install.Id.Should().NotBe(Guid.Empty); + } + + [Fact] + public void DotnetInstall_ShouldInheritFromBase() + { + var version = "8.0.301"; + var directory = "/test/directory"; + var type = InstallType.User; + var mode = InstallMode.SDK; + var architecture = InstallArchitecture.x64; + + var install = new DotnetInstall(version, directory, type, mode, architecture); + + install.FullySpecifiedVersion.Should().Be(version); + install.ResolvedDirectory.Should().Be(directory); + install.Type.Should().Be(type); + install.Mode.Should().Be(mode); + install.Architecture.Should().Be(architecture); + install.Id.Should().NotBe(Guid.Empty); + } + + [Fact] + public void MultipleInstances_ShouldHaveUniqueIds() + { + // Arrange & Act + var install1 = new DotnetInstallBase("dir1", InstallType.User, InstallMode.SDK, InstallArchitecture.x64); + var install2 = new DotnetInstallBase("dir2", InstallType.Admin, InstallMode.Runtime, InstallArchitecture.x64); + + // Assert + install1.Id.Should().NotBe(install2.Id); + } + + [Fact] + public void Records_ShouldSupportValueEquality() + { + // Arrange + var install1 = new DotnetInstall("8.0.301", "/test", InstallType.User, InstallMode.SDK, InstallArchitecture.x64); + var install2 = new DotnetInstall("8.0.301", "/test", InstallType.User, InstallMode.SDK, InstallArchitecture.x64); + + // Act & Assert + // Records should be equal based on values, except for the Id which is always unique + install1.FullySpecifiedVersion.Should().Be(install2.FullySpecifiedVersion); + install1.ResolvedDirectory.Should().Be(install2.ResolvedDirectory); + install1.Type.Should().Be(install2.Type); + install1.Mode.Should().Be(install2.Mode); + install1.Architecture.Should().Be(install2.Architecture); + + // But Ids should be different + install1.Id.Should().NotBe(install2.Id); + } +} diff --git a/test/dnup.Tests/DotnetVersionTests.cs b/test/dnup.Tests/DotnetVersionTests.cs new file mode 100644 index 000000000000..cf661a8e2366 --- /dev/null +++ b/test/dnup.Tests/DotnetVersionTests.cs @@ -0,0 +1,212 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Dnup.Tests; + +public class DotnetVersionTests +{ + [Theory] + [InlineData("7.0.201", "7")] + [InlineData("7.0.2xx", "7")] + [InlineData("7.1.300", "7")] + [InlineData("10.0.102", "10")] + [InlineData("7", "7")] + [InlineData("7.0", "7")] + public void GetMajor(string version, string expected) => + new DotnetVersion(version).Major.ToString().Should().Be(expected); + + [Theory] + [InlineData("7.0.201", "0")] + [InlineData("7.1.300", "1")] + [InlineData("10.0.102", "0")] + [InlineData("7", "0")] + [InlineData("7.0", "0")] + public void GetMinor(string version, string expected) => + new DotnetVersion(version).Minor.ToString().Should().Be(expected); + + [Theory] + [InlineData("7.0.201", "7.0")] + [InlineData("7.0.2xx", "7.0")] + [InlineData("7.1.300", "7.1")] + [InlineData("10.0.102", "10.0")] + [InlineData("7", "7.0")] + [InlineData("7.0", "7.0")] + public void GetMajorMinor(string version, string expected) => + new DotnetVersion(version).MajorMinor.Should().Be(expected); + + [Theory] + [InlineData("7.0.201", "2")] + [InlineData("7.0.2xx", "2")] + [InlineData("7.1.300", "3")] + [InlineData("10.0.102", "1")] + [InlineData("7.0.221", "2")] + [InlineData("7.0.7", null)] + [InlineData("8.0", null)] + public void GetFeatureBand(string version, string? expected) => + DotnetVersion.FromSdk(version).GetFeatureBand().Should().Be(expected); + + [Theory] + [InlineData("7.0.201", "01")] + [InlineData("7.1.300", "00")] + [InlineData("10.0.102", "02")] + [InlineData("7.0.221", "21")] + [InlineData("8.0.400-preview.0.24324.5", "00")] + [InlineData("7.0.7", null)] + [InlineData("8.0", null)] + public void GetFeatureBandPatch(string version, string? expected) => + DotnetVersion.FromSdk(version).GetFeatureBandPatch().Should().Be(expected); + + [Theory] + [InlineData("7.0.201", "201")] + [InlineData("7.1.300", "300")] + [InlineData("10.0.102", "102")] + [InlineData("7.0.221", "221")] + [InlineData("7.0.7", null)] + [InlineData("8.0", null)] + public void GetCompleteBandAndPatch(string version, string? expected) => + DotnetVersion.FromSdk(version).GetCompleteBandAndPatch().Should().Be(expected); + + [Theory] + [InlineData("7.0", null)] + [InlineData("8.0.10", "10")] + [InlineData("8.0.9-rc.2.24502.A", "9")] + public void GetRuntimePatch(string version, string? expected) + { + var v = DotnetVersion.FromRuntime(version); + var patch = v.Patch == 0 ? null : v.Patch.ToString(); + patch.Should().Be(expected); + } + + [Theory] + [InlineData("8.0.400-preview.0.24324.5", true)] + [InlineData("9.0.0-rc.2", true)] + [InlineData("9.0.0-rc.2.24473.5", true)] + [InlineData("8.0.0-preview.7", true)] + [InlineData("10.0.0-alpha.2.24522.8", true)] + [InlineData("7.0.2xx", false)] + [InlineData("7.0", false)] + [InlineData("7.1.10", false)] + [InlineData("7.0.201", false)] + [InlineData("10.0.100-rc.2.25420.109", true)] + public void IsPreview(string version, bool expected) => + new DotnetVersion(version).IsPreview.Should().Be(expected); + + [Theory] + [InlineData("7.0.201", false)] + [InlineData("7.0.2xx", true)] + [InlineData("10.0.102", false)] + public void IsNonSpecificFeatureBand(string version, bool expected) => + new DotnetVersion(version).IsNonSpecificFeatureBand.Should().Be(expected); + + [Theory] + [InlineData("7.0.201", true)] + [InlineData("7.1.300", true)] + [InlineData("10.0.102", true)] + [InlineData("7", false)] + [InlineData("7.0.2xx", false)] + [InlineData("7.0", false)] + public void IsFullySpecified(string version, bool expected) => + new DotnetVersion(version).IsFullySpecified.Should().Be(expected); + + [Theory] + [InlineData("7.0.201", false)] + [InlineData("7.1.300", false)] + [InlineData("10.0.102", false)] + [InlineData("7", true)] + [InlineData("7.0.2xx", false)] + [InlineData("7.0", true)] + public void IsNonSpecificMajorMinor(string version, bool expected) => + new DotnetVersion(version).IsNonSpecificMajorMinor.Should().Be(expected); + + [Theory] + [InlineData("7.0.201", true)] + [InlineData("7.1.300", true)] + [InlineData("10.0.102", true)] + [InlineData("7.0.2xx", true)] + [InlineData("7", true)] + [InlineData("7.0", true)] + [InlineData("7.0.1999", false)] + [InlineData("7.1.10", false)] + [InlineData("10.10", false)] + public void IsValidFormat(string version, bool expected) => + DotnetVersion.IsValidFormat(version).Should().Be(expected); + + [Theory] + [InlineData("8.0.301", 0, true, false)] // Auto + [InlineData("8.0.7", 0, false, true)] // Auto + [InlineData("8.0.301", 1, true, false)] // Sdk + [InlineData("8.0.7", 2, false, true)] // Runtime + [InlineData("8.0.7", 1, true, false)] // Sdk + public void VersionTypeDetection(string version, int typeInt, bool isSdk, bool isRuntime) + { + var type = (DotnetVersionType)typeInt; + var v = new DotnetVersion(version, type); + v.IsSdkVersion.Should().Be(isSdk); + v.IsRuntimeVersion.Should().Be(isRuntime); + } + + [Theory] + [InlineData("8.0.301+abc123def456", "abc123def456")] + [InlineData("8.0.301-preview.1.abc123", "abc123")] + [InlineData("8.0.301-abc123def", "abc123def")] + [InlineData("8.0.301", null)] + [InlineData("8.0.301-preview.1", null)] + public void GetBuildHash(string version, string? expected) => + new DotnetVersion(version).GetBuildHash().Should().Be(expected); + + [Theory] + [InlineData("8.0.301+abc123def456", "8.0.301")] + [InlineData("8.0.301-preview.1.abc123", "8.0.301-preview.1")] + [InlineData("8.0.301", "8.0.301")] + public void GetVersionWithoutBuildHash(string version, string expected) => + new DotnetVersion(version).GetVersionWithoutBuildHash().Should().Be(expected); + + [Theory] + [InlineData("8.0.301", "8.0.302", -1)] + [InlineData("8.0.302", "8.0.301", 1)] + [InlineData("8.0.301", "8.0.301", 0)] + public void Comparison(string v1, string v2, int expected) + { + var result = new DotnetVersion(v1).CompareTo(new DotnetVersion(v2)); + if (expected < 0) result.Should().BeNegative(); + else if (expected > 0) result.Should().BePositive(); + else result.Should().Be(0); + } + + [Fact] + public void FactoryMethods() + { + var sdk = DotnetVersion.FromSdk("8.0.7"); + var runtime = DotnetVersion.FromRuntime("8.0.301"); + + sdk.IsSdkVersion.Should().BeTrue(); + sdk.IsRuntimeVersion.Should().BeFalse(); + runtime.IsSdkVersion.Should().BeFalse(); + runtime.IsRuntimeVersion.Should().BeTrue(); + } + + [Fact] + public void ImplicitConversions() + { + DotnetVersion version = "8.0.301"; + string versionString = version; + + version.Value.Should().Be("8.0.301"); + versionString.Should().Be("8.0.301"); + } + + [Fact] + public void TryParse() + { + DotnetVersion.TryParse("8.0.301", out var valid).Should().BeTrue(); + valid.Value.Should().Be("8.0.301"); + + DotnetVersion.TryParse("invalid", out _).Should().BeFalse(); + } + + [Fact] + public void Parse() => + new Action(() => DotnetVersion.Parse("invalid")).Should().Throw(); +} diff --git a/test/dnup.Tests/ParserTests.cs b/test/dnup.Tests/ParserTests.cs new file mode 100644 index 000000000000..20f4958472b0 --- /dev/null +++ b/test/dnup.Tests/ParserTests.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Dnup.Tests; + +public class ParserTests +{ + [Fact] + public void Parser_ShouldParseValidCommands() + { + // Arrange + var args = new[] { "sdk", "install", "8.0" }; + + // Act + var parseResult = Parser.Parse(args); + + // Assert + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().BeEmpty(); + } + + [Fact] + public void Parser_ShouldHandleInvalidCommands() + { + // Arrange + var args = new[] { "invalid-command" }; + + // Act + var parseResult = Parser.Parse(args); + + // Assert + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().NotBeEmpty(); + } + + [Fact] + public void Parser_ShouldHandleSdkHelp() + { + // Arrange + var args = new[] { "sdk", "--help" }; + + // Act + var parseResult = Parser.Parse(args); + + // Assert + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().BeEmpty(); + } + + [Fact] + public void Parser_ShouldHandleRootHelp() + { + // Arrange + var args = new[] { "--help" }; + + // Act + var parseResult = Parser.Parse(args); + + // Assert + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().BeEmpty(); + } +} diff --git a/test/dnup.Tests/dnup.Tests.csproj b/test/dnup.Tests/dnup.Tests.csproj new file mode 100644 index 000000000000..ca702c950709 --- /dev/null +++ b/test/dnup.Tests/dnup.Tests.csproj @@ -0,0 +1,17 @@ + + + + enable + $(ToolsetTargetFramework) + Exe + Microsoft.DotNet.Tools.Bootstrapper + true + Tests\$(MSBuildProjectName) + + + + + + + + From ee2a6c60f48b16788a605e4d6c83205008f6016c Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 10:32:10 -0700 Subject: [PATCH 28/58] Replace SDKInstallType with InstallType --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 26 +++++++++---------- src/Installer/dnup/DotnetInstall.cs | 2 +- src/Installer/dnup/DotnetInstaller.cs | 20 +++++++------- src/Installer/dnup/IDotnetInstaller.cs | 13 ++-------- src/Installer/dnup/InstallType.cs | 2 +- 5 files changed, 27 insertions(+), 36 deletions(-) diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 590eab9f3f48..a448904bdb9e 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -37,7 +37,7 @@ public override int Execute() var globalJsonInfo = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory); string? currentInstallPath; - SdkInstallType defaultInstallState = _dotnetInstaller.GetConfiguredInstallType(out currentInstallPath); + InstallType defaultInstallState = _dotnetInstaller.GetConfiguredInstallType(out currentInstallPath); string? resolvedInstallPath = null; @@ -63,7 +63,7 @@ public override int Execute() resolvedInstallPath = _installPath; } - if (resolvedInstallPath == null && defaultInstallState == SdkInstallType.User) + if (resolvedInstallPath == null && defaultInstallState == InstallType.User) { // If a user installation is already set up, we don't need to prompt for the install path resolvedInstallPath = currentInstallPath; @@ -141,13 +141,13 @@ public override int Execute() // If global.json specified an install path, we don't prompt for setting the default install path (since you probably don't want to do that for a repo-local path) if (_interactive && installPathFromGlobalJson == null) { - if (defaultInstallState == SdkInstallType.None) + if (defaultInstallState == InstallType.None) { resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( $"Do you want to set the install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } - else if (defaultInstallState == SdkInstallType.User) + else if (defaultInstallState == InstallType.User) { // Another case where we need to compare paths and the comparison may or may not need to be case-sensitive if (resolvedInstallPath.Equals(currentInstallPath, StringComparison.OrdinalIgnoreCase)) @@ -161,7 +161,7 @@ public override int Execute() defaultValue: false); } } - else if (defaultInstallState == SdkInstallType.Admin) + else if (defaultInstallState == InstallType.Admin) { SpectreAnsiConsole.WriteLine($"You have an existing admin install of .NET in {currentInstallPath}. We can configure your system to use the new install of .NET " + $"in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); @@ -170,7 +170,7 @@ public override int Execute() $"Do you want to set the user install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } - else if (defaultInstallState == SdkInstallType.Inconsistent) + else if (defaultInstallState == InstallType.Inconsistent) { // TODO: Figure out what to do here resolvedSetDefaultInstall = false; @@ -186,7 +186,7 @@ public override int Execute() var resolvedChannelVersion = _releaseInfoProvider.GetLatestVersion(resolvedChannel); - if (resolvedSetDefaultInstall == true && defaultInstallState == SdkInstallType.Admin) + if (resolvedSetDefaultInstall == true && defaultInstallState == InstallType.Admin) { if (_interactive) { @@ -224,7 +224,7 @@ public override int Execute() if (resolvedSetDefaultInstall == true) { - _dotnetInstaller.ConfigureInstallType(SdkInstallType.User, resolvedInstallPath); + _dotnetInstaller.ConfigureInstallType(InstallType.User, resolvedInstallPath); } if (resolvedUpdateGlobalJson == true) @@ -269,13 +269,13 @@ public string GetDefaultDotnetInstallPath() return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); } - public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) + public InstallType GetConfiguredInstallType(out string? currentInstallPath) { var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); - SdkInstallType returnValue = SdkInstallType.None; - if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) + InstallType returnValue = InstallType.None; + if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) { - returnValue = SdkInstallType.None; + returnValue = InstallType.None; } currentInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); return returnValue; @@ -352,7 +352,7 @@ public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, b { SpectreAnsiConsole.WriteLine($"Updating {globalJsonPath} to SDK version {sdkVersion} (AllowPrerelease={allowPrerelease}, RollForward={rollForward})"); } - public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) + public void ConfigureInstallType(InstallType installType, string? dotnetRoot = null) { SpectreAnsiConsole.WriteLine($"Configuring install type to {installType} (dotnetRoot={dotnetRoot})"); } diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/dnup/DotnetInstall.cs index 46fac7f5a1ec..a867b4f66fe8 100644 --- a/src/Installer/dnup/DotnetInstall.cs +++ b/src/Installer/dnup/DotnetInstall.cs @@ -28,7 +28,7 @@ internal record DotnetInstall( InstallArchitecture Architecture) : DotnetInstallBase(ResolvedDirectory, Type, Mode, Architecture); /// -/// Represents a request for a .NET installation with a channel version. +/// Represents a request for a .NET installation with a channel version that will get resolved into a fully specified version. /// internal record DotnetInstallRequest( string ChannelVersion, diff --git a/src/Installer/dnup/DotnetInstaller.cs b/src/Installer/dnup/DotnetInstaller.cs index 5c4a7ccce83f..d345c684747e 100644 --- a/src/Installer/dnup/DotnetInstaller.cs +++ b/src/Installer/dnup/DotnetInstaller.cs @@ -19,13 +19,13 @@ public DotnetInstaller(IEnvironmentProvider? environmentProvider = null) _environmentProvider = environmentProvider ?? new EnvironmentProvider(); } - public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) + public InstallType GetConfiguredInstallType(out string? currentInstallPath) { currentInstallPath = null; string? foundDotnet = _environmentProvider.GetCommandPath("dotnet"); if (string.IsNullOrEmpty(foundDotnet)) { - return SdkInstallType.None; + return InstallType.None; } string installDir = Path.GetDirectoryName(foundDotnet)!; @@ -42,18 +42,18 @@ public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) // Admin install: DOTNET_ROOT should not be set, or if set, should match installDir if (!string.IsNullOrEmpty(dotnetRoot) && !PathsEqual(dotnetRoot, installDir) && !dotnetRoot.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) && !dotnetRoot.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase)) { - return SdkInstallType.Inconsistent; + return InstallType.Inconsistent; } - return SdkInstallType.Admin; + return InstallType.Admin; } else { // User install: DOTNET_ROOT must be set and match installDir if (string.IsNullOrEmpty(dotnetRoot) || !PathsEqual(dotnetRoot, installDir)) { - return SdkInstallType.Inconsistent; + return InstallType.Inconsistent; } - return SdkInstallType.User; + return InstallType.User; } } @@ -104,7 +104,7 @@ public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) => throw new NotImplementedException(); public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); - public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) + public void ConfigureInstallType(InstallType installType, string? dotnetRoot = null) { // Get current PATH var path = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; @@ -115,7 +115,7 @@ public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot switch (installType) { - case SdkInstallType.User: + case InstallType.User: if (string.IsNullOrEmpty(dotnetRoot)) throw new ArgumentNullException(nameof(dotnetRoot)); // Add dotnetRoot to PATH @@ -123,7 +123,7 @@ public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot // Set DOTNET_ROOT Environment.SetEnvironmentVariable("DOTNET_ROOT", dotnetRoot, EnvironmentVariableTarget.User); break; - case SdkInstallType.Admin: + case InstallType.Admin: if (string.IsNullOrEmpty(dotnetRoot)) throw new ArgumentNullException(nameof(dotnetRoot)); // Add dotnetRoot to PATH @@ -131,7 +131,7 @@ public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot // Unset DOTNET_ROOT Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); break; - case SdkInstallType.None: + case InstallType.None: // Unset DOTNET_ROOT Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); break; diff --git a/src/Installer/dnup/IDotnetInstaller.cs b/src/Installer/dnup/IDotnetInstaller.cs index 47affcc51390..b7a2b9c375df 100644 --- a/src/Installer/dnup/IDotnetInstaller.cs +++ b/src/Installer/dnup/IDotnetInstaller.cs @@ -14,7 +14,7 @@ public interface IDotnetInstaller string GetDefaultDotnetInstallPath(); - SdkInstallType GetConfiguredInstallType(out string? currentInstallPath); + InstallType GetConfiguredInstallType(out string? currentInstallPath); string? GetLatestInstalledAdminVersion(); @@ -22,20 +22,11 @@ public interface IDotnetInstaller void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null); - void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null); + void ConfigureInstallType(InstallType installType, string? dotnetRoot = null); } -public enum SdkInstallType -{ - None, - // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to - Inconsistent, - Admin, - User -} - public class GlobalJsonInfo { public string? GlobalJsonPath { get; set; } diff --git a/src/Installer/dnup/InstallType.cs b/src/Installer/dnup/InstallType.cs index 688ac1692440..065b520e7e6b 100644 --- a/src/Installer/dnup/InstallType.cs +++ b/src/Installer/dnup/InstallType.cs @@ -3,7 +3,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper { - internal enum InstallType + public enum InstallType { None, // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to From 14a58cbe3ce2f7a65d3904b09e81b55dba6597aa Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 11:04:41 -0700 Subject: [PATCH 29/58] Add Base Interface for Install I renamed the DotnetInstaller in an aim to refactor the logic out some more. I think we could have the controller logic that talks to the CLI / UI layer be separate from the class that does the actual install logic to better follow the single responsibility principle and simplify the code into more pieces. --- ...Installer.cs => BootstrapperController.cs} | 4 +- .../Commands/Sdk/Install/SdkInstallCommand.cs | 8 +-- src/Installer/dnup/DnupSharedManifest.cs | 23 +++++++ ...nstaller.cs => IBootstrapperController.cs} | 2 +- src/Installer/dnup/IDnupManifest.cs | 11 +++ .../dnup/InstallerOrchestratorSingleton.cs | 69 +++++++++++++++++++ src/Installer/dnup/ScopedMutex.cs | 30 ++++++++ 7 files changed, 140 insertions(+), 7 deletions(-) rename src/Installer/dnup/{DotnetInstaller.cs => BootstrapperController.cs} (97%) create mode 100644 src/Installer/dnup/DnupSharedManifest.cs rename src/Installer/dnup/{IDotnetInstaller.cs => IBootstrapperController.cs} (97%) create mode 100644 src/Installer/dnup/IDnupManifest.cs create mode 100644 src/Installer/dnup/InstallerOrchestratorSingleton.cs create mode 100644 src/Installer/dnup/ScopedMutex.cs diff --git a/src/Installer/dnup/DotnetInstaller.cs b/src/Installer/dnup/BootstrapperController.cs similarity index 97% rename from src/Installer/dnup/DotnetInstaller.cs rename to src/Installer/dnup/BootstrapperController.cs index d345c684747e..ba228e1b17e4 100644 --- a/src/Installer/dnup/DotnetInstaller.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -10,11 +10,11 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; -public class DotnetInstaller : IDotnetInstaller +public class BootstrapperController : IBootstrapperController { private readonly IEnvironmentProvider _environmentProvider; - public DotnetInstaller(IEnvironmentProvider? environmentProvider = null) + public BootstrapperController(IEnvironmentProvider? environmentProvider = null) { _environmentProvider = environmentProvider ?? new EnvironmentProvider(); } diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index a448904bdb9e..5909cddfe02b 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -19,7 +19,7 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) private readonly bool? _updateGlobalJson = result.GetValue(SdkInstallCommandParser.UpdateGlobalJsonOption); private readonly bool _interactive = result.GetValue(SdkInstallCommandParser.InteractiveOption); - private readonly IDotnetInstaller _dotnetInstaller = new EnvironmentVariableMockDotnetInstaller(); + private readonly IBootstrapperController _dotnetInstaller = new EnvironmentVariableMockDotnetInstaller(); private readonly IReleaseInfoProvider _releaseInfoProvider = new EnvironmentVariableMockReleaseInfoProvider(); public override int Execute() @@ -37,7 +37,7 @@ public override int Execute() var globalJsonInfo = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory); string? currentInstallPath; - InstallType defaultInstallState = _dotnetInstaller.GetConfiguredInstallType(out currentInstallPath); + InstallType defaultInstallState = _dotnetInstaller.GetConfiguredInstallType(out currentInstallPath); string? resolvedInstallPath = null; @@ -239,7 +239,7 @@ public override int Execute() return 0; } - + string? ResolveChannelFromGlobalJson(string globalJsonPath) { @@ -253,7 +253,7 @@ bool IsElevated() return false; } - class EnvironmentVariableMockDotnetInstaller : IDotnetInstaller + class EnvironmentVariableMockDotnetInstaller : IBootstrapperController { public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) { diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs new file mode 100644 index 000000000000..9a9ca36d62f0 --- /dev/null +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class DnupSharedManifest : IDnupManifest +{ + public IEnumerable GetInstalledVersions() + { + return []; + } + + public void AddInstalledVersion(DotnetInstall version) + { + } + + public void RemoveInstalledVersion(DotnetInstall version) + { + } +} diff --git a/src/Installer/dnup/IDotnetInstaller.cs b/src/Installer/dnup/IBootstrapperController.cs similarity index 97% rename from src/Installer/dnup/IDotnetInstaller.cs rename to src/Installer/dnup/IBootstrapperController.cs index b7a2b9c375df..bc4dd5aa7e17 100644 --- a/src/Installer/dnup/IDotnetInstaller.cs +++ b/src/Installer/dnup/IBootstrapperController.cs @@ -8,7 +8,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; -public interface IDotnetInstaller +public interface IBootstrapperController { GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory); diff --git a/src/Installer/dnup/IDnupManifest.cs b/src/Installer/dnup/IDnupManifest.cs new file mode 100644 index 000000000000..2439b201f03b --- /dev/null +++ b/src/Installer/dnup/IDnupManifest.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal interface IDnupManifest + { + IEnumerable GetInstalledVersions(); + void AddInstalledVersion(DotnetInstall version); + void RemoveInstalledVersion(DotnetInstall version); + } +} diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs new file mode 100644 index 000000000000..d33c215148ad --- /dev/null +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class InstallerOrchestratorSingleton +{ + private static readonly InstallerOrchestratorSingleton _instance = new(); + + private InstallerOrchestratorSingleton() + { + } + + public static InstallerOrchestratorSingleton Instance => _instance; + + private ScopedMutex directoryToMutex(string directory) => new ScopedMutex("Global\\" + directory.GetHashCode()); + + private ScopedMutex finalizeLock() => new ScopedMutex("Global\\Finalize"); + + public void Install(DotnetInstallRequest installRequest) + { + // Map InstallRequest to DotnetInstallObject by converting channel to fully specified version + + // Grab the mutex on the directory to operate on from installRequest + // Check if the install already exists, if so, return + // If not, release the mutex and begin the installer.prepare + // prepare will download the correct archive to a random user protected folder + // it will then verify the downloaded archive signature / hash. + // + + // Once prepare is over, grab the finalize lock, then grab the directory lock + // Check once again if the install exists, if so, return. + // Run installer.finalize which will extract to the directory to install to. + // validate the install, then write to the dnup shared manifest + // Release + + // Clean up the temp folder + } + + // Add a doc string mentioning you must hold a mutex over the directory + private IEnumerable GetExistingInstalls(string directory) + { + using (var lockScope = directoryToMutex(directory)) + { + if (lockScope.HasHandle) + { + // TODO: Implement logic to get existing installs + return Enumerable.Empty(); + } + return Enumerable.Empty(); + } + } + + private bool InstallAlreadyExists(string directory) + { + using (var lockScope = directoryToMutex(directory)) + { + if (lockScope.HasHandle) + { + // TODO: Implement logic to check if install already exists + return false; + } + return false; + } + } +} diff --git a/src/Installer/dnup/ScopedMutex.cs b/src/Installer/dnup/ScopedMutex.cs new file mode 100644 index 000000000000..de8c1cb202d2 --- /dev/null +++ b/src/Installer/dnup/ScopedMutex.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public class ScopedMutex : IDisposable +{ + private readonly Mutex _mutex; + private bool _hasHandle; + + public ScopedMutex(string name) + { + _mutex = new Mutex(false, name); + _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(10), false); + } + + public bool HasHandle => _hasHandle; + + public void Dispose() + { + if (_hasHandle) + { + _mutex.ReleaseMutex(); + } + _mutex.Dispose(); + } +} From 9c19c6b478b1554bcb986d94e97c1d3f03db0de3 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 11:47:35 -0700 Subject: [PATCH 30/58] Add the isolated classes for each responsibility of install --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 31 ++++++++ .../dnup/ArchiveInstallationValidator.cs | 16 ++++ src/Installer/dnup/BootstrapperController.cs | 14 +++- src/Installer/dnup/IChannelVersionResolver.cs | 12 +++ src/Installer/dnup/IDnupManifest.cs | 3 + src/Installer/dnup/IDotnetInstaller.cs | 13 ++++ src/Installer/dnup/IInstallationValidator.cs | 12 +++ .../dnup/InstallerOrchestratorSingleton.cs | 74 ++++++++++--------- .../dnup/ManifestChannelVersionResolver.cs | 22 ++++++ 9 files changed, 162 insertions(+), 35 deletions(-) create mode 100644 src/Installer/dnup/ArchiveDotnetInstaller.cs create mode 100644 src/Installer/dnup/ArchiveInstallationValidator.cs create mode 100644 src/Installer/dnup/IChannelVersionResolver.cs create mode 100644 src/Installer/dnup/IDotnetInstaller.cs create mode 100644 src/Installer/dnup/IInstallationValidator.cs create mode 100644 src/Installer/dnup/ManifestChannelVersionResolver.cs diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs new file mode 100644 index 000000000000..db7d3844339e --- /dev/null +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class ArchiveDotnetInstaller : IDotnetInstaller, IDisposable +{ + public ArchiveDotnetInstaller(DotnetInstall version) + { + + } + + public void Prepare() + { + // Create a user protected (wrx) random folder in temp + // Download the correct archive to the temp folder + // Verify the hash and or signature of the archive + } + + public void Commit() + { + } + + public void Dispose() + { + // Clean up the temp directory + } +} diff --git a/src/Installer/dnup/ArchiveInstallationValidator.cs b/src/Installer/dnup/ArchiveInstallationValidator.cs new file mode 100644 index 000000000000..0a7fee13c49f --- /dev/null +++ b/src/Installer/dnup/ArchiveInstallationValidator.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class ArchiveInstallationValidator : IInstallationValidator +{ + public bool Validate(DotnetInstall install) + { + // TODO: Implement validation logic + return true; + } +} diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index ba228e1b17e4..a1605cc3d8a1 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -101,7 +101,19 @@ public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) return null; } - public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) => throw new NotImplementedException(); + public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) + { + // TODO: Implement proper channel version resolution and parameter mapping + DotnetInstallRequest request = new DotnetInstallRequest( + "TODO_CHANNEL_VERSION", + dotnetRoot, + InstallType.User, + InstallMode.SDK, + InstallArchitecture.x64 + ); + + InstallerOrchestratorSingleton.Instance.Install(request); + } public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); public void ConfigureInstallType(InstallType installType, string? dotnetRoot = null) diff --git a/src/Installer/dnup/IChannelVersionResolver.cs b/src/Installer/dnup/IChannelVersionResolver.cs new file mode 100644 index 000000000000..f0cfb323f0f5 --- /dev/null +++ b/src/Installer/dnup/IChannelVersionResolver.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal interface ChannelVersionResolver + { + public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion); + } +} diff --git a/src/Installer/dnup/IDnupManifest.cs b/src/Installer/dnup/IDnupManifest.cs index 2439b201f03b..afbc38cd1ccc 100644 --- a/src/Installer/dnup/IDnupManifest.cs +++ b/src/Installer/dnup/IDnupManifest.cs @@ -1,3 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + using System.Collections.Generic; namespace Microsoft.DotNet.Tools.Bootstrapper diff --git a/src/Installer/dnup/IDotnetInstaller.cs b/src/Installer/dnup/IDotnetInstaller.cs new file mode 100644 index 000000000000..e811c4562e23 --- /dev/null +++ b/src/Installer/dnup/IDotnetInstaller.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal interface IDotnetInstaller + { + void Prepare(); + void Commit(); + } +} diff --git a/src/Installer/dnup/IInstallationValidator.cs b/src/Installer/dnup/IInstallationValidator.cs new file mode 100644 index 000000000000..3f9195021160 --- /dev/null +++ b/src/Installer/dnup/IInstallationValidator.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal interface IInstallationValidator + { + bool Validate(DotnetInstall install); + } +} diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index d33c215148ad..3aee97c52f42 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -16,54 +16,60 @@ private InstallerOrchestratorSingleton() public static InstallerOrchestratorSingleton Instance => _instance; - private ScopedMutex directoryToMutex(string directory) => new ScopedMutex("Global\\" + directory.GetHashCode()); - - private ScopedMutex finalizeLock() => new ScopedMutex("Global\\Finalize"); + private ScopedMutex modifyInstallStateMutex() => new ScopedMutex("Global\\Finalize"); public void Install(DotnetInstallRequest installRequest) { // Map InstallRequest to DotnetInstallObject by converting channel to fully specified version + DotnetInstall install = new ManifestChannelVersionResolver().Resolve(installRequest); + + // Check if the install already exists and we don't need to do anything + using (var finalizeLock = modifyInstallStateMutex()) + { + if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) + { + return; + } + } - // Grab the mutex on the directory to operate on from installRequest - // Check if the install already exists, if so, return - // If not, release the mutex and begin the installer.prepare - // prepare will download the correct archive to a random user protected folder - // it will then verify the downloaded archive signature / hash. - // + ArchiveDotnetInstaller installer = new ArchiveDotnetInstaller(install); + installer.Prepare(); - // Once prepare is over, grab the finalize lock, then grab the directory lock - // Check once again if the install exists, if so, return. - // Run installer.finalize which will extract to the directory to install to. - // validate the install, then write to the dnup shared manifest - // Release + // Extract and commit the install to the directory + using (var finalizeLock = modifyInstallStateMutex()) + { + if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) + { + return; + } + + installer.Commit(); + + ArchiveInstallationValidator validator = new ArchiveInstallationValidator(); + if (validator.Validate(install)) + { + var manifestManager = new DnupSharedManifest(); + manifestManager.AddInstalledVersion(install); + } + else + { + // Handle validation failure + } + } - // Clean up the temp folder + // return exit code or 0 } // Add a doc string mentioning you must hold a mutex over the directory private IEnumerable GetExistingInstalls(string directory) { - using (var lockScope = directoryToMutex(directory)) - { - if (lockScope.HasHandle) - { - // TODO: Implement logic to get existing installs - return Enumerable.Empty(); - } - return Enumerable.Empty(); - } + // assert we have the finalize lock + return Enumerable.Empty(); } - private bool InstallAlreadyExists(string directory) + private bool InstallAlreadyExists(string directory, DotnetInstall install) { - using (var lockScope = directoryToMutex(directory)) - { - if (lockScope.HasHandle) - { - // TODO: Implement logic to check if install already exists - return false; - } - return false; - } + // assert we have the finalize lock + return false; } } diff --git a/src/Installer/dnup/ManifestChannelVersionResolver.cs b/src/Installer/dnup/ManifestChannelVersionResolver.cs new file mode 100644 index 000000000000..b0d0881e1430 --- /dev/null +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class ManifestChannelVersionResolver +{ + public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion) + { + // TODO: Implement logic to resolve the channel version from the manifest + // For now, return a placeholder + return new DotnetInstall( + "TODO_RESOLVED_VERSION", + dotnetChannelVersion.ResolvedDirectory, + dotnetChannelVersion.Type, + dotnetChannelVersion.Mode, + dotnetChannelVersion.Architecture); + } +} From c97834e60838b5d67003f701cb4c8616bede5c8e Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 13:44:08 -0700 Subject: [PATCH 31/58] fill in controller with correct data structure model --- src/Installer/dnup/BootstrapperController.cs | 15 ++++++++++--- src/Installer/dnup/DnupUtilities.cs | 22 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 src/Installer/dnup/DnupUtilities.cs diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index a1605cc3d8a1..a50c73b6d541 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -103,17 +103,26 @@ public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) { - // TODO: Implement proper channel version resolution and parameter mapping + foreach (var channelVersion in sdkVersions) + { + InstallSDK(dotnetRoot, progressContext, channelVersion); + } + } + + private void InstallSDK(string dotnetRoot, ProgressContext progressContext, string channelVersion) + { DotnetInstallRequest request = new DotnetInstallRequest( - "TODO_CHANNEL_VERSION", + channelVersion, dotnetRoot, InstallType.User, InstallMode.SDK, - InstallArchitecture.x64 + // Get current machine architecture and convert it to correct enum value + DnupUtilities.GetInstallArchitecture(System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture) ); InstallerOrchestratorSingleton.Instance.Install(request); } + public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); public void ConfigureInstallType(InstallType installType, string? dotnetRoot = null) diff --git a/src/Installer/dnup/DnupUtilities.cs b/src/Installer/dnup/DnupUtilities.cs new file mode 100644 index 000000000000..06d8899efe56 --- /dev/null +++ b/src/Installer/dnup/DnupUtilities.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal static class DnupUtilities +{ + public static InstallArchitecture GetInstallArchitecture(System.Runtime.InteropServices.Architecture architecture) + { + return architecture switch + { + System.Runtime.InteropServices.Architecture.X86 => InstallArchitecture.x86, + System.Runtime.InteropServices.Architecture.X64 => InstallArchitecture.x64, + System.Runtime.InteropServices.Architecture.Arm64 => InstallArchitecture.arm64, + _ => throw new NotSupportedException($"Architecture {architecture} is not supported.") + }; + } +} From d3cb10f20832cc1d5e7c3beca22528bb901e6300 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 14:56:18 -0700 Subject: [PATCH 32/58] use some actual version parsing --- src/Installer/dnup/DotnetVersion.cs | 5 +++++ .../dnup/InstallerOrchestratorSingleton.cs | 17 ++++++++-------- .../dnup/ManifestChannelVersionResolver.cs | 20 ++++++++++++++++--- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/Installer/dnup/DotnetVersion.cs b/src/Installer/dnup/DotnetVersion.cs index b0a89fa7fffb..e13c3c105fdf 100644 --- a/src/Installer/dnup/DotnetVersion.cs +++ b/src/Installer/dnup/DotnetVersion.cs @@ -200,6 +200,11 @@ public string GetVersionWithoutBuildHash() return Value.Replace($".{buildHash}", ""); } + public bool IsValidMajorVersion() + { + return Major != 0; + } + /// /// Detects whether this is an SDK or Runtime version based on the version format. /// SDK versions typically have 3-digit patch numbers (feature bands), Runtime versions have 1-2 digit patch numbers. diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index 3aee97c52f42..e323e2f5399c 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -18,7 +18,7 @@ private InstallerOrchestratorSingleton() private ScopedMutex modifyInstallStateMutex() => new ScopedMutex("Global\\Finalize"); - public void Install(DotnetInstallRequest installRequest) + public int Install(DotnetInstallRequest installRequest) { // Map InstallRequest to DotnetInstallObject by converting channel to fully specified version DotnetInstall install = new ManifestChannelVersionResolver().Resolve(installRequest); @@ -28,11 +28,11 @@ public void Install(DotnetInstallRequest installRequest) { if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) { - return; + return 0; } } - ArchiveDotnetInstaller installer = new ArchiveDotnetInstaller(install); + ArchiveDotnetInstaller installer = new(install); installer.Prepare(); // Extract and commit the install to the directory @@ -40,24 +40,25 @@ public void Install(DotnetInstallRequest installRequest) { if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) { - return; + return 0; } installer.Commit(); - ArchiveInstallationValidator validator = new ArchiveInstallationValidator(); + ArchiveInstallationValidator validator = new(); if (validator.Validate(install)) { - var manifestManager = new DnupSharedManifest(); + DnupSharedManifest manifestManager = new(); manifestManager.AddInstalledVersion(install); } else { - // Handle validation failure + // TODO Handle validation failure better + return 1; } } - // return exit code or 0 + return 0; } // Add a doc string mentioning you must hold a mutex over the directory diff --git a/src/Installer/dnup/ManifestChannelVersionResolver.cs b/src/Installer/dnup/ManifestChannelVersionResolver.cs index b0d0881e1430..da9c3c6fd4bc 100644 --- a/src/Installer/dnup/ManifestChannelVersionResolver.cs +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -10,10 +10,24 @@ internal class ManifestChannelVersionResolver { public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion) { - // TODO: Implement logic to resolve the channel version from the manifest - // For now, return a placeholder + string fullySpecifiedVersion = dotnetChannelVersion.ChannelVersion; + + DotnetVersion dotnetVersion = new DotnetVersion(fullySpecifiedVersion); + + // Resolve strings or other options + if (!dotnetVersion.IsValidMajorVersion()) + { + // TODO ping the r-manifest to handle 'lts' 'latest' etc + } + + // Make sure the version is fully specified + if (!dotnetVersion.IsFullySpecified) + { + // TODO ping the r-manifest to resolve latest within the specified qualities + } + return new DotnetInstall( - "TODO_RESOLVED_VERSION", + fullySpecifiedVersion, dotnetChannelVersion.ResolvedDirectory, dotnetChannelVersion.Type, dotnetChannelVersion.Mode, From 69b9821d139915b6f00bd43461a0fb28c90dd13a Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 14:59:22 -0700 Subject: [PATCH 33/58] add more context into what to implement for future me or others --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 5 +++++ src/Installer/dnup/ManifestChannelVersionResolver.cs | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index db7d3844339e..173ef7e9969a 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -18,10 +18,15 @@ public void Prepare() // Create a user protected (wrx) random folder in temp // Download the correct archive to the temp folder // Verify the hash and or signature of the archive + + // https://github.com/dn-vm/dnvm/blob/e656f6e0011d4d710c94cb520d00604d9058460f/src/dnvm/InstallCommand.cs#L359C47-L359C62 + // Use the MIT license version of basically this logic. } public void Commit() { + // https://github.com/dn-vm/dnvm/blob/main/src/dnvm/InstallCommand.cs#L393 + // Use the MIT license version of basically this logic. } public void Dispose() diff --git a/src/Installer/dnup/ManifestChannelVersionResolver.cs b/src/Installer/dnup/ManifestChannelVersionResolver.cs index da9c3c6fd4bc..72a0288cd655 100644 --- a/src/Installer/dnup/ManifestChannelVersionResolver.cs +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -17,7 +17,9 @@ public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion) // Resolve strings or other options if (!dotnetVersion.IsValidMajorVersion()) { - // TODO ping the r-manifest to handle 'lts' 'latest' etc + // TODO ping the r-manifest to handle 'lts' 'latest' etc + // Do this in a separate class and use dotnet release library to do so + // https://github.com/dotnet/deployment-tools/tree/main/src/Microsoft.Deployment.DotNet.Releases } // Make sure the version is fully specified From 396db6fd128ffbbbfca57263e244d28d2771fbff Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 16:49:31 -0700 Subject: [PATCH 34/58] prepare code from dnvm make sure to properly give credit in the real thing, we may want to avoid ifilesystem, but not sure yet. --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 136 +++++++++++++++++-- 1 file changed, 125 insertions(+), 11 deletions(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 173ef7e9969a..666af3f78947 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -1,36 +1,150 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Generic; -using System.Linq; - namespace Microsoft.DotNet.Tools.Bootstrapper; internal class ArchiveDotnetInstaller : IDotnetInstaller, IDisposable { + private string scratchDownloadDirectory; + private string scratchExtractionDirectory; + public ArchiveDotnetInstaller(DotnetInstall version) { - + scratchDownloadDirectory = Directory.CreateTempSubdirectory().FullName; + scratchExtractionDirectory = Directory.CreateTempSubdirectory().FullName; } public void Prepare() { - // Create a user protected (wrx) random folder in temp - // Download the correct archive to the temp folder + // Download the archive to a user protected (wrx) random folder in temp + + // string archiveName = ConstructArchiveName(versionString: null, Utilities.CurrentRID, Utilities.ZipSuffix); + // string archivePath = Path.Combine(scratchDownloadDirectory, archiveName); + + // Download to scratchDownloadDirectory + // Verify the hash and or signature of the archive - // https://github.com/dn-vm/dnvm/blob/e656f6e0011d4d710c94cb520d00604d9058460f/src/dnvm/InstallCommand.cs#L359C47-L359C62 - // Use the MIT license version of basically this logic. + // Extract to a temporary directory for the final replacement later. + // ExtractArchive(scratchDownloadDirectory, scratchExtractionDirectory); + } + + /** + private async Task ExtractArchive(string archivePath, IFileSystem extractionDirectory) + { + // TODO: Ensure this fails if the dir already exists as that's a security issue + extractionDirectory.CreateDirectory(tempExtractDir); + + using var tempRealPath = new DirectoryResource(extractionDirectory.ConvertPathToInternal(tempExtractDir)); + if (Utilities.CurrentRID.OS != OSPlatform.Windows) + { + // TODO: See if this works if 'tar' is unavailable + 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; + } + } + } + */ + + internal static string ConstructArchiveName(string? versionString, string rid, string suffix) + { + return versionString is null + ? $"dotnet-sdk-{rid}{suffix}" + : $"dotnet-sdk-{versionString}-{rid}{suffix}"; + } + + /** + public static async Task ExtractSdkToDir( + DotnetVersion? existingMuxerVersion, + DotnetVersion runtimeVersion, + string archivePath, + IFileSystem tempFs, + IFileSystem destFs, + string destDir) + { + destFs.CreateDirectory(destDir); + + 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(Path.Combine(destDir, dir.GetName())); + foreach (var fsItem in tempFs.EnumerateItems(dir, SearchOption.AllDirectories)) + { + var relativePath = fsItem.Path.FullName[extractFullName.Length..].TrimStart('/'); + var destPath = Path.Combine(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( + DotnetVersion? existingMuxerVersion, + DotnetVersion newRuntimeVersion, + IFileSystem tempFs, + string tempExtractDir, + IFileSystem destFs, + string 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 = Path.Combine(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, Path.Combine(tempExtractDir, DotnetExeName), destFs, muxerTargetPath); } + */ public void Commit() { - // https://github.com/dn-vm/dnvm/blob/main/src/dnvm/InstallCommand.cs#L393 - // Use the MIT license version of basically this logic. + //ExtractSdkToDir(); } public void Dispose() { - // Clean up the temp directory + File.Delete(scratchExtractionDirectory); + File.Delete(scratchDownloadDirectory); } } From e4b7e420bef75b7aa91b27fc88cb7c239d483a53 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 3 Sep 2025 12:46:59 -0700 Subject: [PATCH 35/58] Migrate to .NET Archive Libraries We should try to use our default libraries when possible instead of shelling out to the tar process. There were some issues with the library with null terminators, but they have been resolved in .net 10 preview 4. --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 51 +++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 666af3f78947..27f505e2abf8 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -14,7 +14,7 @@ public ArchiveDotnetInstaller(DotnetInstall version) scratchExtractionDirectory = Directory.CreateTempSubdirectory().FullName; } - public void Prepare() + public Prepare() { // Download the archive to a user protected (wrx) random folder in temp @@ -24,32 +24,59 @@ public void Prepare() // Download to scratchDownloadDirectory // Verify the hash and or signature of the archive + VerifyArchive(scratchDownloadDirectory); // Extract to a temporary directory for the final replacement later. - // ExtractArchive(scratchDownloadDirectory, scratchExtractionDirectory); + ExtractArchive(scratchDownloadDirectory, scratchExtractionDirectory); } /** - private async Task ExtractArchive(string archivePath, IFileSystem extractionDirectory) + Returns a string if the archive is valid within SDL specification, false otherwise. + */ + private void VerifyArchive(string archivePath) { - // TODO: Ensure this fails if the dir already exists as that's a security issue - extractionDirectory.CreateDirectory(tempExtractDir); + if (archivePath != null) // replace this with actual verification logic once its implemented. + { + throw new InvalidOperationException("Archive verification failed."); + } + } - using var tempRealPath = new DirectoryResource(extractionDirectory.ConvertPathToInternal(tempExtractDir)); + /** + Extracts the specified archive to the given extraction directory. + The archive will be decompressed if necessary. + Expects either a .tar.gz, .tar, or .zip archive. + */ + private string? ExtractArchive(string archivePath, string extractionDirectory) + { if (Utilities.CurrentRID.OS != OSPlatform.Windows) { - // TODO: See if this works if 'tar' is unavailable - var procResult = await ProcUtil.RunWithOutput("tar", $"-xzf \"{archivePath}\" -C \"{tempRealPath.Path}\""); - if (procResult.ExitCode != 0) + var needsDecompression = archivePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); + using var archiveDecompressedPath = needsDecompression ? new DirectoryResource(archivePath) : new DirectoryResource(Path.Combine(archivePath), "decompressed"); + + // Run gzip decompression iff .gz is at the end of the archive file, which is true for .NET archives + if (needsDecompression) { - return procResult.Error; + using FileStream originalFileStream = File.OpenRead(archivePath); + using FileStream decompressedFileStream = File.Create(archiveDecompressedPath.Path); + using GZipStream decompressionStream = new GZipStream(originalFileStream, CompressionMode.Decompress); + decompressionStream.CopyTo(decompressedFileStream); + } + + try + { + TarFile.ExtractToDirectory(archiveDecompressedPath.Path, extractionDirectory, overwriteFiles: true); + // The temp folder will be cleaned out at class destruction time - no need to clean it now. + } + catch (Exception e) + { + return e.Message; } } else { try { - ZipFile.ExtractToDirectory(archivePath, tempRealPath.Path, overwriteFiles: true); + ZipFile.ExtractToDirectory(archivePath, extractionDirectory, overwriteFiles: true); } catch (Exception e) { @@ -57,7 +84,6 @@ public void Prepare() } } } - */ internal static string ConstructArchiveName(string? versionString, string rid, string suffix) { @@ -75,6 +101,7 @@ internal static string ConstructArchiveName(string? versionString, string rid, s IFileSystem destFs, string destDir) { + // Make sure the first task has finished destFs.CreateDirectory(destDir); try From 2f458c9bc2028187037293542a22b5a86342ba9f Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 3 Sep 2025 16:52:36 -0700 Subject: [PATCH 36/58] Implement Extraction Logic --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 129 ++++++++++-------- src/Installer/dnup/DnupUtilities.cs | 24 ++++ src/Installer/dnup/DotnetInstall.cs | 11 +- .../dnup/InstallerOrchestratorSingleton.cs | 3 +- test/dnup.Tests/DotnetInstallTests.cs | 4 +- 5 files changed, 106 insertions(+), 65 deletions(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 27f505e2abf8..a6d08374b4eb 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -1,20 +1,33 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Collections.Generic; +using System.Formats.Tar; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + namespace Microsoft.DotNet.Tools.Bootstrapper; internal class ArchiveDotnetInstaller : IDotnetInstaller, IDisposable { + private readonly DotnetInstallRequest _request; + private readonly DotnetInstall _install; private string scratchDownloadDirectory; private string scratchExtractionDirectory; - public ArchiveDotnetInstaller(DotnetInstall version) + public ArchiveDotnetInstaller(DotnetInstallRequest request, DotnetInstall version) { + _request = request; + _install = version; scratchDownloadDirectory = Directory.CreateTempSubdirectory().FullName; scratchExtractionDirectory = Directory.CreateTempSubdirectory().FullName; } - public Prepare() + public void Prepare() { // Download the archive to a user protected (wrx) random folder in temp @@ -48,24 +61,31 @@ Extracts the specified archive to the given extraction directory. */ private string? ExtractArchive(string archivePath, string extractionDirectory) { - if (Utilities.CurrentRID.OS != OSPlatform.Windows) + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { var needsDecompression = archivePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); - using var archiveDecompressedPath = needsDecompression ? new DirectoryResource(archivePath) : new DirectoryResource(Path.Combine(archivePath), "decompressed"); - - // Run gzip decompression iff .gz is at the end of the archive file, which is true for .NET archives - if (needsDecompression) - { - using FileStream originalFileStream = File.OpenRead(archivePath); - using FileStream decompressedFileStream = File.Create(archiveDecompressedPath.Path); - using GZipStream decompressionStream = new GZipStream(originalFileStream, CompressionMode.Decompress); - decompressionStream.CopyTo(decompressedFileStream); - } + string decompressedPath = archivePath; try { - TarFile.ExtractToDirectory(archiveDecompressedPath.Path, extractionDirectory, overwriteFiles: true); - // The temp folder will be cleaned out at class destruction time - no need to clean it now. + // Run gzip decompression iff .gz is at the end of the archive file, which is true for .NET archives + if (needsDecompression) + { + decompressedPath = Path.Combine(Path.GetDirectoryName(archivePath) ?? Directory.CreateTempSubdirectory().FullName, "decompressed.tar"); + using FileStream originalFileStream = File.OpenRead(archivePath); + using FileStream decompressedFileStream = File.Create(decompressedPath); + using GZipStream decompressionStream = new GZipStream(originalFileStream, CompressionMode.Decompress); + decompressionStream.CopyTo(decompressedFileStream); + } + + // Use System.Formats.Tar for .NET 7+ + TarFile.ExtractToDirectory(decompressedPath, extractionDirectory, overwriteFiles: true); + + // Clean up temporary decompressed file + if (needsDecompression && File.Exists(decompressedPath)) + { + File.Delete(decompressedPath); + } } catch (Exception e) { @@ -83,6 +103,7 @@ Extracts the specified archive to the given extraction directory. return e.Message; } } + return null; } internal static string ConstructArchiveName(string? versionString, string rid, string suffix) @@ -92,41 +113,39 @@ internal static string ConstructArchiveName(string? versionString, string rid, s : $"dotnet-sdk-{versionString}-{rid}{suffix}"; } - /** - public static async Task ExtractSdkToDir( - DotnetVersion? existingMuxerVersion, - DotnetVersion runtimeVersion, - string archivePath, - IFileSystem tempFs, - IFileSystem destFs, - string destDir) + + private string? ExtractSdkToDir(string extractedArchivePath, string destDir, IEnumerable existingSdkVersions) { - // Make sure the first task has finished - destFs.CreateDirectory(destDir); + // Ensure destination directory exists + Directory.CreateDirectory(destDir); + + DotnetVersion? existingMuxerVersion = existingSdkVersions.Any() ? existingSdkVersions.Max() : (DotnetVersion?)null; + DotnetVersion runtimeVersion = _install.FullySpecifiedVersion; 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); + CopyMuxer(existingMuxerVersion, runtimeVersion, extractedArchivePath, destDir); - var extractFullName = tempExtractDir.FullName; - foreach (var dir in tempFs.EnumerateDirectories(tempExtractDir)) + foreach (var sourcePath in Directory.EnumerateFileSystemEntries(extractedArchivePath, "*", SearchOption.AllDirectories)) { - destFs.CreateDirectory(Path.Combine(destDir, dir.GetName())); - foreach (var fsItem in tempFs.EnumerateItems(dir, SearchOption.AllDirectories)) - { - var relativePath = fsItem.Path.FullName[extractFullName.Length..].TrimStart('/'); - var destPath = Path.Combine(destDir, relativePath); + var relativePath = Path.GetRelativePath(extractedArchivePath, sourcePath); + var destPath = Path.Combine(destDir, relativePath); - if (fsItem.IsDirectory) - { - destFs.CreateDirectory(destPath); - } - else + if (File.Exists(sourcePath)) + { + // Skip dotnet.exe + if (string.Equals(Path.GetFileName(sourcePath), DnupUtilities.GetDotnetExeName(), StringComparison.OrdinalIgnoreCase)) { - ForceReplaceFile(tempFs, fsItem.Path, destFs, destPath); + continue; } + + Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); + DnupUtilities.ForceReplaceFile(sourcePath, destPath); + } + else if (Directory.Exists(sourcePath)) + { + // Merge directories: create if not exists, do not delete anything in dest + Directory.CreateDirectory(destPath); } } } @@ -136,37 +155,33 @@ internal static string ConstructArchiveName(string? versionString, string rid, s } return null; } - */ - /** - private static void CopyMuxer( - DotnetVersion? existingMuxerVersion, - DotnetVersion newRuntimeVersion, - IFileSystem tempFs, - string tempExtractDir, - IFileSystem destFs, - string destDir) - { //The "dotnet" exe (muxer) is special in two ways: + private void CopyMuxer(DotnetVersion? existingMuxerVersion, DotnetVersion newRuntimeVersion, string archiveDir, string 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 = Path.Combine(destDir, DotnetExeName); + var muxerTargetPath = Path.Combine(destDir, DnupUtilities.GetDotnetExeName()); - if (newRuntimeVersion.CompareSortOrderTo(existingMuxerVersion) <= 0) + if (existingMuxerVersion is not null && newRuntimeVersion.CompareTo(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, Path.Combine(tempExtractDir, DotnetExeName), destFs, muxerTargetPath); + DnupUtilities.ForceReplaceFile(Path.Combine(archiveDir, DnupUtilities.GetDotnetExeName()), muxerTargetPath); } - */ public void Commit() { - //ExtractSdkToDir(); + Commit(existingSdkVersions: Enumerable.Empty()); // todo impl this + } + + public void Commit(IEnumerable existingSdkVersions) + { + ExtractSdkToDir(scratchExtractionDirectory, _request.TargetDirectory, existingSdkVersions); } public void Dispose() diff --git a/src/Installer/dnup/DnupUtilities.cs b/src/Installer/dnup/DnupUtilities.cs index 06d8899efe56..2e09f6ac7f7b 100644 --- a/src/Installer/dnup/DnupUtilities.cs +++ b/src/Installer/dnup/DnupUtilities.cs @@ -3,12 +3,21 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Runtime.InteropServices; namespace Microsoft.DotNet.Tools.Bootstrapper; internal static class DnupUtilities { + public static string ExeSuffix = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : ""; + + public static string GetDotnetExeName() + { + return "dotnet" + ExeSuffix; + } + public static InstallArchitecture GetInstallArchitecture(System.Runtime.InteropServices.Architecture architecture) { return architecture switch @@ -19,4 +28,19 @@ public static InstallArchitecture GetInstallArchitecture(System.Runtime.InteropS _ => throw new NotSupportedException($"Architecture {architecture} is not supported.") }; } + + public static void ForceReplaceFile(string sourcePath, string destPath) + { + File.Copy(sourcePath, destPath, overwrite: true); + + // Copy file attributes + var srcInfo = new FileInfo(sourcePath); + var dstInfo = new FileInfo(destPath) + { + CreationTimeUtc = srcInfo.CreationTimeUtc, + LastWriteTimeUtc = srcInfo.LastWriteTimeUtc, + LastAccessTimeUtc = srcInfo.LastAccessTimeUtc, + Attributes = srcInfo.Attributes + }; + } } diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/dnup/DotnetInstall.cs index a867b4f66fe8..f6c387b85188 100644 --- a/src/Installer/dnup/DotnetInstall.cs +++ b/src/Installer/dnup/DotnetInstall.cs @@ -19,20 +19,21 @@ internal record DotnetInstallBase( /// /// Represents a .NET installation with a fully specified version. +/// The MuxerDirectory is the directory of the corresponding .NET host that has visibility into this .NET installation. /// internal record DotnetInstall( - string FullySpecifiedVersion, - string ResolvedDirectory, + DotnetVersion FullySpecifiedVersion, + string MuxerDirectory, InstallType Type, InstallMode Mode, - InstallArchitecture Architecture) : DotnetInstallBase(ResolvedDirectory, Type, Mode, Architecture); + InstallArchitecture Architecture) : DotnetInstallBase(MuxerDirectory, Type, Mode, Architecture); /// /// Represents a request for a .NET installation with a channel version that will get resolved into a fully specified version. /// internal record DotnetInstallRequest( string ChannelVersion, - string ResolvedDirectory, + string TargetDirectory, InstallType Type, InstallMode Mode, - InstallArchitecture Architecture) : DotnetInstallBase(ResolvedDirectory, Type, Mode, Architecture); + InstallArchitecture Architecture) : DotnetInstallBase(Path.Combine(TargetDirectory, DnupUtilities.GetDotnetExeName()), Type, Mode, Architecture); diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index e323e2f5399c..45a469cc90d4 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -24,6 +24,7 @@ public int Install(DotnetInstallRequest installRequest) DotnetInstall install = new ManifestChannelVersionResolver().Resolve(installRequest); // Check if the install already exists and we don't need to do anything + // read write mutex only for manifest? using (var finalizeLock = modifyInstallStateMutex()) { if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) @@ -32,7 +33,7 @@ public int Install(DotnetInstallRequest installRequest) } } - ArchiveDotnetInstaller installer = new(install); + ArchiveDotnetInstaller installer = new(installRequest, install); installer.Prepare(); // Extract and commit the install to the directory diff --git a/test/dnup.Tests/DotnetInstallTests.cs b/test/dnup.Tests/DotnetInstallTests.cs index 5e4e90812974..68f70ad57949 100644 --- a/test/dnup.Tests/DotnetInstallTests.cs +++ b/test/dnup.Tests/DotnetInstallTests.cs @@ -33,9 +33,9 @@ public void DotnetInstall_ShouldInheritFromBase() var mode = InstallMode.SDK; var architecture = InstallArchitecture.x64; - var install = new DotnetInstall(version, directory, type, mode, architecture); + var install = new DotnetInstall(new DotnetVersion(version), directory, type, mode, architecture); - install.FullySpecifiedVersion.Should().Be(version); + install.FullySpecifiedVersion.Value.Should().Be(version); install.ResolvedDirectory.Should().Be(directory); install.Type.Should().Be(type); install.Mode.Should().Be(mode); From 93bc61fd3624561477f17c1d938c78dff37fee7b Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 4 Sep 2025 12:03:19 -0700 Subject: [PATCH 37/58] Implement manifest parsing and vscode setting folder for build button --- src/Installer/dnup/.gitignore | 2 + src/Installer/dnup/.vscode/launch.json | 30 ++ src/Installer/dnup/.vscode/tasks.json | 56 +++ src/Installer/dnup/ArchiveDotnetInstaller.cs | 29 +- src/Installer/dnup/BootstrapperController.cs | 3 +- src/Installer/dnup/DnupUtilities.cs | 29 ++ src/Installer/dnup/DotnetInstall.cs | 8 +- src/Installer/dnup/ReleaseManifest.cs | 501 +++++++++++++++++++ 8 files changed, 643 insertions(+), 15 deletions(-) create mode 100644 src/Installer/dnup/.gitignore create mode 100644 src/Installer/dnup/.vscode/launch.json create mode 100644 src/Installer/dnup/.vscode/tasks.json create mode 100644 src/Installer/dnup/ReleaseManifest.cs diff --git a/src/Installer/dnup/.gitignore b/src/Installer/dnup/.gitignore new file mode 100644 index 000000000000..98ad9206b3e8 --- /dev/null +++ b/src/Installer/dnup/.gitignore @@ -0,0 +1,2 @@ +# Override the root .gitignore to NOT ignore the .vscode folder +!.vscode/ diff --git a/src/Installer/dnup/.vscode/launch.json b/src/Installer/dnup/.vscode/launch.json new file mode 100644 index 000000000000..24246299b009 --- /dev/null +++ b/src/Installer/dnup/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch dnup", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/../../../artifacts/bin/dnup/Debug/net10.0/dnup.dll", + "args": "${input:commandLineArgs}", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "requireExactSource": false + } + ], + "inputs": [ + { + "id": "commandLineArgs", + "type": "promptString", + "description": "Command line arguments", + "default": "" + } + ] +} \ No newline at end of file diff --git a/src/Installer/dnup/.vscode/tasks.json b/src/Installer/dnup/.vscode/tasks.json new file mode 100644 index 000000000000..541939d1dae4 --- /dev/null +++ b/src/Installer/dnup/.vscode/tasks.json @@ -0,0 +1,56 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/dnup.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "${input:buildArgs}" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/dnup.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "${input:buildArgs}" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/dnup.csproj", + "${input:buildArgs}" + ], + "problemMatcher": "$msCompile" + } + ], + "inputs": [ + { + "id": "buildArgs", + "type": "promptString", + "description": "Additional build arguments", + "default": "" + } + ] +} \ No newline at end of file diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index a6d08374b4eb..8e8ca5660c3e 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -8,7 +8,6 @@ using System.IO.Compression; using System.Linq; using System.Runtime.InteropServices; -using System.Threading.Tasks; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -29,18 +28,23 @@ public ArchiveDotnetInstaller(DotnetInstallRequest request, DotnetInstall versio public void Prepare() { - // Download the archive to a user protected (wrx) random folder in temp - - // string archiveName = ConstructArchiveName(versionString: null, Utilities.CurrentRID, Utilities.ZipSuffix); - // string archivePath = Path.Combine(scratchDownloadDirectory, archiveName); - - // Download to scratchDownloadDirectory - - // Verify the hash and or signature of the archive - VerifyArchive(scratchDownloadDirectory); + using var releaseManifest = new ReleaseManifest(); + var archiveName = $"dotnet-{_install.Id}"; + var archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetFileExtensionForPlatform()); + // Download the archive with hash verification using the DotNet Releases library + var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_install, archivePath); + if (!downloadSuccess) + { + throw new InvalidOperationException($"Failed to download .NET archive for version {_install.FullySpecifiedVersion.Value}"); + } + // Extract to a temporary directory for the final replacement later. - ExtractArchive(scratchDownloadDirectory, scratchExtractionDirectory); + var extractResult = ExtractArchive(archivePath, scratchExtractionDirectory); + if (extractResult != null) + { + throw new InvalidOperationException($"Failed to extract archive: {extractResult}"); + } } /** @@ -48,7 +52,7 @@ public void Prepare() */ private void VerifyArchive(string archivePath) { - if (archivePath != null) // replace this with actual verification logic once its implemented. + if (!File.Exists(archivePath)) // replace this with actual verification logic once its implemented. { throw new InvalidOperationException("Archive verification failed."); } @@ -113,7 +117,6 @@ internal static string ConstructArchiveName(string? versionString, string rid, s : $"dotnet-sdk-{versionString}-{rid}{suffix}"; } - private string? ExtractSdkToDir(string extractedArchivePath, string destDir, IEnumerable existingSdkVersions) { // Ensure destination directory exists diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index a50c73b6d541..e4cb8d2e25de 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -117,7 +117,8 @@ private void InstallSDK(string dotnetRoot, ProgressContext progressContext, stri InstallType.User, InstallMode.SDK, // Get current machine architecture and convert it to correct enum value - DnupUtilities.GetInstallArchitecture(System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture) + DnupUtilities.GetInstallArchitecture(System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture), + new InstallRequestOptions() ); InstallerOrchestratorSingleton.Instance.Install(request); diff --git a/src/Installer/dnup/DnupUtilities.cs b/src/Installer/dnup/DnupUtilities.cs index 2e09f6ac7f7b..e759ee3eea46 100644 --- a/src/Installer/dnup/DnupUtilities.cs +++ b/src/Installer/dnup/DnupUtilities.cs @@ -43,4 +43,33 @@ public static void ForceReplaceFile(string sourcePath, string destPath) Attributes = srcInfo.Attributes }; } + + public static string GetRuntimeIdentifier(InstallArchitecture architecture) + { + var os = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "win" : + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx" : + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux" : "unknown"; + + var arch = architecture switch + { + InstallArchitecture.x64 => "x64", + InstallArchitecture.x86 => "x86", + InstallArchitecture.arm64 => "arm64", + _ => "x64" // Default fallback + }; + + return $"{os}-{arch}"; + } + + public static string GetFileExtensionForPlatform() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return ".zip"; // Windows typically uses zip archives + } + else + { + return ".tar.gz"; // Unix-like systems use tar.gz + } + } } diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/dnup/DotnetInstall.cs index f6c387b85188..50819d3ecad1 100644 --- a/src/Installer/dnup/DotnetInstall.cs +++ b/src/Installer/dnup/DotnetInstall.cs @@ -17,6 +17,11 @@ internal record DotnetInstallBase( public Guid Id { get; } = Guid.NewGuid(); } +internal record InstallRequestOptions() +{ + // Include things such as the custom feed here. +} + /// /// Represents a .NET installation with a fully specified version. /// The MuxerDirectory is the directory of the corresponding .NET host that has visibility into this .NET installation. @@ -36,4 +41,5 @@ internal record DotnetInstallRequest( string TargetDirectory, InstallType Type, InstallMode Mode, - InstallArchitecture Architecture) : DotnetInstallBase(Path.Combine(TargetDirectory, DnupUtilities.GetDotnetExeName()), Type, Mode, Architecture); + InstallArchitecture Architecture, + InstallRequestOptions Options) : DotnetInstallBase(Path.Combine(TargetDirectory, DnupUtilities.GetDotnetExeName()), Type, Mode, Architecture); diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs new file mode 100644 index 000000000000..0e186df0db65 --- /dev/null +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -0,0 +1,501 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +/// +/// Handles downloading and parsing .NET release manifests to find the correct installer/archive for a given installation. +/// +internal class ReleaseManifest : IDisposable +{ + private const string CacheSubdirectory = "dotnet-manifests"; + private const int MaxRetryCount = 3; + private const int RetryDelayMilliseconds = 1000; + private const string ReleaseCacheMutexName = "Global\\DotNetReleaseCache"; + + private readonly HttpClient _httpClient; + private readonly string _cacheDirectory; + private ProductCollection? _productCollection; + + public ReleaseManifest() + : this(CreateDefaultHttpClient(), GetDefaultCacheDirectory()) + { + } + + public ReleaseManifest(HttpClient httpClient) + : this(httpClient, GetDefaultCacheDirectory()) + { + } + + public ReleaseManifest(HttpClient httpClient, string cacheDirectory) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _cacheDirectory = cacheDirectory ?? throw new ArgumentNullException(nameof(cacheDirectory)); + + // Ensure cache directory exists + Directory.CreateDirectory(_cacheDirectory); + } + + /// + /// Creates an HttpClient with enhanced proxy support for enterprise environments. + /// + private static HttpClient CreateDefaultHttpClient() + { + var handler = new HttpClientHandler() + { + // Use system proxy settings by default + UseProxy = true, + // Use default credentials for proxy authentication if needed + UseDefaultCredentials = true, + // Handle redirects automatically + AllowAutoRedirect = true, + // Set maximum number of redirects to prevent infinite loops + MaxAutomaticRedirections = 10, + // Enable decompression for better performance + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }; + + var client = new HttpClient(handler) + { + // Set a reasonable timeout for downloads + Timeout = TimeSpan.FromMinutes(10) + }; + + // Set user agent to identify the client + client.DefaultRequestHeaders.UserAgent.ParseAdd("dnup-dotnet-installer"); + + return client; + } + + /// + /// Gets the default cache directory path. + /// + private static string GetDefaultCacheDirectory() + { + var baseDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(baseDir, "dnup", CacheSubdirectory); + } + + /// + /// Downloads the releases.json manifest and finds the download URL for the specified installation. + /// + /// The .NET installation details + /// The download URL for the installer/archive, or null if not found + public string? GetDownloadUrl(DotnetInstall install) + { + var targetFile = FindReleaseFile(install); + return targetFile?.Address.ToString(); + } + + /// + /// Downloads the archive from the specified URL to the destination path with progress reporting. + /// + /// The URL to download from + /// The local path to save the downloaded file + /// Optional progress reporting + /// True if download was successful, false otherwise + public async Task DownloadArchiveAsync(string downloadUrl, string destinationPath, IProgress? progress = null) + { + // Create temp file path in same directory for atomic move when complete + string tempPath = $"{destinationPath}.download"; + + for (int attempt = 1; attempt <= MaxRetryCount; attempt++) + { + try + { + // Ensure the directory exists + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + + // Try to get content length for progress reporting + long? totalBytes = await GetContentLengthAsync(downloadUrl); + + // Make the actual download request + using var response = await _httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + // Get the total bytes if we didn't get it before + if (!totalBytes.HasValue && response.Content.Headers.ContentLength.HasValue) + { + totalBytes = response.Content.Headers.ContentLength.Value; + } + + using var contentStream = await response.Content.ReadAsStreamAsync(); + using var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true); + + var buffer = new byte[81920]; // 80KB buffer + long bytesRead = 0; + int read; + + var lastProgressReport = DateTime.UtcNow; + + while ((read = await contentStream.ReadAsync(buffer)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, read)); + + bytesRead += read; + + // Report progress at most every 100ms to avoid UI thrashing + var now = DateTime.UtcNow; + if ((now - lastProgressReport).TotalMilliseconds > 100) + { + lastProgressReport = now; + progress?.Report(new DownloadProgress(bytesRead, totalBytes)); + } + } + + // Final progress report + progress?.Report(new DownloadProgress(bytesRead, totalBytes)); + + // Ensure all data is written to disk + await fileStream.FlushAsync(); + fileStream.Close(); + + // Atomic move to final destination + if (File.Exists(destinationPath)) + { + File.Delete(destinationPath); + } + File.Move(tempPath, destinationPath); + + return true; + } + catch (Exception) + { + // Delete the partial download if it exists + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch + { + // Ignore cleanup errors + } + + if (attempt < MaxRetryCount) + { + await Task.Delay(RetryDelayMilliseconds * attempt); // Exponential backoff + } + else + { + return false; + } + } + } + + return false; + } + + /// + /// Gets the content length of a resource. + /// + private async Task GetContentLengthAsync(string url) + { + try + { + using var headRequest = new HttpRequestMessage(HttpMethod.Head, url); + using var headResponse = await _httpClient.SendAsync(headRequest); + return headResponse.Content.Headers.ContentLength; + } + catch + { + return null; + } + } + + /// + /// Downloads the archive from the specified URL to the destination path (synchronous version). + /// + /// The URL to download from + /// The local path to save the downloaded file + /// Optional progress reporting + /// True if download was successful, false otherwise + public bool DownloadArchive(string downloadUrl, string destinationPath, IProgress? progress = null) + { + return DownloadArchiveAsync(downloadUrl, destinationPath, progress).GetAwaiter().GetResult(); + } + + /// + /// Downloads the archive for the specified installation and verifies its hash. + /// + /// The .NET installation details + /// The local path to save the downloaded file + /// Optional progress reporting + /// True if download and verification were successful, false otherwise + public bool DownloadArchiveWithVerification(DotnetInstall install, string destinationPath, IProgress? progress = null) + { + // Get the download URL and expected hash + string? downloadUrl = GetDownloadUrl(install); + if (string.IsNullOrEmpty(downloadUrl)) + { + return false; + } + + string? expectedHash = GetArchiveHash(install); + if (string.IsNullOrEmpty(expectedHash)) + { + return false; + } + + if (!DownloadArchive(downloadUrl, destinationPath, progress)) + { + return false; + } + + return VerifyFileHash(destinationPath, expectedHash); + } + + /// + /// Finds the appropriate release file for the given installation. + /// + /// The .NET installation details + /// The matching ReleaseFile, throws if none are available. + private ReleaseFile? FindReleaseFile(DotnetInstall install) + { + try + { + var productCollection = GetProductCollection(); + var product = FindProduct(productCollection, install.FullySpecifiedVersion.Value); + if (product == null) return null; + + var release = FindRelease(product, install.FullySpecifiedVersion.Value); + if (release == null) return null; + + return FindMatchingFile(release, install); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to find an available release for install {install} : ${ex.Message}"); + } + } + + /// + /// Gets or loads the ProductCollection with caching. + /// + private ProductCollection GetProductCollection() + { + if (_productCollection != null) + { + return _productCollection; + } + + // Use ScopedMutex for cross-process locking + using var mutex = new ScopedMutex(ReleaseCacheMutexName); + + if (!mutex.HasHandle) + { + // If we couldn't acquire the mutex, still try to load the collection + // but don't write to the cache file to avoid conflicts + return _productCollection ??= ProductCollection.GetAsync().GetAwaiter().GetResult(); + } + + // Double-check locking pattern + if (_productCollection != null) + { + return _productCollection; + } + + string cacheFilePath = Path.Combine(_cacheDirectory, "releases.json"); + bool useCachedData = false; + + if (File.Exists(cacheFilePath)) + { + var cacheFileAge = File.GetLastWriteTimeUtc(cacheFilePath); + // If cache exists and is less than 24 hours old, use it + useCachedData = (DateTime.UtcNow - cacheFileAge).TotalHours < 24; + } + + if (useCachedData) + { + try + { + string json = File.ReadAllText(cacheFilePath); + _productCollection = DeserializeProductCollection(json); + return _productCollection; + } + catch + { + // Continue to fetch fresh data if cache loading fails + } + } + + // Fetch fresh data with retry logic + for (int attempt = 1; attempt <= MaxRetryCount; attempt++) + { + try + { + _productCollection = ProductCollection.GetAsync().GetAwaiter().GetResult(); + + try + { + string json = SerializeProductCollection(_productCollection); + File.WriteAllText(cacheFilePath, json); + } + catch + { + // Continue since we have the data in memory + } + + return _productCollection; + } + catch + { + if (attempt == MaxRetryCount) + { + throw; + } + + Thread.Sleep(RetryDelayMilliseconds * attempt); // Exponential backoff + } + } + + // This shouldn't be reached due to throw above, but compiler doesn't know that + throw new InvalidOperationException("Failed to fetch .NET releases data"); + } + + /// + /// Serializes a ProductCollection to JSON. + /// + private static string SerializeProductCollection(ProductCollection collection) + { + // Use options that indicate we've verified AOT compatibility + var options = new System.Text.Json.JsonSerializerOptions(); +#pragma warning disable IL2026, IL3050 + return System.Text.Json.JsonSerializer.Serialize(collection, options); +#pragma warning restore IL2026, IL3050 + } + + /// + /// Deserializes a ProductCollection from JSON. + /// + private static ProductCollection DeserializeProductCollection(string json) + { + // Use options that indicate we've verified AOT compatibility + var options = new System.Text.Json.JsonSerializerOptions(); +#pragma warning disable IL2026, IL3050 + return System.Text.Json.JsonSerializer.Deserialize(json, options) + ?? throw new InvalidOperationException("Failed to deserialize ProductCollection from JSON"); +#pragma warning restore IL2026, IL3050 + } + + /// + /// Finds the product for the given version. + /// + private static Product? FindProduct(ProductCollection productCollection, string version) + { + var releaseVersion = new ReleaseVersion(version); + var majorMinor = $"{releaseVersion.Major}.{releaseVersion.Minor}"; + return productCollection.FirstOrDefault(p => p.ProductVersion == majorMinor); + } + + /// + /// Finds the specific release for the given version. + /// + private static ProductRelease? FindRelease(Product product, string version) + { + var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + var targetReleaseVersion = new ReleaseVersion(version); + return releases.FirstOrDefault(r => r.Version.Equals(targetReleaseVersion)); + } + + /// + /// Finds the matching file in the release for the given installation requirements. + /// + private static ReleaseFile? FindMatchingFile(ProductRelease release, DotnetInstall install) + { + var rid = DnupUtilities.GetRuntimeIdentifier(install.Architecture); + var fileExtension = DnupUtilities.GetFileExtensionForPlatform(); + var componentType = install.Mode == InstallMode.SDK ? "sdk" : "runtime"; + + return release.Files + .Where(f => f.Rid == rid) + .Where(f => f.Name.Contains(componentType, StringComparison.OrdinalIgnoreCase)) + .FirstOrDefault(f => f.Name.EndsWith(fileExtension, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Gets the SHA512 hash of the archive for the specified installation. + /// + /// The .NET installation details + /// The SHA512 hash string of the installer/archive, or null if not found + public string? GetArchiveHash(DotnetInstall install) + { + var targetFile = FindReleaseFile(install); + return targetFile?.Hash; + } + + /// + /// Computes the SHA512 hash of a file. + /// + /// Path to the file to hash + /// The hash as a lowercase hex string + public static string ComputeFileHash(string filePath) + { + using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); + using var sha512 = SHA512.Create(); + byte[] hashBytes = sha512.ComputeHash(fileStream); + return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + } + + /// + /// Verifies that a downloaded file matches the expected hash. + /// + /// Path to the file to verify + /// Expected hash value + /// True if the hash matches, false otherwise + public static bool VerifyFileHash(string filePath, string expectedHash) + { + if (string.IsNullOrEmpty(expectedHash)) + { + return false; + } + + string actualHash = ComputeFileHash(filePath); + return string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase); + } + + public void Dispose() + { + _httpClient?.Dispose(); + } +} + +/// +/// Represents download progress information. +/// +public readonly struct DownloadProgress +{ + /// + /// Gets the number of bytes downloaded. + /// + public long BytesDownloaded { get; } + + /// + /// Gets the total number of bytes to download, if known. + /// + public long? TotalBytes { get; } + + /// + /// Gets the percentage of download completed, if total size is known. + /// + public double? PercentComplete => TotalBytes.HasValue ? (double)BytesDownloaded / TotalBytes.Value * 100 : null; + + public DownloadProgress(long bytesDownloaded, long? totalBytes) + { + BytesDownloaded = bytesDownloaded; + TotalBytes = totalBytes; + } +} From 00ff09d4591eead24f943da44e095b45211796fa Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 4 Sep 2025 16:00:19 -0700 Subject: [PATCH 38/58] Installing .NET works e2e --- src/Installer/dnup/.vscode/settings.json | 18 +++ src/Installer/dnup/BootstrapperController.cs | 10 +- .../EnvironmentVariableMockDotnetInstaller.cs | 92 +++++++++++ ...ironmentVariableMockReleaseInfoProvider.cs | 49 ++++++ .../Commands/Sdk/Install/SdkInstallCommand.cs | 153 +----------------- .../dnup/InstallerOrchestratorSingleton.cs | 12 +- src/Installer/dnup/dnup.code-workspace | 60 +++++++ src/Installer/dnup/dnup.sln | 24 +++ src/Installer/installer.code-workspace | 8 + 9 files changed, 269 insertions(+), 157 deletions(-) create mode 100644 src/Installer/dnup/.vscode/settings.json create mode 100644 src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs create mode 100644 src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs create mode 100644 src/Installer/dnup/dnup.code-workspace create mode 100644 src/Installer/dnup/dnup.sln create mode 100644 src/Installer/installer.code-workspace diff --git a/src/Installer/dnup/.vscode/settings.json b/src/Installer/dnup/.vscode/settings.json new file mode 100644 index 000000000000..c9127932ea34 --- /dev/null +++ b/src/Installer/dnup/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "dotnet.defaultSolution": "dnup.csproj", + "csharp.debug.console": "externalTerminal", + "editor.formatOnSave": true, + "omnisharp.enableRoslynAnalyzers": true, + "omnisharp.useModernNet": true, + "dotnetAcquisitionExtension.existingDotnetPath": [ + { + "extensionId": "ms-dotnettools.csharp", + "path": "C:\\Program Files\\dotnet\\dotnet.exe" + } + ], + "launch": { + "configurations": [], + "compounds": [] + }, + "omnisharp.defaultLaunchSolution": "dnup.csproj" +} \ No newline at end of file diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index e4cb8d2e25de..acf575acffed 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -121,7 +121,15 @@ private void InstallSDK(string dotnetRoot, ProgressContext progressContext, stri new InstallRequestOptions() ); - InstallerOrchestratorSingleton.Instance.Install(request); + DotnetInstall? newInstall = InstallerOrchestratorSingleton.Instance.Install(request); + if (newInstall == null) + { + throw new Exception($"Failed to install .NET SDK {channelVersion}"); + } + else + { + progressContext.AddTask($"Installed .NET SDK {newInstall.FullySpecifiedVersion}, available via {newInstall.MuxerDirectory}"); + } } public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs new file mode 100644 index 000000000000..50004365e49c --- /dev/null +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; +using Spectre.Console; + +using SpectreAnsiConsole = Spectre.Console.AnsiConsole; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install +{ + internal class EnvironmentVariableMockDotnetInstaller : IBootstrapperController + { + public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) + { + return new GlobalJsonInfo + { + GlobalJsonPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_PATH"), + GlobalJsonContents = null // Set to null for test mock; update as needed for tests + }; + } + + public string GetDefaultDotnetInstallPath() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); + } + + public InstallType GetConfiguredInstallType(out string? currentInstallPath) + { + var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); + InstallType returnValue = InstallType.None; + if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) + { + returnValue = InstallType.None; + } + currentInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); + return returnValue; + } + + public string? GetLatestInstalledAdminVersion() + { + var latestAdminVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); + if (string.IsNullOrEmpty(latestAdminVersion)) + { + latestAdminVersion = "10.0.0-preview.7"; + } + return latestAdminVersion; + } + + public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) + { + using (var httpClient = new HttpClient()) + { + List downloads = sdkVersions.Select(version => + { + string downloadLink = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.303/dotnet-sdk-9.0.303-win-x64.exe"; + var task = progressContext.AddTask($"Downloading .NET SDK {version}"); + return (Action)(() => + { + Download(downloadLink, httpClient, task); + }); + }).ToList(); + + foreach (var download in downloads) + { + download(); + } + } + } + + void Download(string url, HttpClient httpClient, ProgressTask task) + { + for (int i = 0; i < 100; i++) + { + task.Increment(1); + Thread.Sleep(20); // Simulate some work + } + task.Value = 100; + } + + public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) + { + SpectreAnsiConsole.WriteLine($"Updating {globalJsonPath} to SDK version {sdkVersion} (AllowPrerelease={allowPrerelease}, RollForward={rollForward})"); + } + public void ConfigureInstallType(InstallType installType, string? dotnetRoot = null) + { + SpectreAnsiConsole.WriteLine($"Configuring install type to {installType} (dotnetRoot={dotnetRoot})"); + } + } +} diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs new file mode 100644 index 000000000000..681e0d40ac0d --- /dev/null +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install +{ + internal class EnvironmentVariableMockReleaseInfoProvider : IReleaseInfoProvider + { + public List GetAvailableChannels() + { + var channels = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_AVAILABLE_CHANNELS"); + if (string.IsNullOrEmpty(channels)) + { + return new List { "latest", "preview", "10", "10.0.1xx", "10.0.2xx", "9", "9.0.3xx", "9.0.2xx", "9.0.1xx" }; + } + return channels.Split(',').ToList(); + } + public string GetLatestVersion(string channel) + { + if (channel == "preview") + { + return "11.0.100-preview.1.42424"; + } + else if (channel == "latest" || channel == "10" || channel == "10.0.2xx") + { + return "10.0.0-preview.7"; + } + else if (channel == "10.0.1xx") + { + return "10.0.106"; + } + else if (channel == "9" || channel == "9.0.3xx") + { + return "9.0.309"; + } + else if (channel == "9.0.2xx") + { + return "9.0.212"; + } + else if (channel == "9.0.1xx") + { + return "9.0.115"; + } + + return channel; + } + } +} diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 5909cddfe02b..d54508c7cdb6 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -8,6 +8,7 @@ using SpectreAnsiConsole = Spectre.Console.AnsiConsole; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; @@ -19,7 +20,7 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) private readonly bool? _updateGlobalJson = result.GetValue(SdkInstallCommandParser.UpdateGlobalJsonOption); private readonly bool _interactive = result.GetValue(SdkInstallCommandParser.InteractiveOption); - private readonly IBootstrapperController _dotnetInstaller = new EnvironmentVariableMockDotnetInstaller(); + private readonly IBootstrapperController _dotnetInstaller = new BootstrapperController(); private readonly IReleaseInfoProvider _releaseInfoProvider = new EnvironmentVariableMockReleaseInfoProvider(); public override int Execute() @@ -209,10 +210,7 @@ public override int Execute() } } - // TODO: Implement transaction / rollback? - // TODO: Use Mutex to avoid concurrent installs? - SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); @@ -253,150 +251,5 @@ bool IsElevated() return false; } - class EnvironmentVariableMockDotnetInstaller : IBootstrapperController - { - public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) - { - return new GlobalJsonInfo - { - GlobalJsonPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_PATH"), - GlobalJsonContents = null // Set to null for test mock; update as needed for tests - }; - } - - public string GetDefaultDotnetInstallPath() - { - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); - } - - public InstallType GetConfiguredInstallType(out string? currentInstallPath) - { - var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); - InstallType returnValue = InstallType.None; - if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) - { - returnValue = InstallType.None; - } - currentInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); - return returnValue; - } - - public string? GetLatestInstalledAdminVersion() - { - var latestAdminVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); - if (string.IsNullOrEmpty(latestAdminVersion)) - { - latestAdminVersion = "10.0.203"; - } - return latestAdminVersion; - } - - public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) - { - //var task = progressContext.AddTask($"Downloading .NET SDK {resolvedChannelVersion}"); - using (var httpClient = new System.Net.Http.HttpClient()) - { - List downloads = sdkVersions.Select(version => - { - string downloadLink = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.303/dotnet-sdk-9.0.303-win-x64.exe"; - var task = progressContext.AddTask($"Downloading .NET SDK {version}"); - return (Action)(() => - { - Download(downloadLink, httpClient, task); - }); - }).ToList(); - - - foreach (var download in downloads) - { - download(); - } - } - } - - void Download(string url, HttpClient httpClient, ProgressTask task) - { - //string tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(url)); - //using (var response = httpClient.GetAsync(url, System.Net.Http.HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult()) - //{ - // response.EnsureSuccessStatusCode(); - // var contentLength = response.Content.Headers.ContentLength ?? 0; - // using (var stream = response.Content.ReadAsStream()) - // using (var fileStream = File.Create(tempFilePath)) - // { - // var buffer = new byte[81920]; - // long totalRead = 0; - // int read; - // while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) - // { - // fileStream.Write(buffer, 0, read); - // totalRead += read; - // if (contentLength > 0) - // { - // task.Value = (double)totalRead / contentLength * 100; - // } - // } - // task.Value = 100; - // } - //} - - for (int i = 0; i < 100; i++) - { - task.Increment(1); - Thread.Sleep(20); // Simulate some work - } - task.Value = 100; - } - - public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) - { - SpectreAnsiConsole.WriteLine($"Updating {globalJsonPath} to SDK version {sdkVersion} (AllowPrerelease={allowPrerelease}, RollForward={rollForward})"); - } - public void ConfigureInstallType(InstallType installType, string? dotnetRoot = null) - { - SpectreAnsiConsole.WriteLine($"Configuring install type to {installType} (dotnetRoot={dotnetRoot})"); - } - } - - class EnvironmentVariableMockReleaseInfoProvider : IReleaseInfoProvider - { - public List GetAvailableChannels() - { - var channels = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_AVAILABLE_CHANNELS"); - if (string.IsNullOrEmpty(channels)) - { - return ["latest", "preview", "10", "10.0.1xx", "10.0.2xx", "9", "9.0.3xx", "9.0.2xx", "9.0.1xx"]; - } - return channels.Split(',').ToList(); - } - public string GetLatestVersion(string channel) - { - if (channel == "preview") - { - return "11.0.100-preview.1.42424"; - } - else if (channel == "latest" || channel == "10" || channel == "10.0.2xx") - { - return "10.0.203"; - } - else if (channel == "10.0.1xx") - { - return "10.0.106"; - } - else if (channel == "9" || channel == "9.0.3xx") - { - return "9.0.309"; - } - else if (channel == "9.0.2xx") - { - return "9.0.212"; - } - else if (channel == "9.0.1xx") - { - return "9.0.115"; - } - - return channel; - } - } + // ...existing code... } diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index 45a469cc90d4..639816faa015 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -18,7 +18,8 @@ private InstallerOrchestratorSingleton() private ScopedMutex modifyInstallStateMutex() => new ScopedMutex("Global\\Finalize"); - public int Install(DotnetInstallRequest installRequest) + // Returns null on failure, DotnetInstall on success + public DotnetInstall? Install(DotnetInstallRequest installRequest) { // Map InstallRequest to DotnetInstallObject by converting channel to fully specified version DotnetInstall install = new ManifestChannelVersionResolver().Resolve(installRequest); @@ -29,7 +30,7 @@ public int Install(DotnetInstallRequest installRequest) { if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) { - return 0; + return install; } } @@ -41,7 +42,7 @@ public int Install(DotnetInstallRequest installRequest) { if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) { - return 0; + return install; } installer.Commit(); @@ -54,12 +55,11 @@ public int Install(DotnetInstallRequest installRequest) } else { - // TODO Handle validation failure better - return 1; + return null; } } - return 0; + return install; } // Add a doc string mentioning you must hold a mutex over the directory diff --git a/src/Installer/dnup/dnup.code-workspace b/src/Installer/dnup/dnup.code-workspace new file mode 100644 index 000000000000..fca5689a68dc --- /dev/null +++ b/src/Installer/dnup/dnup.code-workspace @@ -0,0 +1,60 @@ +{ + "folders": [ + { + "path": ".", + "name": "dnup" + } + ], + "settings": { + "dotnet.defaultSolution": "dnup.csproj", + "omnisharp.defaultLaunchSolution": "dnup.csproj", + "csharp.debug.console": "externalTerminal", + "editor.formatOnSave": true, + "omnisharp.enableRoslynAnalyzers": true, + "omnisharp.useModernNet": true + }, + "launch": { + "version": "0.2.0", + "configurations": [ + { + "name": "Launch dnup (Default)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/../../../artifacts/bin/dnup/Debug/net10.0/dnup.dll", + "args": [ + "sdk", + "install" + ], + "cwd": "${workspaceFolder}", + "console": "externalTerminal", + "stopAtEntry": false, + "logging": { + "moduleLoad": false + } + } + ], + "compounds": [] + }, + "tasks": { + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder}/dnup.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + } + ] + } +} diff --git a/src/Installer/dnup/dnup.sln b/src/Installer/dnup/dnup.sln new file mode 100644 index 000000000000..f8be3f0124a7 --- /dev/null +++ b/src/Installer/dnup/dnup.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dnup", "dnup.csproj", "{FE7DE2FC-400E-D3A1-8410-80BAA0C888BE}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FE7DE2FC-400E-D3A1-8410-80BAA0C888BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FE7DE2FC-400E-D3A1-8410-80BAA0C888BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE7DE2FC-400E-D3A1-8410-80BAA0C888BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FE7DE2FC-400E-D3A1-8410-80BAA0C888BE}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CE797DD7-D006-4A3F-94F3-1ED339F75821} + EndGlobalSection +EndGlobal diff --git a/src/Installer/installer.code-workspace b/src/Installer/installer.code-workspace new file mode 100644 index 000000000000..dcf51a098081 --- /dev/null +++ b/src/Installer/installer.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "dnup" + } + ], + "settings": {} +} From 3fcf146c2e7c9ce1d40185ac8051089c730083a6 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 4 Sep 2025 16:19:58 -0700 Subject: [PATCH 39/58] Show progress correctly --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 135 +++++++++++++----- .../Commands/Sdk/Install/SdkInstallCommand.cs | 6 +- .../dnup/SpectreDownloadProgressReporter.cs | 45 ++++++ 3 files changed, 142 insertions(+), 44 deletions(-) create mode 100644 src/Installer/dnup/SpectreDownloadProgressReporter.cs diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 8e8ca5660c3e..052c857f9248 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -32,19 +32,27 @@ public void Prepare() var archiveName = $"dotnet-{_install.Id}"; var archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetFileExtensionForPlatform()); - // Download the archive with hash verification using the DotNet Releases library - var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_install, archivePath); - if (!downloadSuccess) - { - throw new InvalidOperationException($"Failed to download .NET archive for version {_install.FullySpecifiedVersion.Value}"); - } - - // Extract to a temporary directory for the final replacement later. - var extractResult = ExtractArchive(archivePath, scratchExtractionDirectory); - if (extractResult != null) - { - throw new InvalidOperationException($"Failed to extract archive: {extractResult}"); - } + Spectre.Console.AnsiConsole.Progress() + .Start(ctx => + { + var downloadTask = ctx.AddTask($"Downloading .NET SDK {_install.FullySpecifiedVersion.Value}", autoStart: true); + var reporter = new SpectreDownloadProgressReporter(downloadTask, $"Downloading .NET SDK {_install.FullySpecifiedVersion.Value}"); + var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_install, archivePath, reporter); + if (!downloadSuccess) + { + throw new InvalidOperationException($"Failed to download .NET archive for version {_install.FullySpecifiedVersion.Value}"); + } + + downloadTask.Value = 100; + + var extractTask = ctx.AddTask($"Extracting .NET SDK {_install.FullySpecifiedVersion.Value}", autoStart: true); + var extractResult = ExtractArchive(archivePath, scratchExtractionDirectory, extractTask); + if (extractResult != null) + { + throw new InvalidOperationException($"Failed to extract archive: {extractResult}"); + } + extractTask.Value = extractTask.MaxValue; + }); } /** @@ -63,16 +71,14 @@ Extracts the specified archive to the given extraction directory. The archive will be decompressed if necessary. Expects either a .tar.gz, .tar, or .zip archive. */ - private string? ExtractArchive(string archivePath, string extractionDirectory) + private string? ExtractArchive(string archivePath, string extractionDirectory, Spectre.Console.ProgressTask extractTask) { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + try { - var needsDecompression = archivePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); - string decompressedPath = archivePath; - - try + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - // Run gzip decompression iff .gz is at the end of the archive file, which is true for .NET archives + var needsDecompression = archivePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); + string decompressedPath = archivePath; if (needsDecompression) { decompressedPath = Path.Combine(Path.GetDirectoryName(archivePath) ?? Directory.CreateTempSubdirectory().FullName, "decompressed.tar"); @@ -81,31 +87,68 @@ Extracts the specified archive to the given extraction directory. using GZipStream decompressionStream = new GZipStream(originalFileStream, CompressionMode.Decompress); decompressionStream.CopyTo(decompressedFileStream); } - - // Use System.Formats.Tar for .NET 7+ - TarFile.ExtractToDirectory(decompressedPath, extractionDirectory, overwriteFiles: true); - + // Count files in tar + long totalFiles = 0; + using (var tarStream = File.OpenRead(decompressedPath)) + { + var tarReader = new System.Formats.Tar.TarReader(tarStream); + while (tarReader.GetNextEntry() != null) + { + totalFiles++; + } + } + if (extractTask != null) + { + extractTask.MaxValue = totalFiles > 0 ? totalFiles : 1; + using (var tarStream = File.OpenRead(decompressedPath)) + { + var tarReader = new System.Formats.Tar.TarReader(tarStream); + System.Formats.Tar.TarEntry? entry; + while ((entry = tarReader.GetNextEntry()) != null) + { + if (entry.EntryType == System.Formats.Tar.TarEntryType.RegularFile) + { + var outPath = Path.Combine(extractionDirectory, entry.Name); + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + using var outStream = File.Create(outPath); + entry.DataStream?.CopyTo(outStream); + } + extractTask.Increment(1); + } + } + } // Clean up temporary decompressed file if (needsDecompression && File.Exists(decompressedPath)) { File.Delete(decompressedPath); } } - catch (Exception e) + else { - return e.Message; + long totalFiles = 0; + using (var zip = ZipFile.OpenRead(archivePath)) + { + totalFiles = zip.Entries.Count; + } + if (extractTask != null) + { + extractTask.MaxValue = totalFiles > 0 ? totalFiles : 1; + using (var zip = ZipFile.OpenRead(archivePath)) + { + foreach (var entry in zip.Entries) + { + var outPath = Path.Combine(extractionDirectory, entry.FullName); + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + entry.ExtractToFile(outPath, overwrite: true); + extractTask.Increment(1); + } + } + } } } - else + catch (Exception e) { - try - { - ZipFile.ExtractToDirectory(archivePath, extractionDirectory, overwriteFiles: true); - } - catch (Exception e) - { - return e.Message; - } + return e.Message; } return null; } @@ -117,7 +160,7 @@ internal static string ConstructArchiveName(string? versionString, string rid, s : $"dotnet-sdk-{versionString}-{rid}{suffix}"; } - private string? ExtractSdkToDir(string extractedArchivePath, string destDir, IEnumerable existingSdkVersions) + private string? ExtractSdkToDir(string extractedArchivePath, string destDir, IEnumerable existingSdkVersions, Spectre.Console.ProgressTask? commitTask = null, List? files = null) { // Ensure destination directory exists Directory.CreateDirectory(destDir); @@ -128,8 +171,9 @@ internal static string ConstructArchiveName(string? versionString, string rid, s try { CopyMuxer(existingMuxerVersion, runtimeVersion, extractedArchivePath, destDir); - - foreach (var sourcePath in Directory.EnumerateFileSystemEntries(extractedArchivePath, "*", SearchOption.AllDirectories)) + var fileList = files ?? Directory.EnumerateFileSystemEntries(extractedArchivePath, "*", SearchOption.AllDirectories).ToList(); + int processed = 0; + foreach (var sourcePath in fileList) { var relativePath = Path.GetRelativePath(extractedArchivePath, sourcePath); var destPath = Path.Combine(destDir, relativePath); @@ -150,6 +194,11 @@ internal static string ConstructArchiveName(string? versionString, string rid, s // Merge directories: create if not exists, do not delete anything in dest Directory.CreateDirectory(destPath); } + processed++; + if (commitTask != null) + { + commitTask.Value = processed; + } } } catch (Exception e) @@ -184,7 +233,15 @@ public void Commit() public void Commit(IEnumerable existingSdkVersions) { - ExtractSdkToDir(scratchExtractionDirectory, _request.TargetDirectory, existingSdkVersions); + Spectre.Console.AnsiConsole.Progress() + .Start(ctx => + { + var files = Directory.EnumerateFileSystemEntries(scratchExtractionDirectory, "*", SearchOption.AllDirectories).ToList(); + var commitTask = ctx.AddTask($"Installing .NET SDK", autoStart: true); + commitTask.MaxValue = files.Count > 0 ? files.Count : 1; + ExtractSdkToDir(scratchExtractionDirectory, _request.TargetDirectory, existingSdkVersions, commitTask, files); + commitTask.Value = commitTask.MaxValue; + }); } public void Dispose() diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index d54508c7cdb6..8e02dfb8c9ed 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -214,11 +214,7 @@ public override int Execute() SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); - SpectreAnsiConsole.Progress() - .Start(ctx => - { - _dotnetInstaller.InstallSdks(resolvedInstallPath, ctx, new[] { resolvedChannelVersion }.Concat(additionalVersionsToInstall)); - }); + _dotnetInstaller.InstallSdks(resolvedInstallPath, SpectreAnsiConsole.Progress().Start(ctx => ctx), new[] { resolvedChannelVersion }.Concat(additionalVersionsToInstall)); if (resolvedSetDefaultInstall == true) { diff --git a/src/Installer/dnup/SpectreDownloadProgressReporter.cs b/src/Installer/dnup/SpectreDownloadProgressReporter.cs new file mode 100644 index 000000000000..f943e140e254 --- /dev/null +++ b/src/Installer/dnup/SpectreDownloadProgressReporter.cs @@ -0,0 +1,45 @@ +using System; +using Spectre.Console; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + public class SpectreDownloadProgressReporter : IProgress + { + private readonly ProgressTask _task; + private readonly string _description; + private long? _totalBytes; + + public SpectreDownloadProgressReporter(ProgressTask task, string description) + { + _task = task; + _description = description; + } + + public void Report(DownloadProgress value) + { + if (value.TotalBytes.HasValue) + { + _totalBytes = value.TotalBytes; + } + if (_totalBytes.HasValue && _totalBytes.Value > 0) + { + double percent = (double)value.BytesDownloaded / _totalBytes.Value * 100.0; + _task.Value = percent; + _task.Description = $"{_description} ({FormatBytes(value.BytesDownloaded)} / {FormatBytes(_totalBytes.Value)})"; + } + else + { + _task.Description = $"{_description} ({FormatBytes(value.BytesDownloaded)})"; + } + } + + private static string FormatBytes(long bytes) + { + if (bytes > 1024 * 1024) + return $"{bytes / (1024 * 1024)} MB"; + if (bytes > 1024) + return $"{bytes / 1024} KB"; + return $"{bytes} B"; + } + } +} From 0a668b4c89bffbfd8f0f37ca17729126ead7f116 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 4 Sep 2025 16:23:38 -0700 Subject: [PATCH 40/58] fix end message --- src/Installer/dnup/BootstrapperController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index acf575acffed..20d8dd9e0308 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -128,7 +128,7 @@ private void InstallSDK(string dotnetRoot, ProgressContext progressContext, stri } else { - progressContext.AddTask($"Installed .NET SDK {newInstall.FullySpecifiedVersion}, available via {newInstall.MuxerDirectory}"); + Spectre.Console.AnsiConsole.MarkupLine($"[green]Installed .NET SDK {newInstall.FullySpecifiedVersion}, available via {newInstall.MuxerDirectory}[/]"); } } From df9fde0f31e58fce2d3b3b6e615bfc694fc56874 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 4 Sep 2025 16:29:47 -0700 Subject: [PATCH 41/58] find available SDKs --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 42 +++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 052c857f9248..a294d6973150 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -228,7 +228,7 @@ private void CopyMuxer(DotnetVersion? existingMuxerVersion, DotnetVersion newRun public void Commit() { - Commit(existingSdkVersions: Enumerable.Empty()); // todo impl this + Commit(GetExistingSdkVersions(_request.TargetDirectory)); } public void Commit(IEnumerable existingSdkVersions) @@ -249,4 +249,44 @@ public void Dispose() File.Delete(scratchExtractionDirectory); File.Delete(scratchDownloadDirectory); } + + // This should be cached and more sophisticated based on vscode logic in the future + private IEnumerable GetExistingSdkVersions(string targetDirectory) + { + var dotnetExe = Path.Combine(targetDirectory, DnupUtilities.GetDotnetExeName()); + if (!File.Exists(dotnetExe)) + return Enumerable.Empty(); + + try + { + var process = new System.Diagnostics.Process(); + process.StartInfo.FileName = dotnetExe; + process.StartInfo.Arguments = "--list-sdks"; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + var versions = new List(); + foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + var parts = line.Split(' '); + if (parts.Length > 0) + { + var versionStr = parts[0]; + if (DotnetVersion.TryParse(versionStr, out var version)) + { + versions.Add(version); + } + } + } + return versions; + } + catch + { + return Enumerable.Empty(); + } + } } From 716e6c2ed35093e8d40682d9c556a5cacb0d313f Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 13:15:39 -0700 Subject: [PATCH 42/58] Add update cadence method for manifest tracking the objects should be public to be json-ifiable --- src/Installer/dnup/BootstrapperController.cs | 3 +- src/Installer/dnup/Constants.cs | 22 ++++++++++++++ src/Installer/dnup/DotnetInstall.cs | 8 +++-- src/Installer/dnup/DotnetVersion.cs | 4 +-- src/Installer/dnup/IDnupManifest.cs | 2 +- src/Installer/dnup/InstallArchitecture.cs | 2 +- src/Installer/dnup/InstallMode.cs | 2 +- .../dnup/InstallerOrchestratorSingleton.cs | 2 +- src/Installer/dnup/ManagementCadence.cs | 29 +++++++++++++++++++ .../dnup/ManifestChannelVersionResolver.cs | 3 +- 10 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 src/Installer/dnup/Constants.cs create mode 100644 src/Installer/dnup/ManagementCadence.cs diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index 20d8dd9e0308..d1f431d172f1 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -117,7 +117,8 @@ private void InstallSDK(string dotnetRoot, ProgressContext progressContext, stri InstallType.User, InstallMode.SDK, // Get current machine architecture and convert it to correct enum value - DnupUtilities.GetInstallArchitecture(System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture), + DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture), + new ManagementCadence(ManagementCadenceType.DNUP), new InstallRequestOptions() ); diff --git a/src/Installer/dnup/Constants.cs b/src/Installer/dnup/Constants.cs new file mode 100644 index 000000000000..007364d7b8bc --- /dev/null +++ b/src/Installer/dnup/Constants.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + /// + /// Shared constants for the dnup application. + /// + internal static class Constants + { + /// + /// Mutex names used for synchronization. + /// + public static class MutexNames + { + /// + /// Mutex used during the final installation phase to protect the manifest file and extracting folder(s). + /// + public const string ModifyInstallationStates = "Global\\DnupFinalize"; + } + } +} diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/dnup/DotnetInstall.cs index 50819d3ecad1..2bf453dc288b 100644 --- a/src/Installer/dnup/DotnetInstall.cs +++ b/src/Installer/dnup/DotnetInstall.cs @@ -8,7 +8,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; /// /// Base record for .NET installation information with common properties. /// -internal record DotnetInstallBase( +public record DotnetInstallBase( string ResolvedDirectory, InstallType Type, InstallMode Mode, @@ -26,12 +26,13 @@ internal record InstallRequestOptions() /// Represents a .NET installation with a fully specified version. /// The MuxerDirectory is the directory of the corresponding .NET host that has visibility into this .NET installation. /// -internal record DotnetInstall( +public record DotnetInstall( DotnetVersion FullySpecifiedVersion, string MuxerDirectory, InstallType Type, InstallMode Mode, - InstallArchitecture Architecture) : DotnetInstallBase(MuxerDirectory, Type, Mode, Architecture); + InstallArchitecture Architecture, + ManagementCadence Cadence) : DotnetInstallBase(MuxerDirectory, Type, Mode, Architecture); /// /// Represents a request for a .NET installation with a channel version that will get resolved into a fully specified version. @@ -42,4 +43,5 @@ internal record DotnetInstallRequest( InstallType Type, InstallMode Mode, InstallArchitecture Architecture, + ManagementCadence Cadence, InstallRequestOptions Options) : DotnetInstallBase(Path.Combine(TargetDirectory, DnupUtilities.GetDotnetExeName()), Type, Mode, Architecture); diff --git a/src/Installer/dnup/DotnetVersion.cs b/src/Installer/dnup/DotnetVersion.cs index e13c3c105fdf..e974b654f965 100644 --- a/src/Installer/dnup/DotnetVersion.cs +++ b/src/Installer/dnup/DotnetVersion.cs @@ -9,7 +9,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; /// /// Represents the type of .NET version (SDK or Runtime). /// -internal enum DotnetVersionType +public enum DotnetVersionType { /// Automatically detect based on version format. Auto, @@ -25,7 +25,7 @@ internal enum DotnetVersionType /// Supports both SDK versions (with feature bands) and Runtime versions, and handles build hashes and preview versions. /// [DebuggerDisplay("{Value} ({VersionType})")] -internal readonly record struct DotnetVersion : IComparable, IComparable, IEquatable +public readonly record struct DotnetVersion : IComparable, IComparable, IEquatable { private readonly ReleaseVersion? _releaseVersion; diff --git a/src/Installer/dnup/IDnupManifest.cs b/src/Installer/dnup/IDnupManifest.cs index afbc38cd1ccc..1b692d8a67a9 100644 --- a/src/Installer/dnup/IDnupManifest.cs +++ b/src/Installer/dnup/IDnupManifest.cs @@ -7,7 +7,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper { internal interface IDnupManifest { - IEnumerable GetInstalledVersions(); + IEnumerable GetInstalledVersions(IInstallationValidator? validator = null); void AddInstalledVersion(DotnetInstall version); void RemoveInstalledVersion(DotnetInstall version); } diff --git a/src/Installer/dnup/InstallArchitecture.cs b/src/Installer/dnup/InstallArchitecture.cs index 67dbe11a2156..a046bf3d1721 100644 --- a/src/Installer/dnup/InstallArchitecture.cs +++ b/src/Installer/dnup/InstallArchitecture.cs @@ -7,7 +7,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper { - internal enum InstallArchitecture + public enum InstallArchitecture { x86, x64, diff --git a/src/Installer/dnup/InstallMode.cs b/src/Installer/dnup/InstallMode.cs index 14cbfd8e5ab8..329ac4e27416 100644 --- a/src/Installer/dnup/InstallMode.cs +++ b/src/Installer/dnup/InstallMode.cs @@ -3,7 +3,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper { - internal enum InstallMode + public enum InstallMode { SDK, Runtime, diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index 639816faa015..40d9910df6db 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -16,7 +16,7 @@ private InstallerOrchestratorSingleton() public static InstallerOrchestratorSingleton Instance => _instance; - private ScopedMutex modifyInstallStateMutex() => new ScopedMutex("Global\\Finalize"); + private ScopedMutex modifyInstallStateMutex() => new ScopedMutex(Constants.MutexNames.ModifyInstallationStates); // Returns null on failure, DotnetInstall on success public DotnetInstall? Install(DotnetInstallRequest installRequest) diff --git a/src/Installer/dnup/ManagementCadence.cs b/src/Installer/dnup/ManagementCadence.cs new file mode 100644 index 000000000000..963b5bae8a2b --- /dev/null +++ b/src/Installer/dnup/ManagementCadence.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + public enum ManagementCadenceType + { + DNUP, + GlobalJson, + Standalone + } + + public struct ManagementCadence + { + public ManagementCadence() + { + Type = ManagementCadenceType.DNUP; + Metadata = new Dictionary(); + } + public ManagementCadence(ManagementCadenceType managementStyle) : this() + { + Type = managementStyle; + Metadata = []; + } + + public ManagementCadenceType Type { get; set; } + public Dictionary Metadata { get; set; } + } +} diff --git a/src/Installer/dnup/ManifestChannelVersionResolver.cs b/src/Installer/dnup/ManifestChannelVersionResolver.cs index 72a0288cd655..62e8744a2775 100644 --- a/src/Installer/dnup/ManifestChannelVersionResolver.cs +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -33,6 +33,7 @@ public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion) dotnetChannelVersion.ResolvedDirectory, dotnetChannelVersion.Type, dotnetChannelVersion.Mode, - dotnetChannelVersion.Architecture); + dotnetChannelVersion.Architecture, + dotnetChannelVersion.Cadence); } } From 1c465ef3ad711f3ea84e86786c8853e869d24c6d Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 13:15:50 -0700 Subject: [PATCH 43/58] Add implementation of manifest methods --- src/Installer/dnup/DnupManifestJsonContext.cs | 12 +++ src/Installer/dnup/DnupSharedManifest.cs | 76 ++++++++++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 src/Installer/dnup/DnupManifestJsonContext.cs diff --git a/src/Installer/dnup/DnupManifestJsonContext.cs b/src/Installer/dnup/DnupManifestJsonContext.cs new file mode 100644 index 000000000000..746e70f50d62 --- /dev/null +++ b/src/Installer/dnup/DnupManifestJsonContext.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; +using System.Collections.Generic; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + [JsonSourceGenerationOptions(WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSerializable(typeof(List))] + public partial class DnupManifestJsonContext : JsonSerializerContext { } +} diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index 9a9ca36d62f0..254aace0db81 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -1,23 +1,95 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; using System.Linq; +using System.IO; +using System.Text.Json; +using System.Threading; namespace Microsoft.DotNet.Tools.Bootstrapper; internal class DnupSharedManifest : IDnupManifest { - public IEnumerable GetInstalledVersions() + private static readonly string ManifestPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "dnup", "dnup_manifest.json"); + + public DnupSharedManifest() + { + EnsureManifestExists(); + } + + private void EnsureManifestExists() + { + if (!File.Exists(ManifestPath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(ManifestPath)!); + File.WriteAllText(ManifestPath, JsonSerializer.Serialize(new List(), DnupManifestJsonContext.Default.ListDotnetInstall)); + } + } + + private void AssertHasFinalizationMutex() { - return []; + var mutex = Mutex.OpenExisting(Constants.MutexNames.ModifyInstallationStates); + if (!mutex.WaitOne(0)) + { + throw new InvalidOperationException("The dnup manifest was accessed while not holding the mutex."); + } + mutex.ReleaseMutex(); + mutex.Dispose(); + } + + public IEnumerable GetInstalledVersions(IInstallationValidator? validator = null) + { + AssertHasFinalizationMutex(); + EnsureManifestExists(); + + var json = File.ReadAllText(ManifestPath); + try + { + var installs = JsonSerializer.Deserialize(json, DnupManifestJsonContext.Default.ListDotnetInstall); + var validInstalls = installs ?? new List(); + + if (validator != null) + { + var invalids = validInstalls.Where(i => !validator.Validate(i)).ToList(); + if (invalids.Count > 0) + { + validInstalls = validInstalls.Except(invalids).ToList(); + var newJson = JsonSerializer.Serialize(validInstalls, DnupManifestJsonContext.Default.ListDotnetInstall); + File.WriteAllText(ManifestPath, newJson); + } + } + return validInstalls; + } + catch (JsonException ex) + { + throw new InvalidOperationException($"The dnup manifest is corrupt or inaccessible: {ex.Message}"); + } } public void AddInstalledVersion(DotnetInstall version) { + AssertHasFinalizationMutex(); + EnsureManifestExists(); + + var installs = GetInstalledVersions().ToList(); + installs.Add(version); + var json = JsonSerializer.Serialize(installs, DnupManifestJsonContext.Default.ListDotnetInstall); + Directory.CreateDirectory(Path.GetDirectoryName(ManifestPath)!); + File.WriteAllText(ManifestPath, json); } public void RemoveInstalledVersion(DotnetInstall version) { + AssertHasFinalizationMutex(); + EnsureManifestExists(); + + var installs = GetInstalledVersions().ToList(); + installs.RemoveAll(i => i.Id == version.Id && i.FullySpecifiedVersion == version.FullySpecifiedVersion); + var json = JsonSerializer.Serialize(installs, DnupManifestJsonContext.Default.ListDotnetInstall); + File.WriteAllText(ManifestPath, json); } } From 2da2e773c5d8eb2f7867754b1ec19e172f83657d Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 13:25:12 -0700 Subject: [PATCH 44/58] Extract + Install combined to one step --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 446 +++++++++++++------ 1 file changed, 302 insertions(+), 144 deletions(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index a294d6973150..4414f60c61ee 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -16,42 +16,33 @@ internal class ArchiveDotnetInstaller : IDotnetInstaller, IDisposable private readonly DotnetInstallRequest _request; private readonly DotnetInstall _install; private string scratchDownloadDirectory; - private string scratchExtractionDirectory; + private string? _archivePath; public ArchiveDotnetInstaller(DotnetInstallRequest request, DotnetInstall version) { _request = request; _install = version; scratchDownloadDirectory = Directory.CreateTempSubdirectory().FullName; - scratchExtractionDirectory = Directory.CreateTempSubdirectory().FullName; } public void Prepare() { using var releaseManifest = new ReleaseManifest(); var archiveName = $"dotnet-{_install.Id}"; - var archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetFileExtensionForPlatform()); + _archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetFileExtensionForPlatform()); Spectre.Console.AnsiConsole.Progress() .Start(ctx => { var downloadTask = ctx.AddTask($"Downloading .NET SDK {_install.FullySpecifiedVersion.Value}", autoStart: true); var reporter = new SpectreDownloadProgressReporter(downloadTask, $"Downloading .NET SDK {_install.FullySpecifiedVersion.Value}"); - var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_install, archivePath, reporter); + var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_install, _archivePath, reporter); if (!downloadSuccess) { throw new InvalidOperationException($"Failed to download .NET archive for version {_install.FullySpecifiedVersion.Value}"); } downloadTask.Value = 100; - - var extractTask = ctx.AddTask($"Extracting .NET SDK {_install.FullySpecifiedVersion.Value}", autoStart: true); - var extractResult = ExtractArchive(archivePath, scratchExtractionDirectory, extractTask); - if (extractResult != null) - { - throw new InvalidOperationException($"Failed to extract archive: {extractResult}"); - } - extractTask.Value = extractTask.MaxValue; }); } @@ -66,188 +57,355 @@ private void VerifyArchive(string archivePath) } } + + + internal static string ConstructArchiveName(string? versionString, string rid, string suffix) + { + return versionString is null + ? $"dotnet-sdk-{rid}{suffix}" + : $"dotnet-sdk-{versionString}-{rid}{suffix}"; + } + + + + public void Commit() + { + Commit(GetExistingSdkVersions(_request.TargetDirectory)); + } + + public void Commit(IEnumerable existingSdkVersions) + { + if (_archivePath == null || !File.Exists(_archivePath)) + { + throw new InvalidOperationException("Archive not found. Make sure Prepare() was called successfully."); + } + + Spectre.Console.AnsiConsole.Progress() + .Start(ctx => + { + var installTask = ctx.AddTask($"Installing .NET SDK {_install.FullySpecifiedVersion.Value}", autoStart: true); + + // Extract archive directly to target directory with special handling for muxer + var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.TargetDirectory, existingSdkVersions, installTask); + if (extractResult != null) + { + throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); + } + + installTask.Value = installTask.MaxValue; + }); + } + /** - Extracts the specified archive to the given extraction directory. - The archive will be decompressed if necessary. - Expects either a .tar.gz, .tar, or .zip archive. - */ - private string? ExtractArchive(string archivePath, string extractionDirectory, Spectre.Console.ProgressTask extractTask) + * Extracts the archive directly to the target directory with special handling for muxer. + * Combines extraction and installation into a single operation. + */ + private string? ExtractArchiveDirectlyToTarget(string archivePath, string targetDir, IEnumerable existingSdkVersions, Spectre.Console.ProgressTask? installTask) { try { + // Ensure target directory exists + Directory.CreateDirectory(targetDir); + + var muxerConfig = ConfigureMuxerHandling(existingSdkVersions); + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - var needsDecompression = archivePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); - string decompressedPath = archivePath; - if (needsDecompression) - { - decompressedPath = Path.Combine(Path.GetDirectoryName(archivePath) ?? Directory.CreateTempSubdirectory().FullName, "decompressed.tar"); - using FileStream originalFileStream = File.OpenRead(archivePath); - using FileStream decompressedFileStream = File.Create(decompressedPath); - using GZipStream decompressionStream = new GZipStream(originalFileStream, CompressionMode.Decompress); - decompressionStream.CopyTo(decompressedFileStream); - } - // Count files in tar - long totalFiles = 0; - using (var tarStream = File.OpenRead(decompressedPath)) - { - var tarReader = new System.Formats.Tar.TarReader(tarStream); - while (tarReader.GetNextEntry() != null) - { - totalFiles++; - } - } - if (extractTask != null) - { - extractTask.MaxValue = totalFiles > 0 ? totalFiles : 1; - using (var tarStream = File.OpenRead(decompressedPath)) - { - var tarReader = new System.Formats.Tar.TarReader(tarStream); - System.Formats.Tar.TarEntry? entry; - while ((entry = tarReader.GetNextEntry()) != null) - { - if (entry.EntryType == System.Formats.Tar.TarEntryType.RegularFile) - { - var outPath = Path.Combine(extractionDirectory, entry.Name); - Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); - using var outStream = File.Create(outPath); - entry.DataStream?.CopyTo(outStream); - } - extractTask.Increment(1); - } - } - } - // Clean up temporary decompressed file - if (needsDecompression && File.Exists(decompressedPath)) - { - File.Delete(decompressedPath); - } + return ExtractTarArchive(archivePath, targetDir, muxerConfig, installTask); } else { - long totalFiles = 0; - using (var zip = ZipFile.OpenRead(archivePath)) - { - totalFiles = zip.Entries.Count; - } - if (extractTask != null) - { - extractTask.MaxValue = totalFiles > 0 ? totalFiles : 1; - using (var zip = ZipFile.OpenRead(archivePath)) - { - foreach (var entry in zip.Entries) - { - var outPath = Path.Combine(extractionDirectory, entry.FullName); - Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); - entry.ExtractToFile(outPath, overwrite: true); - extractTask.Increment(1); - } - } - } + return ExtractZipArchive(archivePath, targetDir, muxerConfig, installTask); } } catch (Exception e) { return e.Message; } - return null; } - internal static string ConstructArchiveName(string? versionString, string rid, string suffix) + /** + * Configure muxer handling by determining if it needs to be updated. + */ + private MuxerHandlingConfig ConfigureMuxerHandling(IEnumerable existingSdkVersions) { - return versionString is null - ? $"dotnet-sdk-{rid}{suffix}" - : $"dotnet-sdk-{versionString}-{rid}{suffix}"; + DotnetVersion? existingMuxerVersion = existingSdkVersions.Any() ? existingSdkVersions.Max() : (DotnetVersion?)null; + DotnetVersion newRuntimeVersion = _install.FullySpecifiedVersion; + bool shouldUpdateMuxer = existingMuxerVersion is null || newRuntimeVersion.CompareTo(existingMuxerVersion) > 0; + + string muxerName = DnupUtilities.GetDotnetExeName(); + string muxerTargetPath = Path.Combine(_request.TargetDirectory, muxerName); + + return new MuxerHandlingConfig( + muxerName, + muxerTargetPath, + shouldUpdateMuxer); } - private string? ExtractSdkToDir(string extractedArchivePath, string destDir, IEnumerable existingSdkVersions, Spectre.Console.ProgressTask? commitTask = null, List? files = null) + /** + * Extracts a tar or tar.gz archive to the target directory. + */ + private string? ExtractTarArchive(string archivePath, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) { - // Ensure destination directory exists - Directory.CreateDirectory(destDir); - - DotnetVersion? existingMuxerVersion = existingSdkVersions.Any() ? existingSdkVersions.Max() : (DotnetVersion?)null; - DotnetVersion runtimeVersion = _install.FullySpecifiedVersion; + string decompressedPath = DecompressTarGzIfNeeded(archivePath, out bool needsDecompression); try { - CopyMuxer(existingMuxerVersion, runtimeVersion, extractedArchivePath, destDir); - var fileList = files ?? Directory.EnumerateFileSystemEntries(extractedArchivePath, "*", SearchOption.AllDirectories).ToList(); - int processed = 0; - foreach (var sourcePath in fileList) + // Count files in tar for progress reporting + long totalFiles = CountTarEntries(decompressedPath); + + // Set progress maximum + if (installTask != null) { - var relativePath = Path.GetRelativePath(extractedArchivePath, sourcePath); - var destPath = Path.Combine(destDir, relativePath); + installTask.MaxValue = totalFiles > 0 ? totalFiles : 1; + } - if (File.Exists(sourcePath)) - { - // Skip dotnet.exe - if (string.Equals(Path.GetFileName(sourcePath), DnupUtilities.GetDotnetExeName(), StringComparison.OrdinalIgnoreCase)) - { - continue; - } + // Extract files directly to target + ExtractTarContents(decompressedPath, targetDir, muxerConfig, installTask); - Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); - DnupUtilities.ForceReplaceFile(sourcePath, destPath); - } - else if (Directory.Exists(sourcePath)) - { - // Merge directories: create if not exists, do not delete anything in dest - Directory.CreateDirectory(destPath); - } - processed++; - if (commitTask != null) - { - commitTask.Value = processed; - } + return null; + } + finally + { + // Clean up temporary decompressed file + if (needsDecompression && File.Exists(decompressedPath)) + { + File.Delete(decompressedPath); } } - catch (Exception e) + } + + /** + * Decompresses a .tar.gz file if needed, returning the path to the tar file. + */ + private string DecompressTarGzIfNeeded(string archivePath, out bool needsDecompression) + { + needsDecompression = archivePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); + if (!needsDecompression) { - return e.Message; + return archivePath; + } + + string decompressedPath = Path.Combine( + Path.GetDirectoryName(archivePath) ?? Directory.CreateTempSubdirectory().FullName, + "decompressed.tar"); + + using FileStream originalFileStream = File.OpenRead(archivePath); + using FileStream decompressedFileStream = File.Create(decompressedPath); + using GZipStream decompressionStream = new GZipStream(originalFileStream, CompressionMode.Decompress); + decompressionStream.CopyTo(decompressedFileStream); + + return decompressedPath; + } + + /** + * Counts the number of entries in a tar file for progress reporting. + */ + private long CountTarEntries(string tarPath) + { + long totalFiles = 0; + using var tarStream = File.OpenRead(tarPath); + var tarReader = new TarReader(tarStream); + while (tarReader.GetNextEntry() != null) + { + totalFiles++; + } + return totalFiles; + } + + /** + * Extracts the contents of a tar file to the target directory. + */ + private void ExtractTarContents(string tarPath, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) + { + using var tarStream = File.OpenRead(tarPath); + var tarReader = new TarReader(tarStream); + TarEntry? entry; + + while ((entry = tarReader.GetNextEntry()) != null) + { + if (entry.EntryType == TarEntryType.RegularFile) + { + ExtractTarFileEntry(entry, targetDir, muxerConfig, installTask); + } + else if (entry.EntryType == TarEntryType.Directory) + { + // Create directory if it doesn't exist + var dirPath = Path.Combine(targetDir, entry.Name); + Directory.CreateDirectory(dirPath); + installTask?.Increment(1); + } + else + { + // Skip other entry types + installTask?.Increment(1); + } } + } + + /** + * Extracts a single file entry from a tar archive. + */ + private void ExtractTarFileEntry(TarEntry entry, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) + { + var fileName = Path.GetFileName(entry.Name); + var destPath = Path.Combine(targetDir, entry.Name); + + if (string.Equals(fileName, muxerConfig.MuxerName, StringComparison.OrdinalIgnoreCase)) + { + if (muxerConfig.ShouldUpdateMuxer) + { + HandleMuxerUpdateFromTar(entry, muxerConfig.MuxerTargetPath); + } + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); + using var outStream = File.Create(destPath); + entry.DataStream?.CopyTo(outStream); + } + + installTask?.Increment(1); + } + + /** + * Handles updating the muxer from a tar entry, using a temporary file to avoid locking issues. + */ + private void HandleMuxerUpdateFromTar(TarEntry entry, string muxerTargetPath) + { + // Create a temporary file for the muxer first to avoid locking issues + var tempMuxerPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + using (var outStream = File.Create(tempMuxerPath)) + { + entry.DataStream?.CopyTo(outStream); + } + + try + { + // Replace the muxer using the utility that handles locking + DnupUtilities.ForceReplaceFile(tempMuxerPath, muxerTargetPath); + } + finally + { + if (File.Exists(tempMuxerPath)) + { + File.Delete(tempMuxerPath); + } + } + } + + /** + * Extracts a zip archive to the target directory. + */ + private string? ExtractZipArchive(string archivePath, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) + { + long totalFiles = CountZipEntries(archivePath); + + installTask?.MaxValue = totalFiles > 0 ? totalFiles : 1; + + using var zip = ZipFile.OpenRead(archivePath); + foreach (var entry in zip.Entries) + { + ExtractZipEntry(entry, targetDir, muxerConfig, installTask); + } + return null; } - private void CopyMuxer(DotnetVersion? existingMuxerVersion, DotnetVersion newRuntimeVersion, string archiveDir, string destDir) + /** + * Counts the number of entries in a zip file for progress reporting. + */ + private long CountZipEntries(string zipPath) + { + using var zip = ZipFile.OpenRead(zipPath); + return zip.Entries.Count; + } + + /** + * Extracts a single entry from a zip archive. + */ + private void ExtractZipEntry(ZipArchiveEntry entry, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) { - // 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 = Path.Combine(destDir, DnupUtilities.GetDotnetExeName()); + var fileName = Path.GetFileName(entry.FullName); + var destPath = Path.Combine(targetDir, entry.FullName); - if (existingMuxerVersion is not null && newRuntimeVersion.CompareTo(existingMuxerVersion) <= 0) + // Skip directories (we'll create them for files as needed) + if (string.IsNullOrEmpty(fileName)) { - // The new SDK is older than the existing muxer, so we don't need to do anything. + Directory.CreateDirectory(destPath); + installTask?.Increment(1); return; } - // The new SDK is newer than the existing muxer, so we need to replace it. - DnupUtilities.ForceReplaceFile(Path.Combine(archiveDir, DnupUtilities.GetDotnetExeName()), muxerTargetPath); + // Special handling for dotnet executable (muxer) + if (string.Equals(fileName, muxerConfig.MuxerName, StringComparison.OrdinalIgnoreCase)) + { + if (muxerConfig.ShouldUpdateMuxer) + { + HandleMuxerUpdateFromZip(entry, muxerConfig.MuxerTargetPath); + } + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); + entry.ExtractToFile(destPath, overwrite: true); + } + + installTask?.Increment(1); } - public void Commit() + /** + * Handles updating the muxer from a zip entry, using a temporary file to avoid locking issues. + */ + private void HandleMuxerUpdateFromZip(ZipArchiveEntry entry, string muxerTargetPath) { - Commit(GetExistingSdkVersions(_request.TargetDirectory)); + var tempMuxerPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + entry.ExtractToFile(tempMuxerPath, overwrite: true); + + try + { + // Replace the muxer using the utility that handles locking + DnupUtilities.ForceReplaceFile(tempMuxerPath, muxerTargetPath); + } + finally + { + if (File.Exists(tempMuxerPath)) + { + File.Delete(tempMuxerPath); + } + } } - public void Commit(IEnumerable existingSdkVersions) + /** + * Configuration class for muxer handling. + */ + private readonly struct MuxerHandlingConfig { - Spectre.Console.AnsiConsole.Progress() - .Start(ctx => - { - var files = Directory.EnumerateFileSystemEntries(scratchExtractionDirectory, "*", SearchOption.AllDirectories).ToList(); - var commitTask = ctx.AddTask($"Installing .NET SDK", autoStart: true); - commitTask.MaxValue = files.Count > 0 ? files.Count : 1; - ExtractSdkToDir(scratchExtractionDirectory, _request.TargetDirectory, existingSdkVersions, commitTask, files); - commitTask.Value = commitTask.MaxValue; - }); + public string MuxerName { get; } + public string MuxerTargetPath { get; } + public bool ShouldUpdateMuxer { get; } + + public MuxerHandlingConfig(string muxerName, string muxerTargetPath, bool shouldUpdateMuxer) + { + MuxerName = muxerName; + MuxerTargetPath = muxerTargetPath; + ShouldUpdateMuxer = shouldUpdateMuxer; + } } public void Dispose() { - File.Delete(scratchExtractionDirectory); - File.Delete(scratchDownloadDirectory); + try + { + // Clean up temporary download directory + if (Directory.Exists(scratchDownloadDirectory)) + { + Directory.Delete(scratchDownloadDirectory, recursive: true); + } + } + catch + { + } } // This should be cached and more sophisticated based on vscode logic in the future From b35c6d8d52b01215064f304cc9236697b9d0c171 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 13:42:41 -0700 Subject: [PATCH 45/58] Fix json aot serialization --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 2 +- src/Installer/dnup/DnupManifestJsonContext.cs | 9 ++- src/Installer/dnup/DotnetVersion.cs | 4 +- .../dnup/DotnetVersionJsonConverter.cs | 73 +++++++++++++++++++ .../dnup/ManifestChannelVersionResolver.cs | 8 +- 5 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 src/Installer/dnup/DotnetVersionJsonConverter.cs diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 4414f60c61ee..2b9ec5ddc344 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -444,7 +444,7 @@ private IEnumerable GetExistingSdkVersions(string targetDirectory } catch { - return Enumerable.Empty(); + return []; } } } diff --git a/src/Installer/dnup/DnupManifestJsonContext.cs b/src/Installer/dnup/DnupManifestJsonContext.cs index 746e70f50d62..2abd4eb49b68 100644 --- a/src/Installer/dnup/DnupManifestJsonContext.cs +++ b/src/Installer/dnup/DnupManifestJsonContext.cs @@ -6,7 +6,14 @@ namespace Microsoft.DotNet.Tools.Bootstrapper { - [JsonSourceGenerationOptions(WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSourceGenerationOptions(WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + Converters = new[] { typeof(DotnetVersionJsonConverter) })] [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(DotnetVersion))] + [JsonSerializable(typeof(DotnetVersionType))] + [JsonSerializable(typeof(InstallMode))] + [JsonSerializable(typeof(InstallArchitecture))] + [JsonSerializable(typeof(InstallType))] + [JsonSerializable(typeof(ManagementCadence))] public partial class DnupManifestJsonContext : JsonSerializerContext { } } diff --git a/src/Installer/dnup/DotnetVersion.cs b/src/Installer/dnup/DotnetVersion.cs index e974b654f965..cdc6cb864021 100644 --- a/src/Installer/dnup/DotnetVersion.cs +++ b/src/Installer/dnup/DotnetVersion.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Text.Json.Serialization; using Microsoft.Deployment.DotNet.Releases; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -25,6 +26,7 @@ public enum DotnetVersionType /// Supports both SDK versions (with feature bands) and Runtime versions, and handles build hashes and preview versions. /// [DebuggerDisplay("{Value} ({VersionType})")] +[JsonConverter(typeof(DotnetVersionJsonConverter))] public readonly record struct DotnetVersion : IComparable, IComparable, IEquatable { private readonly ReleaseVersion? _releaseVersion; @@ -88,7 +90,7 @@ public DotnetVersion(string? value, DotnetVersionType versionType = DotnetVersio { Value = value ?? string.Empty; VersionType = versionType; - _releaseVersion = ReleaseVersion.TryParse(GetVersionWithoutBuildHash(), out var version) ? version : null; + _releaseVersion = !string.IsNullOrEmpty(Value) && ReleaseVersion.TryParse(GetVersionWithoutBuildHash(), out var version) ? version : null; } /// diff --git a/src/Installer/dnup/DotnetVersionJsonConverter.cs b/src/Installer/dnup/DotnetVersionJsonConverter.cs new file mode 100644 index 000000000000..4ff20013e0c1 --- /dev/null +++ b/src/Installer/dnup/DotnetVersionJsonConverter.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + /// + /// A custom JSON converter for the DotnetVersion struct. + /// This ensures proper serialization and deserialization of the struct. + /// + public class DotnetVersionJsonConverter : JsonConverter + { + /// + /// Reads and converts the JSON to a DotnetVersion struct. + /// + public override DotnetVersion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + string? versionString = reader.GetString(); + return new DotnetVersion(versionString); + } + else if (reader.TokenType == JsonTokenType.StartObject) + { + string? versionString = null; + DotnetVersionType versionType = DotnetVersionType.Auto; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType == JsonTokenType.PropertyName) + { + string? propertyName = reader.GetString(); + reader.Read(); // Move to the property value + + if (propertyName != null && propertyName.Equals("value", StringComparison.OrdinalIgnoreCase)) + { + versionString = reader.GetString(); + } + else if (propertyName != null && propertyName.Equals("versionType", StringComparison.OrdinalIgnoreCase)) + { + versionType = (DotnetVersionType)reader.GetInt32(); + } + } + } + + return new DotnetVersion(versionString, versionType); + } + else if (reader.TokenType == JsonTokenType.Null) + { + return new DotnetVersion(null); + } + + throw new JsonException($"Unexpected token {reader.TokenType} when deserializing DotnetVersion"); + } + + /// + /// Writes a DotnetVersion struct as JSON. + /// + public override void Write(Utf8JsonWriter writer, DotnetVersion value, JsonSerializerOptions options) + { + if (string.IsNullOrEmpty(value.Value)) + { + writer.WriteNullValue(); + return; + } + writer.WriteStringValue(value.Value); + + } + } +} diff --git a/src/Installer/dnup/ManifestChannelVersionResolver.cs b/src/Installer/dnup/ManifestChannelVersionResolver.cs index 62e8744a2775..22502a23c1ff 100644 --- a/src/Installer/dnup/ManifestChannelVersionResolver.cs +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -17,9 +17,9 @@ public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion) // Resolve strings or other options if (!dotnetVersion.IsValidMajorVersion()) { - // TODO ping the r-manifest to handle 'lts' 'latest' etc - // Do this in a separate class and use dotnet release library to do so - // https://github.com/dotnet/deployment-tools/tree/main/src/Microsoft.Deployment.DotNet.Releases + // TODO ping the r-manifest to handle 'lts' 'latest' etc + // Do this in a separate class and use dotnet release library to do so + // https://github.com/dotnet/deployment-tools/tree/main/src/Microsoft.Deployment.DotNet.Releases } // Make sure the version is fully specified @@ -29,7 +29,7 @@ public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion) } return new DotnetInstall( - fullySpecifiedVersion, + dotnetVersion, dotnetChannelVersion.ResolvedDirectory, dotnetChannelVersion.Type, dotnetChannelVersion.Mode, From 7dfe03e9b3a638d32463ea8d4e1d43c40d156cda Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 14:12:41 -0700 Subject: [PATCH 46/58] find existing versions + parse channels --- src/Installer/dnup/DnupSharedManifest.cs | 15 ++++++ src/Installer/dnup/IDnupManifest.cs | 1 + .../dnup/ManifestChannelVersionResolver.cs | 23 ++++----- src/Installer/dnup/ReleaseManifest.cs | 49 +++++++++++++++++++ 4 files changed, 75 insertions(+), 13 deletions(-) diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index 254aace0db81..863eb18a92e5 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -70,6 +70,21 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v } } + /// + /// Gets installed versions filtered by a specific muxer directory. + /// + /// Directory to filter by (must match the MuxerDirectory property) + /// Optional validator to check installation validity + /// Installations that match the specified directory + public IEnumerable GetInstalledVersions(string muxerDirectory, IInstallationValidator? validator = null) + { + return GetInstalledVersions(validator) + .Where(install => string.Equals( + Path.GetFullPath(install.MuxerDirectory), + Path.GetFullPath(muxerDirectory), + StringComparison.OrdinalIgnoreCase)); + } + public void AddInstalledVersion(DotnetInstall version) { AssertHasFinalizationMutex(); diff --git a/src/Installer/dnup/IDnupManifest.cs b/src/Installer/dnup/IDnupManifest.cs index 1b692d8a67a9..252eefd41e65 100644 --- a/src/Installer/dnup/IDnupManifest.cs +++ b/src/Installer/dnup/IDnupManifest.cs @@ -8,6 +8,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper internal interface IDnupManifest { IEnumerable GetInstalledVersions(IInstallationValidator? validator = null); + IEnumerable GetInstalledVersions(string muxerDirectory, IInstallationValidator? validator = null); void AddInstalledVersion(DotnetInstall version); void RemoveInstalledVersion(DotnetInstall version); } diff --git a/src/Installer/dnup/ManifestChannelVersionResolver.cs b/src/Installer/dnup/ManifestChannelVersionResolver.cs index 22502a23c1ff..2b3c89aff8a2 100644 --- a/src/Installer/dnup/ManifestChannelVersionResolver.cs +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.DotNet.Tools.Bootstrapper; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -10,22 +11,18 @@ internal class ManifestChannelVersionResolver { public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion) { - string fullySpecifiedVersion = dotnetChannelVersion.ChannelVersion; + string channel = dotnetChannelVersion.ChannelVersion; + DotnetVersion dotnetVersion = new DotnetVersion(channel); - DotnetVersion dotnetVersion = new DotnetVersion(fullySpecifiedVersion); - - // Resolve strings or other options - if (!dotnetVersion.IsValidMajorVersion()) - { - // TODO ping the r-manifest to handle 'lts' 'latest' etc - // Do this in a separate class and use dotnet release library to do so - // https://github.com/dotnet/deployment-tools/tree/main/src/Microsoft.Deployment.DotNet.Releases - } - - // Make sure the version is fully specified + // If not fully specified, resolve to latest using ReleaseManifest if (!dotnetVersion.IsFullySpecified) { - // TODO ping the r-manifest to resolve latest within the specified qualities + var manifest = new ReleaseManifest(); + var latestVersion = manifest.GetLatestVersionForChannel(channel, dotnetChannelVersion.Mode); + if (latestVersion != null) + { + dotnetVersion = new DotnetVersion(latestVersion); + } } return new DotnetInstall( diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 0e186df0db65..644ad49c64dc 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -19,6 +19,55 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; /// internal class ReleaseManifest : IDisposable { + /// + /// Finds the latest fully specified version for a given channel string (major, major.minor, or feature band). + /// + /// Channel string (e.g., "9", "9.0", "9.0.1xx") + /// InstallMode.SDK or InstallMode.Runtime + /// Latest fully specified version string, or null if not found + public string? GetLatestVersionForChannel(string channel, InstallMode mode) + { + var products = GetProductCollection(); + // Parse channel + var parts = channel.Split('.'); + int major = parts.Length > 0 && int.TryParse(parts[0], out var m) ? m : -1; + int minor = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n : -1; + string? featureBandPattern = null; + if (parts.Length == 3 && parts[2].EndsWith("xx")) + { + featureBandPattern = parts[2].Substring(0, parts[2].Length - 2); // e.g., "1" from "1xx" + } + + // Find matching product(s) + var matchingProducts = products.Where(p => + int.TryParse(p.ProductVersion.Split('.')[0], out var prodMajor) && prodMajor == major && + (minor == -1 || (p.ProductVersion.Split('.').Length > 1 && int.TryParse(p.ProductVersion.Split('.')[1], out var prodMinor) && prodMinor == minor)) + ); + foreach (var product in matchingProducts.OrderByDescending(p => p.ProductVersion)) + { + var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + // Filter by mode (SDK or Runtime) + var filtered = releases.Where(r => + r.Files.Any(f => mode == InstallMode.SDK ? f.Name.Contains("sdk", StringComparison.OrdinalIgnoreCase) : f.Name.Contains("runtime", StringComparison.OrdinalIgnoreCase)) + ); + + // If feature band pattern is specified, filter SDK releases by patch + if (featureBandPattern != null && mode == InstallMode.SDK) + { + filtered = filtered.Where(r => + r.Version.Patch >= 100 && r.Version.Patch <= 999 && + r.Version.Patch.ToString().StartsWith(featureBandPattern) + ); + } + + var latest = filtered.OrderByDescending(r => r.Version).FirstOrDefault(); + if (latest != null) + { + return latest.Version.ToString(); + } + } + return null; + } private const string CacheSubdirectory = "dotnet-manifests"; private const int MaxRetryCount = 3; private const int RetryDelayMilliseconds = 1000; From e4f4fd06fb1bcb85daab59bdb2468d97325abfea Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 14:13:05 -0700 Subject: [PATCH 47/58] find existing versions --- .../dnup/InstallerOrchestratorSingleton.cs | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index 40d9910df6db..0384e885e0c5 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; +using System.IO; using System.Linq; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -30,6 +32,7 @@ private InstallerOrchestratorSingleton() { if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) { + Console.WriteLine($"\n.NET SDK {install.FullySpecifiedVersion.Value} is already installed, skipping installation."); return install; } } @@ -62,16 +65,27 @@ private InstallerOrchestratorSingleton() return install; } - // Add a doc string mentioning you must hold a mutex over the directory + /// + /// Gets the existing installs from the manifest. Must hold a mutex over the directory. + /// private IEnumerable GetExistingInstalls(string directory) { - // assert we have the finalize lock - return Enumerable.Empty(); + var manifestManager = new DnupSharedManifest(); + // Use the overload that filters by muxer directory + return manifestManager.GetInstalledVersions(directory); } + /// + /// Checks if the installation already exists. Must hold a mutex over the directory. + /// private bool InstallAlreadyExists(string directory, DotnetInstall install) { - // assert we have the finalize lock - return false; + var existingInstalls = GetExistingInstalls(directory); + + // Check if there's any existing installation that matches the version we're trying to install + return existingInstalls.Any(existing => + existing.FullySpecifiedVersion.Value == install.FullySpecifiedVersion.Value && + existing.Type == install.Type && + existing.Architecture == install.Architecture); } } From dca4da2c552771b702d3978e7c7e8dd4b4321def Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 14:56:13 -0700 Subject: [PATCH 48/58] Add tests for version parsing. --- src/Installer/dnup/ReleaseManifest.cs | 156 +++++++++++++----------- src/Installer/dnup/dnup.code-workspace | 130 +++++++++++--------- test/dnup.Tests/DotnetInstallTests.cs | 6 +- test/dnup.Tests/ReleaseManifestTests.cs | 39 ++++++ 4 files changed, 200 insertions(+), 131 deletions(-) create mode 100644 test/dnup.Tests/ReleaseManifestTests.cs diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 644ad49c64dc..189a0542e0a0 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -27,7 +27,6 @@ internal class ReleaseManifest : IDisposable /// Latest fully specified version string, or null if not found public string? GetLatestVersionForChannel(string channel, InstallMode mode) { - var products = GetProductCollection(); // Parse channel var parts = channel.Split('.'); int major = parts.Length > 0 && int.TryParse(parts[0], out var m) ? m : -1; @@ -38,36 +37,98 @@ internal class ReleaseManifest : IDisposable featureBandPattern = parts[2].Substring(0, parts[2].Length - 2); // e.g., "1" from "1xx" } - // Find matching product(s) - var matchingProducts = products.Where(p => - int.TryParse(p.ProductVersion.Split('.')[0], out var prodMajor) && prodMajor == major && - (minor == -1 || (p.ProductVersion.Split('.').Length > 1 && int.TryParse(p.ProductVersion.Split('.')[1], out var prodMinor) && prodMinor == minor)) - ); - foreach (var product in matchingProducts.OrderByDescending(p => p.ProductVersion)) + // Load the index manifest + var index = ProductCollection.GetAsync().GetAwaiter().GetResult(); + + // For major-only channels like "9", we need to find all products with that major version + if (minor == -1) { - var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); - // Filter by mode (SDK or Runtime) - var filtered = releases.Where(r => - r.Files.Any(f => mode == InstallMode.SDK ? f.Name.Contains("sdk", StringComparison.OrdinalIgnoreCase) : f.Name.Contains("runtime", StringComparison.OrdinalIgnoreCase)) - ); - - // If feature band pattern is specified, filter SDK releases by patch - if (featureBandPattern != null && mode == InstallMode.SDK) + // Get all products with matching major version + var matchingProducts = index.Where(p => + { + // Split the product version into parts + var productParts = p.ProductVersion.Split('.'); + if (productParts.Length > 0 && int.TryParse(productParts[0], out var productMajor)) + { + return productMajor == major; + } + return false; + }).ToList(); + + // For each matching product, get releases and filter + var allReleases = new List<(ProductRelease Release, Product Product)>(); + + foreach (var matchingProduct in matchingProducts) { - filtered = filtered.Where(r => - r.Version.Patch >= 100 && r.Version.Patch <= 999 && - r.Version.Patch.ToString().StartsWith(featureBandPattern) - ); + var productReleases = matchingProduct.GetReleasesAsync().GetAwaiter().GetResult(); + + // Filter by mode (SDK or Runtime) + var filteredForProduct = productReleases.Where(r => + r.Files.Any(f => mode == InstallMode.SDK ? + f.Name.Contains("sdk", StringComparison.OrdinalIgnoreCase) : + f.Name.Contains("runtime", StringComparison.OrdinalIgnoreCase)) + ).ToList(); + + foreach (var release in filteredForProduct) + { + allReleases.Add((release, matchingProduct)); + } } - var latest = filtered.OrderByDescending(r => r.Version).FirstOrDefault(); - if (latest != null) + // Find the latest release across all products + var latestAcrossProducts = allReleases.OrderByDescending(r => r.Release.Version).FirstOrDefault(); + + if (latestAcrossProducts.Release != null) { - return latest.Version.ToString(); + return latestAcrossProducts.Release.Version.ToString(); } + + return null; } + + // Find the product for the requested major.minor + string channelKey = $"{major}.{minor}"; + var product = index.FirstOrDefault(p => p.ProductVersion == channelKey); + if (product == null) + { + return null; + } + + // Load releases from the sub-manifest for this product + var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + + // Filter by mode (SDK or Runtime) + var filtered = releases.Where(r => + r.Files.Any(f => mode == InstallMode.SDK ? + f.Name.Contains("sdk", StringComparison.OrdinalIgnoreCase) : + f.Name.Contains("runtime", StringComparison.OrdinalIgnoreCase)) + ).ToList(); + + // If feature band pattern is specified, handle it specially for SDK + if (featureBandPattern != null && mode == InstallMode.SDK) + { + if (int.TryParse(featureBandPattern, out var bandNum)) + { + // For feature bands, we need to construct the version manually + // Since SDK feature bands are represented differently than runtime versions, + // we return a special format for feature bands + if (filtered.Any()) + { + // Return the feature band version pattern + return $"{major}.{minor}.{featureBandPattern}00"; + } + } + } + + var latest = filtered.OrderByDescending(r => r.Version).FirstOrDefault(); + if (latest != null) + { + return latest.Version.ToString(); + } + return null; } + private const string CacheSubdirectory = "dotnet-manifests"; private const int MaxRetryCount = 3; private const int RetryDelayMilliseconds = 1000; @@ -344,60 +405,12 @@ private ProductCollection GetProductCollection() // Use ScopedMutex for cross-process locking using var mutex = new ScopedMutex(ReleaseCacheMutexName); - if (!mutex.HasHandle) - { - // If we couldn't acquire the mutex, still try to load the collection - // but don't write to the cache file to avoid conflicts - return _productCollection ??= ProductCollection.GetAsync().GetAwaiter().GetResult(); - } - - // Double-check locking pattern - if (_productCollection != null) - { - return _productCollection; - } - - string cacheFilePath = Path.Combine(_cacheDirectory, "releases.json"); - bool useCachedData = false; - - if (File.Exists(cacheFilePath)) - { - var cacheFileAge = File.GetLastWriteTimeUtc(cacheFilePath); - // If cache exists and is less than 24 hours old, use it - useCachedData = (DateTime.UtcNow - cacheFileAge).TotalHours < 24; - } - - if (useCachedData) - { - try - { - string json = File.ReadAllText(cacheFilePath); - _productCollection = DeserializeProductCollection(json); - return _productCollection; - } - catch - { - // Continue to fetch fresh data if cache loading fails - } - } - - // Fetch fresh data with retry logic + // Always use the index manifest for ProductCollection for (int attempt = 1; attempt <= MaxRetryCount; attempt++) { try { _productCollection = ProductCollection.GetAsync().GetAwaiter().GetResult(); - - try - { - string json = SerializeProductCollection(_productCollection); - File.WriteAllText(cacheFilePath, json); - } - catch - { - // Continue since we have the data in memory - } - return _productCollection; } catch @@ -406,7 +419,6 @@ private ProductCollection GetProductCollection() { throw; } - Thread.Sleep(RetryDelayMilliseconds * attempt); // Exponential backoff } } diff --git a/src/Installer/dnup/dnup.code-workspace b/src/Installer/dnup/dnup.code-workspace index fca5689a68dc..e0349b05a271 100644 --- a/src/Installer/dnup/dnup.code-workspace +++ b/src/Installer/dnup/dnup.code-workspace @@ -1,60 +1,78 @@ { - "folders": [ - { - "path": ".", - "name": "dnup" - } - ], - "settings": { - "dotnet.defaultSolution": "dnup.csproj", - "omnisharp.defaultLaunchSolution": "dnup.csproj", - "csharp.debug.console": "externalTerminal", - "editor.formatOnSave": true, - "omnisharp.enableRoslynAnalyzers": true, - "omnisharp.useModernNet": true - }, - "launch": { - "version": "0.2.0", - "configurations": [ - { - "name": "Launch dnup (Default)", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build", - "program": "${workspaceFolder}/../../../artifacts/bin/dnup/Debug/net10.0/dnup.dll", - "args": [ - "sdk", - "install" - ], - "cwd": "${workspaceFolder}", - "console": "externalTerminal", - "stopAtEntry": false, - "logging": { - "moduleLoad": false + "folders": [ + { + "path": ".", + "name": "dnup" + }, + { + "path": "../../../../test/dnup.Tests", + "name": "dnup.Tests" } - } ], - "compounds": [] - }, - "tasks": { - "version": "2.0.0", - "tasks": [ - { - "label": "build", - "type": "process", - "command": "dotnet", - "args": [ - "build", - "${workspaceFolder}/dnup.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" + "settings": { + "dotnet.defaultSolution": "dnup.csproj", + "omnisharp.defaultLaunchSolution": "dnup.csproj", + "csharp.debug.console": "externalTerminal", + "editor.formatOnSave": true, + "omnisharp.enableRoslynAnalyzers": true, + "omnisharp.useModernNet": true + }, + "launch": { + "version": "0.2.0", + "configurations": [ + { + "name": "Launch dnup (Default)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/../../../artifacts/bin/dnup/Debug/net10.0/dnup.dll", + "args": [ + "sdk", + "install" + ], + "cwd": "${workspaceFolder}", + "console": "externalTerminal", + "stopAtEntry": false, + "logging": { + "moduleLoad": false + } + } ], - "problemMatcher": "$msCompile", - "group": { - "kind": "build", - "isDefault": true - } - } - ] - } -} + "compounds": [] + }, + "tasks": { + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "dnup.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "test", + "type": "process", + "command": "dotnet", + "args": [ + "test", + "../../../../test/dnup.Tests/dnup.Tests.csproj" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "test", + "isDefault": true + } + } + ] + } +} \ No newline at end of file diff --git a/test/dnup.Tests/DotnetInstallTests.cs b/test/dnup.Tests/DotnetInstallTests.cs index 68f70ad57949..b0c497b8e9a1 100644 --- a/test/dnup.Tests/DotnetInstallTests.cs +++ b/test/dnup.Tests/DotnetInstallTests.cs @@ -33,7 +33,7 @@ public void DotnetInstall_ShouldInheritFromBase() var mode = InstallMode.SDK; var architecture = InstallArchitecture.x64; - var install = new DotnetInstall(new DotnetVersion(version), directory, type, mode, architecture); + var install = new DotnetInstall(new DotnetVersion(version), directory, type, mode, architecture, new ManagementCadence()); install.FullySpecifiedVersion.Value.Should().Be(version); install.ResolvedDirectory.Should().Be(directory); @@ -58,8 +58,8 @@ public void MultipleInstances_ShouldHaveUniqueIds() public void Records_ShouldSupportValueEquality() { // Arrange - var install1 = new DotnetInstall("8.0.301", "/test", InstallType.User, InstallMode.SDK, InstallArchitecture.x64); - var install2 = new DotnetInstall("8.0.301", "/test", InstallType.User, InstallMode.SDK, InstallArchitecture.x64); + var install1 = new DotnetInstall("8.0.301", "/test", InstallType.User, InstallMode.SDK, InstallArchitecture.x64, new ManagementCadence()); + var install2 = new DotnetInstall("8.0.301", "/test", InstallType.User, InstallMode.SDK, InstallArchitecture.x64, new ManagementCadence()); // Act & Assert // Records should be equal based on values, except for the Id which is always unique diff --git a/test/dnup.Tests/ReleaseManifestTests.cs b/test/dnup.Tests/ReleaseManifestTests.cs new file mode 100644 index 000000000000..3484a9a38ba1 --- /dev/null +++ b/test/dnup.Tests/ReleaseManifestTests.cs @@ -0,0 +1,39 @@ +using System; +using Xunit; +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Dnup.Tests +{ + public class ReleaseManifestTests + { + [Fact] + public void GetLatestVersionForChannel_MajorOnly_ReturnsLatestVersion() + { + var manifest = new ReleaseManifest(); + var version = manifest.GetLatestVersionForChannel("9", InstallMode.SDK); + Assert.True(!string.IsNullOrEmpty(version)); + } + + [Fact] + public void GetLatestVersionForChannel_MajorMinor_ReturnsLatestVersion() + { + var manifest = new ReleaseManifest(); + var version = manifest.GetLatestVersionForChannel("9.0", InstallMode.SDK); + Assert.False(string.IsNullOrEmpty(version)); + Assert.StartsWith("9.0.", version); + } + + [Fact] + public void GetLatestVersionForChannel_FeatureBand_ReturnsLatestVersion() + { + var manifest = new ReleaseManifest(); + + var version = manifest.GetLatestVersionForChannel("9.0.1xx", InstallMode.SDK); + Console.WriteLine($"Version found: {version ?? "null"}"); + + // Feature band version should be returned in the format 9.0.100 + Assert.True(!string.IsNullOrEmpty(version)); + Assert.Matches(@"^9\.0\.1\d{2}$", version); + } + } +} From 87eea7db0001b745a7adbed2361838c59ff5c595 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 15:58:50 -0700 Subject: [PATCH 49/58] Correctly parse 9.0.1xx, and 9, and 9.0 --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 2 +- .../Commands/Sdk/Install/SdkInstallCommand.cs | 52 +- src/Installer/dnup/DotnetInstall.cs | 4 +- src/Installer/dnup/ReleaseManifest.cs | 446 +++++++++++++++--- 4 files changed, 435 insertions(+), 69 deletions(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 2b9ec5ddc344..4057de47730b 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -51,7 +51,7 @@ public void Prepare() */ private void VerifyArchive(string archivePath) { - if (!File.Exists(archivePath)) // replace this with actual verification logic once its implemented. + if (!File.Exists(archivePath)) // Enhancement: replace this with actual verification logic once its implemented. { throw new InvalidOperationException("Archive verification failed."); } diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 8e02dfb8c9ed..d26da67167ad 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -9,6 +9,7 @@ using SpectreAnsiConsole = Spectre.Console.AnsiConsole; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; +using System.Runtime.InteropServices; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; @@ -22,6 +23,7 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) private readonly IBootstrapperController _dotnetInstaller = new BootstrapperController(); private readonly IReleaseInfoProvider _releaseInfoProvider = new EnvironmentVariableMockReleaseInfoProvider(); + private readonly ManifestChannelVersionResolver _channelVersionResolver = new ManifestChannelVersionResolver(); public override int Execute() { @@ -185,7 +187,18 @@ public override int Execute() List additionalVersionsToInstall = new(); - var resolvedChannelVersion = _releaseInfoProvider.GetLatestVersion(resolvedChannel); + // Create a request and resolve it using the channel version resolver + var installRequest = new DotnetInstallRequest( + resolvedChannel, + resolvedInstallPath, + InstallType.User, + InstallMode.SDK, + DnupUtilities.GetInstallArchitecture(System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture), + new ManagementCadence(ManagementCadenceType.DNUP), + new InstallRequestOptions()); + + var resolvedInstall = _channelVersionResolver.Resolve(installRequest); + var resolvedChannelVersion = resolvedInstall.FullySpecifiedVersion.Value; if (resolvedSetDefaultInstall == true && defaultInstallState == InstallType.Admin) { @@ -214,7 +227,42 @@ public override int Execute() SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); - _dotnetInstaller.InstallSdks(resolvedInstallPath, SpectreAnsiConsole.Progress().Start(ctx => ctx), new[] { resolvedChannelVersion }.Concat(additionalVersionsToInstall)); + // Create and use a progress context + var progressContext = SpectreAnsiConsole.Progress().Start(ctx => ctx); + + // Install the main SDK using the InstallerOrchestratorSingleton directly + DotnetInstall? mainInstall = InstallerOrchestratorSingleton.Instance.Install(installRequest); + if (mainInstall == null) + { + SpectreAnsiConsole.MarkupLine($"[red]Failed to install .NET SDK {resolvedChannelVersion}[/]"); + return 1; + } + SpectreAnsiConsole.MarkupLine($"[green]Installed .NET SDK {mainInstall.FullySpecifiedVersion}, available via {mainInstall.MuxerDirectory}[/]"); + + // Install any additional versions + foreach (var additionalVersion in additionalVersionsToInstall) + { + // Create the request for the additional version + var additionalRequest = new DotnetInstallRequest( + additionalVersion, + resolvedInstallPath, + InstallType.User, + InstallMode.SDK, + DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture), + new ManagementCadence(ManagementCadenceType.DNUP), + new InstallRequestOptions()); + + // Install the additional version directly using InstallerOrchestratorSingleton + DotnetInstall? additionalInstall = InstallerOrchestratorSingleton.Instance.Install(additionalRequest); + if (additionalInstall == null) + { + SpectreAnsiConsole.MarkupLine($"[red]Failed to install additional .NET SDK {additionalVersion}[/]"); + } + else + { + SpectreAnsiConsole.MarkupLine($"[green]Installed additional .NET SDK {additionalInstall.FullySpecifiedVersion}, available via {additionalInstall.MuxerDirectory}[/]"); + } + } if (resolvedSetDefaultInstall == true) { diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/dnup/DotnetInstall.cs index 2bf453dc288b..7ff4b756f224 100644 --- a/src/Installer/dnup/DotnetInstall.cs +++ b/src/Installer/dnup/DotnetInstall.cs @@ -17,7 +17,7 @@ public record DotnetInstallBase( public Guid Id { get; } = Guid.NewGuid(); } -internal record InstallRequestOptions() +public record InstallRequestOptions() { // Include things such as the custom feed here. } @@ -37,7 +37,7 @@ public record DotnetInstall( /// /// Represents a request for a .NET installation with a channel version that will get resolved into a fully specified version. /// -internal record DotnetInstallRequest( +public record DotnetInstallRequest( string ChannelVersion, string TargetDirectory, InstallType Type, diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 189a0542e0a0..568d5427dadb 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -33,62 +33,256 @@ internal class ReleaseManifest : IDisposable int minor = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n : -1; string? featureBandPattern = null; if (parts.Length == 3 && parts[2].EndsWith("xx")) + // Check if we have a feature band (like 1xx) or a fully specified patch { - featureBandPattern = parts[2].Substring(0, parts[2].Length - 2); // e.g., "1" from "1xx" + featureBand = parts[2].Substring(0, parts[2].Length - 2); + } + else if (int.TryParse(parts[2], out _)) + { + // Fully specified version (e.g., "9.0.103") + isFullySpecified = true; + } } - // Load the index manifest - var index = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return (major, minor, featureBand, isFullySpecified); + } - // For major-only channels like "9", we need to find all products with that major version - if (minor == -1) + /// + /// Gets products from the index that match the specified major version. + /// + /// The product collection to search + /// The major version to match + /// List of matching products, ordered by minor version (descending) + private List GetProductsForMajorVersion(ProductCollection index, int major) + { + var matchingProducts = index.Where(p => { - // Get all products with matching major version - var matchingProducts = index.Where(p => + var productParts = p.ProductVersion.Split('.'); + if (productParts.Length > 0 && int.TryParse(productParts[0], out var productMajor)) { // Split the product version into parts var productParts = p.ProductVersion.Split('.'); - if (productParts.Length > 0 && int.TryParse(productParts[0], out var productMajor)) - { - return productMajor == major; } return false; }).ToList(); - // For each matching product, get releases and filter - var allReleases = new List<(ProductRelease Release, Product Product)>(); + return productMajor == major; + } + return false; + }).ToList(); foreach (var matchingProduct in matchingProducts) + { + var productParts = p.ProductVersion.Split('.'); { var productReleases = matchingProduct.GetReleasesAsync().GetAwaiter().GetResult(); + /// + /// List of releases to search + /// Optional major version filter + /// Optional minor version filter + /// Latest SDK version string, or null if none found + private string? GetLatestSdkVersion(IEnumerable releases, int? majorFilter = null, int? minorFilter = null) + { + var allSdks = releases + .SelectMany(r => r.Sdks) + .Where(sdk => + (!majorFilter.HasValue || sdk.Version.Major == majorFilter.Value) && + (!minorFilter.HasValue || sdk.Version.Minor == minorFilter.Value)) + .OrderByDescending(sdk => sdk.Version) + .ToList(); + + if (allSdks.Any()) + { + return allSdks.First().Version.ToString(); + } - // Filter by mode (SDK or Runtime) - var filteredForProduct = productReleases.Where(r => - r.Files.Any(f => mode == InstallMode.SDK ? - f.Name.Contains("sdk", StringComparison.OrdinalIgnoreCase) : - f.Name.Contains("runtime", StringComparison.OrdinalIgnoreCase)) - ).ToList(); + return null; + } - foreach (var release in filteredForProduct) - { - allReleases.Add((release, matchingProduct)); - } - } + /// + /// Gets all runtime components from the releases and returns the latest one. + /// + /// List of releases to search + /// Optional major version filter + /// Optional minor version filter + /// Optional runtime type filter (null for any runtime) + /// Latest runtime version string, or null if none found + private string? GetLatestRuntimeVersion(IEnumerable releases, int? majorFilter = null, int? minorFilter = null, string? runtimeType = null) + { + var allRuntimes = releases.SelectMany(r => r.Runtimes).ToList(); + + // Filter by version constraints if provided + if (majorFilter.HasValue) + { + allRuntimes = allRuntimes.Where(r => r.Version.Major == majorFilter.Value).ToList(); + } - // Find the latest release across all products - var latestAcrossProducts = allReleases.OrderByDescending(r => r.Release.Version).FirstOrDefault(); + if (minorFilter.HasValue) + { + allRuntimes = allRuntimes.Where(r => r.Version.Minor == minorFilter.Value).ToList(); + } - if (latestAcrossProducts.Release != null) + // Filter by runtime type if specified + if (!string.IsNullOrEmpty(runtimeType)) + { + if (string.Equals(runtimeType, "aspnetcore", StringComparison.OrdinalIgnoreCase)) + { + allRuntimes = allRuntimes + .Where(r => r.GetType().Name.Contains("AspNetCore", StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + else if (string.Equals(runtimeType, "windowsdesktop", StringComparison.OrdinalIgnoreCase)) { - return latestAcrossProducts.Release.Version.ToString(); + allRuntimes = allRuntimes + .Where(r => r.GetType().Name.Contains("WindowsDesktop", StringComparison.OrdinalIgnoreCase)) + .ToList(); } + else // Regular runtime + { + allRuntimes = allRuntimes + .Where(r => !r.GetType().Name.Contains("AspNetCore", StringComparison.OrdinalIgnoreCase) && + !r.GetType().Name.Contains("WindowsDesktop", StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + } + if (allRuntimes.Any()) + { + return allRuntimes.OrderByDescending(r => r.Version).First().Version.ToString(); + } + + return null; + } + + /// + /// Gets the latest SDK version that matches a specific feature band pattern. + /// + /// List of releases to search + /// Major version + /// Minor version + /// Feature band prefix (e.g., "1" for "1xx") + /// Latest matching version string, or fallback format if none found + private string? GetLatestFeatureBandVersion(IEnumerable releases, int major, int minor, string featureBand) + { + var allSdkComponents = releases.SelectMany(r => r.Sdks).ToList(); + + // Filter by feature band + var featureBandSdks = allSdkComponents + .Where(sdk => + { + var version = sdk.Version.ToString(); + var versionParts = version.Split('.'); + if (versionParts.Length < 3) return false; + + var patchPart = versionParts[2].Split('-')[0]; // Remove prerelease suffix + return patchPart.Length >= 3 && patchPart.StartsWith(featureBand); + }) + .OrderByDescending(sdk => sdk.Version) + .ToList(); + + if (featureBandSdks.Any()) + { + // Return the exact version from the latest matching SDK + return featureBandSdks.First().Version.ToString(); + } + + // Fallback if no actual release matches the feature band pattern + return $"{major}.{minor}.{featureBand}00"; + } + + /// + /// Finds the latest fully specified version for a given channel string (major, major.minor, or feature band). + /// + /// Channel string (e.g., "9", "9.0", "9.0.1xx", "9.0.103") + /// InstallMode.SDK or InstallMode.Runtime + /// Latest fully specified version string, or null if not found + public string? GetLatestVersionForChannel(string channel, InstallMode mode) + { + // If channel is null or empty, return null + if (string.IsNullOrEmpty(channel)) + { + return null; + } + + // Parse the channel string into components + var (major, minor, featureBand, isFullySpecified) = ParseVersionChannel(channel); + + // If major is invalid, return null + if (major < 0) + { + return null; + } + + // If the version is already fully specified, just return it as-is + if (isFullySpecified) + { + return channel; + } + + // Load the index manifest + var index = ProductCollection.GetAsync().GetAwaiter().GetResult(); + + // Case 1: Major only version (e.g., "9") + if (minor < 0) + { + return GetLatestVersionForMajorOnly(index, major, mode); + } + + // Case 2: Major.Minor version (e.g., "9.0") + if (minor >= 0 && featureBand == null) + { + return GetLatestVersionForMajorMinor(index, major, minor, mode); + } + + // Case 3: Feature band version (e.g., "9.0.1xx") + if (minor >= 0 && featureBand != null) + { + return GetLatestVersionForFeatureBand(index, major, minor, featureBand, mode); + } + + return null; + } + + /// + /// Gets the latest version for a major-only channel (e.g., "9"). + /// + private string? GetLatestVersionForMajorOnly(ProductCollection index, int major, InstallMode mode) + { + // Get products matching the major version + var matchingProducts = GetProductsForMajorVersion(index, major); + + if (!matchingProducts.Any()) + { return null; } + // Get all releases from all matching products + var allReleases = new List(); + foreach (var matchingProduct in matchingProducts) + { + allReleases.AddRange(matchingProduct.GetReleasesAsync().GetAwaiter().GetResult()); + } + + // Find the latest version based on mode + if (mode == InstallMode.SDK) + { + return GetLatestSdkVersion(allReleases, major); + } + else // Runtime mode + { + return GetLatestRuntimeVersion(allReleases, major); + } + } + + /// + /// Gets the latest version for a major.minor channel (e.g., "9.0"). + /// + private string? GetLatestVersionForMajorMinor(ProductCollection index, int major, int minor, InstallMode mode) + { // Find the product for the requested major.minor string channelKey = $"{major}.{minor}"; var product = index.FirstOrDefault(p => p.ProductVersion == channelKey); + if (product == null) { return null; @@ -97,38 +291,45 @@ internal class ReleaseManifest : IDisposable // Load releases from the sub-manifest for this product var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); - // Filter by mode (SDK or Runtime) - var filtered = releases.Where(r => - r.Files.Any(f => mode == InstallMode.SDK ? - f.Name.Contains("sdk", StringComparison.OrdinalIgnoreCase) : - f.Name.Contains("runtime", StringComparison.OrdinalIgnoreCase)) - ).ToList(); - - // If feature band pattern is specified, handle it specially for SDK - if (featureBandPattern != null && mode == InstallMode.SDK) + // Find the latest version based on mode + if (mode == InstallMode.SDK) { - if (int.TryParse(featureBandPattern, out var bandNum)) - { - // For feature bands, we need to construct the version manually - // Since SDK feature bands are represented differently than runtime versions, - // we return a special format for feature bands - if (filtered.Any()) - { - // Return the feature band version pattern - return $"{major}.{minor}.{featureBandPattern}00"; - } - } + return GetLatestSdkVersion(releases, major, minor); } + else // Runtime mode + { + return GetLatestRuntimeVersion(releases, major, minor); + } + } - var latest = filtered.OrderByDescending(r => r.Version).FirstOrDefault(); - if (latest != null) + /// + /// Gets the latest version for a feature band channel (e.g., "9.0.1xx"). + /// + private string? GetLatestVersionForFeatureBand(ProductCollection index, int major, int minor, string featureBand, InstallMode mode) + { + // Find the product for the requested major.minor + string channelKey = $"{major}.{minor}"; + var product = index.FirstOrDefault(p => p.ProductVersion == channelKey); + + if (product == null) { - return latest.Version.ToString(); + return null; } - return null; + // Load releases from the sub-manifest for this product + var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + + // For SDK mode, use feature band filtering + if (mode == InstallMode.SDK) + { + return GetLatestFeatureBandVersion(releases, major, minor, featureBand); + } + else // For Runtime mode, just use regular major.minor filtering + { + return GetLatestRuntimeVersion(releases, major, minor); + } } - + private const string CacheSubdirectory = "dotnet-manifests"; private const int MaxRetryCount = 3; private const int RetryDelayMilliseconds = 1000; @@ -378,12 +579,8 @@ public bool DownloadArchiveWithVerification(DotnetInstall install, string destin try { var productCollection = GetProductCollection(); - var product = FindProduct(productCollection, install.FullySpecifiedVersion.Value); - if (product == null) return null; - - var release = FindRelease(product, install.FullySpecifiedVersion.Value); - if (release == null) return null; - + var product = FindProduct(productCollection, install.FullySpecifiedVersion.Value) ?? throw new InvalidOperationException($"No product found for version {install.FullySpecifiedVersion.MajorMinor}"); + var release = FindRelease(product, install.FullySpecifiedVersion.Value, install.Mode) ?? throw new InvalidOperationException($"No release found for version {install.FullySpecifiedVersion.Value}"); return FindMatchingFile(release, install); } catch (Exception ex) @@ -465,11 +662,95 @@ private static ProductCollection DeserializeProductCollection(string json) /// /// Finds the specific release for the given version. /// - private static ProductRelease? FindRelease(Product product, string version) + private static ProductRelease? FindRelease(Product product, string version, InstallMode mode) { var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); var targetReleaseVersion = new ReleaseVersion(version); - return releases.FirstOrDefault(r => r.Version.Equals(targetReleaseVersion)); + + // Get all releases + var allReleases = releases.ToList(); + + // First try to find the exact version in the original release list + var exactReleaseMatch = allReleases.FirstOrDefault(r => r.Version.Equals(targetReleaseVersion)); + if (exactReleaseMatch != null) + { + return exactReleaseMatch; + } + + // Now check through the releases to find matching components + foreach (var release in allReleases) + { + bool foundMatch = false; + + // Check the appropriate collection based on the mode + if (mode == InstallMode.SDK) + { + foreach (var sdk in release.Sdks) + { + // Check for exact match + if (sdk.Version.Equals(targetReleaseVersion)) + { + foundMatch = true; + break; + } + + // Check for match on major, minor, patch + if (sdk.Version.Major == targetReleaseVersion.Major && + sdk.Version.Minor == targetReleaseVersion.Minor && + sdk.Version.Patch == targetReleaseVersion.Patch) + { + foundMatch = true; + break; + } + } + } + else // Runtime mode + { + // Filter by runtime type based on file names in the release + var runtimeTypeMatches = release.Files.Any(f => + f.Name.Contains("runtime", StringComparison.OrdinalIgnoreCase) && + !f.Name.Contains("aspnetcore", StringComparison.OrdinalIgnoreCase) && + !f.Name.Contains("windowsdesktop", StringComparison.OrdinalIgnoreCase)); + + var aspnetCoreMatches = release.Files.Any(f => + f.Name.Contains("aspnetcore", StringComparison.OrdinalIgnoreCase)); + + var windowsDesktopMatches = release.Files.Any(f => + f.Name.Contains("windowsdesktop", StringComparison.OrdinalIgnoreCase)); + + // Get the appropriate runtime components based on the file patterns + var filteredRuntimes = release.Runtimes; + + // Use the type information from the file names to filter runtime components + // This will prioritize matching the exact runtime type the user is looking for + + foreach (var runtime in filteredRuntimes) + { + // Check for exact match + if (runtime.Version.Equals(targetReleaseVersion)) + { + foundMatch = true; + break; + } + + // Check for match on major, minor, patch + if (runtime.Version.Major == targetReleaseVersion.Major && + runtime.Version.Minor == targetReleaseVersion.Minor && + runtime.Version.Patch == targetReleaseVersion.Patch) + { + foundMatch = true; + break; + } + } + } + + if (foundMatch) + { + return release; + } + } + + return null; } /// @@ -479,12 +760,49 @@ private static ProductCollection DeserializeProductCollection(string json) { var rid = DnupUtilities.GetRuntimeIdentifier(install.Architecture); var fileExtension = DnupUtilities.GetFileExtensionForPlatform(); - var componentType = install.Mode == InstallMode.SDK ? "sdk" : "runtime"; - return release.Files + // Determine the component type pattern to look for in file names + string componentTypePattern; + if (install.Mode == InstallMode.SDK) + { + componentTypePattern = "sdk"; + } + else // Runtime mode + { + // Determine the specific runtime type based on the release's file patterns + // Default to "runtime" if can't determine more specifically + componentTypePattern = "runtime"; + + // Check if this is specifically an ASP.NET Core runtime + if (install.FullySpecifiedVersion.Value.Contains("aspnetcore")) + { + componentTypePattern = "aspnetcore"; + } + // Check if this is specifically a Windows Desktop runtime + else if (install.FullySpecifiedVersion.Value.Contains("windowsdesktop")) + { + componentTypePattern = "windowsdesktop"; + } + } + + // Filter files based on runtime identifier, component type, and file extension + var matchingFiles = release.Files .Where(f => f.Rid == rid) - .Where(f => f.Name.Contains(componentType, StringComparison.OrdinalIgnoreCase)) - .FirstOrDefault(f => f.Name.EndsWith(fileExtension, StringComparison.OrdinalIgnoreCase)); + .Where(f => f.Name.Contains(componentTypePattern, StringComparison.OrdinalIgnoreCase)) + .Where(f => f.Name.EndsWith(fileExtension, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matchingFiles.Count == 0) + { + return null; + } + + // If we have multiple matching files, prefer the one with the full version in the name + var versionString = install.FullySpecifiedVersion.Value; + var bestMatch = matchingFiles.FirstOrDefault(f => f.Name.Contains(versionString, StringComparison.OrdinalIgnoreCase)); + + // If no file has the exact version string, return the first match + return bestMatch ?? matchingFiles.First(); } /// From 112115288116122fb7062c15db31f10ad6fb6890 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 15:59:11 -0700 Subject: [PATCH 50/58] Fix version parsing --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 19 ++++++++-- src/Installer/dnup/ReleaseManifest.cs | 39 ++++++++++++-------- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 4057de47730b..71f9a9e4d7a2 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -61,9 +61,22 @@ private void VerifyArchive(string archivePath) internal static string ConstructArchiveName(string? versionString, string rid, string suffix) { - return versionString is null - ? $"dotnet-sdk-{rid}{suffix}" - : $"dotnet-sdk-{versionString}-{rid}{suffix}"; + // If version is not specified, use a generic name + if (string.IsNullOrEmpty(versionString)) + { + return $"dotnet-sdk-{rid}{suffix}"; + } + + // Make sure the version string doesn't have any build hash or prerelease identifiers + // This ensures compatibility with the official download URLs + string cleanVersion = versionString; + int dashIndex = versionString.IndexOf('-'); + if (dashIndex >= 0) + { + cleanVersion = versionString.Substring(0, dashIndex); + } + + return $"dotnet-sdk-{cleanVersion}-{rid}{suffix}"; } diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 568d5427dadb..5ff89ea4614b 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -20,21 +20,25 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; internal class ReleaseManifest : IDisposable { /// - /// Finds the latest fully specified version for a given channel string (major, major.minor, or feature band). + /// Parses a version channel string into its components. /// - /// Channel string (e.g., "9", "9.0", "9.0.1xx") - /// InstallMode.SDK or InstallMode.Runtime - /// Latest fully specified version string, or null if not found - public string? GetLatestVersionForChannel(string channel, InstallMode mode) + /// Channel string to parse (e.g., "9", "9.0", "9.0.1xx", "9.0.103") + /// Tuple containing (major, minor, featureBand, isFullySpecified) + private (int Major, int Minor, string? FeatureBand, bool IsFullySpecified) ParseVersionChannel(string channel) { - // Parse channel var parts = channel.Split('.'); int major = parts.Length > 0 && int.TryParse(parts[0], out var m) ? m : -1; int minor = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n : -1; - string? featureBandPattern = null; - if (parts.Length == 3 && parts[2].EndsWith("xx")) + // Check if we have a feature band (like 1xx) or a fully specified patch + string? featureBand = null; + bool isFullySpecified = false; + + if (parts.Length >= 3) { + if (parts[2].EndsWith("xx")) + { + // Feature band pattern (e.g., "1xx") featureBand = parts[2].Substring(0, parts[2].Length - 2); } else if (int.TryParse(parts[2], out _)) @@ -60,22 +64,25 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma var productParts = p.ProductVersion.Split('.'); if (productParts.Length > 0 && int.TryParse(productParts[0], out var productMajor)) { - // Split the product version into parts - var productParts = p.ProductVersion.Split('.'); - } - return false; - }).ToList(); - return productMajor == major; } return false; }).ToList(); - foreach (var matchingProduct in matchingProducts) + // Order by minor version (descending) to prioritize newer versions + return matchingProducts.OrderByDescending(p => { var productParts = p.ProductVersion.Split('.'); + if (productParts.Length > 1 && int.TryParse(productParts[1], out var productMinor)) { - var productReleases = matchingProduct.GetReleasesAsync().GetAwaiter().GetResult(); + return productMinor; + } + return 0; + }).ToList(); + } + + /// + /// Gets all SDK components from the releases and returns the latest one. /// /// List of releases to search /// Optional major version filter From f65bc4ec435c01a28669cacea90036926c70efdd Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 16:19:39 -0700 Subject: [PATCH 51/58] add lts sts schannel support. fix project. --- src/Installer/dnup/ReleaseManifest.cs | 103 +++++++++++++++++++++++- src/Installer/dnup/dnup.code-workspace | 2 +- test/dnup.Tests/ReleaseManifestTests.cs | 44 ++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 5ff89ea4614b..9ae2d278b13b 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -200,7 +200,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// /// Finds the latest fully specified version for a given channel string (major, major.minor, or feature band). /// - /// Channel string (e.g., "9", "9.0", "9.0.1xx", "9.0.103") + /// Channel string (e.g., "9", "9.0", "9.0.1xx", "9.0.103", "lts", "sts") /// InstallMode.SDK or InstallMode.Runtime /// Latest fully specified version string, or null if not found public string? GetLatestVersionForChannel(string channel, InstallMode mode) @@ -211,6 +211,20 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma return null; } + // Check for special channel strings (case insensitive) + if (string.Equals(channel, "lts", StringComparison.OrdinalIgnoreCase)) + { + // Handle LTS (Long-Term Support) channel + var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return GetLatestVersionBySupportStatus(productIndex, isLts: true, mode); + } + else if (string.Equals(channel, "sts", StringComparison.OrdinalIgnoreCase)) + { + // Handle STS (Standard-Term Support) channel + var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return GetLatestVersionBySupportStatus(productIndex, isLts: false, mode); + } + // Parse the channel string into components var (major, minor, featureBand, isFullySpecified) = ParseVersionChannel(channel); @@ -281,6 +295,93 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma } } + /// + /// Gets the latest version based on support status (LTS or STS). + /// + /// The product collection to search + /// True for LTS (Long-Term Support), false for STS (Standard-Term Support) + /// InstallMode.SDK or InstallMode.Runtime + /// Latest stable version string matching the support status, or null if none found + private string? GetLatestVersionBySupportStatus(ProductCollection index, bool isLts, InstallMode mode) + { + // Get all products + var allProducts = index.ToList(); + + // LTS versions typically have even minor versions (e.g., 6.0, 8.0, 10.0) + // STS versions typically have odd minor versions (e.g., 7.0, 9.0, 11.0) + var filteredProducts = allProducts.Where(p => + { + var productParts = p.ProductVersion.Split('.'); + if (productParts.Length > 1 && int.TryParse(productParts[1], out var minorVersion)) + { + // For LTS, we want even minor versions (0, 2, 4, etc.) + // For STS, we want odd minor versions (1, 3, 5, etc.) + bool isEvenMinor = minorVersion % 2 == 0; + return isLts ? isEvenMinor : !isEvenMinor; + } + return false; + }).ToList(); + + // Order by major and minor version (descending) to get the most recent first + filteredProducts = filteredProducts + .OrderByDescending(p => + { + var productParts = p.ProductVersion.Split('.'); + if (productParts.Length > 0 && int.TryParse(productParts[0], out var majorVersion)) + { + return majorVersion * 100 + (productParts.Length > 1 && int.TryParse(productParts[1], out var minorVersion) ? minorVersion : 0); + } + return 0; + }) + .ToList(); + + // Get all releases from filtered products + foreach (var product in filteredProducts) + { + var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + + // Filter out preview versions + var stableReleases = releases + .Where(r => !r.IsPreview) + .ToList(); + + if (!stableReleases.Any()) + { + continue; // No stable releases for this product, try next one + } + + // Find latest version based on mode + if (mode == InstallMode.SDK) + { + var sdks = stableReleases + .SelectMany(r => r.Sdks) + .Where(sdk => !sdk.Version.ToString().Contains("-")) // Exclude any preview/RC versions + .OrderByDescending(sdk => sdk.Version) + .ToList(); + + if (sdks.Any()) + { + return sdks.First().Version.ToString(); + } + } + else // Runtime mode + { + var runtimes = stableReleases + .SelectMany(r => r.Runtimes) + .Where(runtime => !runtime.Version.ToString().Contains("-")) // Exclude any preview/RC versions + .OrderByDescending(runtime => runtime.Version) + .ToList(); + + if (runtimes.Any()) + { + return runtimes.First().Version.ToString(); + } + } + } + + return null; // No matching versions found + } + /// /// Gets the latest version for a major.minor channel (e.g., "9.0"). /// diff --git a/src/Installer/dnup/dnup.code-workspace b/src/Installer/dnup/dnup.code-workspace index e0349b05a271..6e8de4bb18ae 100644 --- a/src/Installer/dnup/dnup.code-workspace +++ b/src/Installer/dnup/dnup.code-workspace @@ -5,7 +5,7 @@ "name": "dnup" }, { - "path": "../../../../test/dnup.Tests", + "path": "../../../test/dnup.Tests", "name": "dnup.Tests" } ], diff --git a/test/dnup.Tests/ReleaseManifestTests.cs b/test/dnup.Tests/ReleaseManifestTests.cs index 3484a9a38ba1..52c72812de97 100644 --- a/test/dnup.Tests/ReleaseManifestTests.cs +++ b/test/dnup.Tests/ReleaseManifestTests.cs @@ -35,5 +35,49 @@ public void GetLatestVersionForChannel_FeatureBand_ReturnsLatestVersion() Assert.True(!string.IsNullOrEmpty(version)); Assert.Matches(@"^9\.0\.1\d{2}$", version); } + + [Fact] + public void GetLatestVersionForChannel_LTS_ReturnsLatestLTSVersion() + { + var manifest = new ReleaseManifest(); + var version = manifest.GetLatestVersionForChannel("lts", InstallMode.SDK); + + Console.WriteLine($"LTS Version found: {version ?? "null"}"); + + // Check that we got a version + Assert.False(string.IsNullOrEmpty(version)); + + // LTS versions should have even minor versions (e.g., 6.0, 8.0, 10.0) + var versionParts = version.Split('.'); + Assert.True(versionParts.Length >= 2, "Version should have at least major.minor parts"); + + int minorVersion = int.Parse(versionParts[1]); + Assert.True(minorVersion % 2 == 0, $"LTS version {version} should have an even minor version"); + + // Should not be a preview version + Assert.DoesNotContain("-", version); + } + + [Fact] + public void GetLatestVersionForChannel_STS_ReturnsLatestSTSVersion() + { + var manifest = new ReleaseManifest(); + var version = manifest.GetLatestVersionForChannel("sts", InstallMode.SDK); + + Console.WriteLine($"STS Version found: {version ?? "null"}"); + + // Check that we got a version + Assert.False(string.IsNullOrEmpty(version)); + + // STS versions should have odd minor versions (e.g., 7.0, 9.0, 11.0) + var versionParts = version.Split('.'); + Assert.True(versionParts.Length >= 2, "Version should have at least major.minor parts"); + + int minorVersion = int.Parse(versionParts[1]); + Assert.True(minorVersion % 2 != 0, $"STS version {version} should have an odd minor version"); + + // Should not be a preview version + Assert.DoesNotContain("-", version); + } } } From 681ab9887d911cc201a929f425f8418464471d7d Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 16:25:58 -0700 Subject: [PATCH 52/58] add preview support. --- src/Installer/dnup/ReleaseManifest.cs | 81 +++++++++++++++++++++++-- test/dnup.Tests/ReleaseManifestTests.cs | 24 ++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 9ae2d278b13b..b01a333b97ca 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -200,7 +200,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// /// Finds the latest fully specified version for a given channel string (major, major.minor, or feature band). /// - /// Channel string (e.g., "9", "9.0", "9.0.1xx", "9.0.103", "lts", "sts") + /// Channel string (e.g., "9", "9.0", "9.0.1xx", "9.0.103", "lts", "sts", "preview") /// InstallMode.SDK or InstallMode.Runtime /// Latest fully specified version string, or null if not found public string? GetLatestVersionForChannel(string channel, InstallMode mode) @@ -224,8 +224,12 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); return GetLatestVersionBySupportStatus(productIndex, isLts: false, mode); } - - // Parse the channel string into components + else if (string.Equals(channel, "preview", StringComparison.OrdinalIgnoreCase)) + { + // Handle Preview channel - get the latest preview version + var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return GetLatestPreviewVersion(productIndex, mode); + } // Parse the channel string into components var (major, minor, featureBand, isFullySpecified) = ParseVersionChannel(channel); // If major is invalid, return null @@ -383,8 +387,77 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma } /// - /// Gets the latest version for a major.minor channel (e.g., "9.0"). + /// Gets the latest preview version available. /// + /// The product collection to search + /// InstallMode.SDK or InstallMode.Runtime + /// Latest preview version string, or null if none found + private string? GetLatestPreviewVersion(ProductCollection index, InstallMode mode) + { + // Get all products + var allProducts = index.ToList(); + + // Order by major and minor version (descending) to get the most recent first + var sortedProducts = allProducts + .OrderByDescending(p => + { + var productParts = p.ProductVersion.Split('.'); + if (productParts.Length > 0 && int.TryParse(productParts[0], out var majorVersion)) + { + return majorVersion * 100 + (productParts.Length > 1 && int.TryParse(productParts[1], out var minorVersion) ? minorVersion : 0); + } + return 0; + }) + .ToList(); + + // Get all releases from products + foreach (var product in sortedProducts) + { + var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + + // Filter for preview versions + var previewReleases = releases + .Where(r => r.IsPreview) + .ToList(); + + if (!previewReleases.Any()) + { + continue; // No preview releases for this product, try next one + } + + // Find latest version based on mode + if (mode == InstallMode.SDK) + { + var sdks = previewReleases + .SelectMany(r => r.Sdks) + .Where(sdk => sdk.Version.ToString().Contains("-")) // Include only preview/RC versions + .OrderByDescending(sdk => sdk.Version) + .ToList(); + + if (sdks.Any()) + { + return sdks.First().Version.ToString(); + } + } + else // Runtime mode + { + var runtimes = previewReleases + .SelectMany(r => r.Runtimes) + .Where(runtime => runtime.Version.ToString().Contains("-")) // Include only preview/RC versions + .OrderByDescending(runtime => runtime.Version) + .ToList(); + + if (runtimes.Any()) + { + return runtimes.First().Version.ToString(); + } + } + } + + return null; // No preview versions found + } /// + /// Gets the latest version for a major.minor channel (e.g., "9.0"). + /// private string? GetLatestVersionForMajorMinor(ProductCollection index, int major, int minor, InstallMode mode) { // Find the product for the requested major.minor diff --git a/test/dnup.Tests/ReleaseManifestTests.cs b/test/dnup.Tests/ReleaseManifestTests.cs index 52c72812de97..81e88633c8dd 100644 --- a/test/dnup.Tests/ReleaseManifestTests.cs +++ b/test/dnup.Tests/ReleaseManifestTests.cs @@ -79,5 +79,29 @@ public void GetLatestVersionForChannel_STS_ReturnsLatestSTSVersion() // Should not be a preview version Assert.DoesNotContain("-", version); } + + [Fact] + public void GetLatestVersionForChannel_Preview_ReturnsLatestPreviewVersion() + { + var manifest = new ReleaseManifest(); + var version = manifest.GetLatestVersionForChannel("preview", InstallMode.SDK); + + Console.WriteLine($"Preview Version found: {version ?? "null"}"); + + // Check that we got a version + Assert.False(string.IsNullOrEmpty(version)); + + // Preview versions should contain a hyphen (e.g., "11.0.0-preview.1") + Assert.Contains("-", version); + + // Should contain preview, rc, beta, or alpha + Assert.True( + version.Contains("preview", StringComparison.OrdinalIgnoreCase) || + version.Contains("rc", StringComparison.OrdinalIgnoreCase) || + version.Contains("beta", StringComparison.OrdinalIgnoreCase) || + version.Contains("alpha", StringComparison.OrdinalIgnoreCase), + $"Version {version} should be a preview/rc/beta/alpha version" + ); + } } } From 70a828f751790dda948b12bba5a358b553684b0b Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 8 Sep 2025 10:22:48 -0700 Subject: [PATCH 53/58] Fix LTS STS Parsing --- src/Installer/dnup/ReleaseManifest.cs | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index b01a333b97ca..603a55b337ed 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -311,23 +311,10 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma // Get all products var allProducts = index.ToList(); - // LTS versions typically have even minor versions (e.g., 6.0, 8.0, 10.0) - // STS versions typically have odd minor versions (e.g., 7.0, 9.0, 11.0) - var filteredProducts = allProducts.Where(p => - { - var productParts = p.ProductVersion.Split('.'); - if (productParts.Length > 1 && int.TryParse(productParts[1], out var minorVersion)) - { - // For LTS, we want even minor versions (0, 2, 4, etc.) - // For STS, we want odd minor versions (1, 3, 5, etc.) - bool isEvenMinor = minorVersion % 2 == 0; - return isLts ? isEvenMinor : !isEvenMinor; - } - return false; - }).ToList(); - - // Order by major and minor version (descending) to get the most recent first - filteredProducts = filteredProducts + // Use ReleaseType from manifest (dotnetreleases library) + var targetType = isLts ? ReleaseType.LTS : ReleaseType.STS; + var filteredProducts = allProducts + .Where(p => p.ReleaseType == targetType) .OrderByDescending(p => { var productParts = p.ProductVersion.Split('.'); From 8d455777de4c352039040cad71f28f0f68dc93d0 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 26 Sep 2025 11:19:55 -0400 Subject: [PATCH 54/58] Add dnup tests to dnup.slnf --- dnup.slnf | 1 + 1 file changed, 1 insertion(+) diff --git a/dnup.slnf b/dnup.slnf index f2557db6ee09..d68938fd2ae0 100644 --- a/dnup.slnf +++ b/dnup.slnf @@ -3,6 +3,7 @@ "path": "sdk.slnx", "projects": [ "src\\Installer\\dnup\\dnup.csproj", + "test\\dnup.Tests\\dnup.Tests.csproj", ] } } \ No newline at end of file From 29b70539463517ee0de2f6b040c2c6c5b2ad9f90 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 29 Sep 2025 11:06:10 -0400 Subject: [PATCH 55/58] Move sdk update command to dnup --- src/Cli/dotnet/CliUsage.cs | 2 -- src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs | 6 +----- src/Cli/dotnet/Parser.cs | 4 ---- .../dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs | 2 +- src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs | 6 +++--- .../dnup}/Commands/Sdk/Update/SdkUpdateCommandParser.cs | 8 ++++---- src/Installer/dnup/Parser.cs | 4 ++++ 7 files changed, 13 insertions(+), 19 deletions(-) rename src/{Cli/dotnet => Installer/dnup}/Commands/Sdk/Update/SdkUpdateCommandParser.cs (84%) diff --git a/src/Cli/dotnet/CliUsage.cs b/src/Cli/dotnet/CliUsage.cs index 50b77bf0351f..8e0870a86e84 100644 --- a/src/Cli/dotnet/CliUsage.cs +++ b/src/Cli/dotnet/CliUsage.cs @@ -43,7 +43,6 @@ internal static class CliUsage clean {CliCommandStrings.CleanDefinition} format {CliCommandStrings.FormatDefinition} help {CliCommandStrings.HelpDefinition} - install Installs the .NET SDK msbuild {CliCommandStrings.MsBuildDefinition} new {CliCommandStrings.NewDefinition} nuget {CliCommandStrings.NugetDefinition} @@ -58,7 +57,6 @@ install Installs the .NET SDK store {CliCommandStrings.StoreDefinition} test {CliCommandStrings.TestDefinition} tool {CliCommandStrings.ToolDefinition} - update Updates the .NET SDK vstest {CliCommandStrings.VsTestDefinition} workload {CliCommandStrings.WorkloadDefinition} diff --git a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs index e030768e14ab..061eed3dbdf2 100644 --- a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs @@ -5,8 +5,6 @@ using System.CommandLine; using Microsoft.DotNet.Cli.Commands.Sdk.Check; -//using Microsoft.DotNet.Cli.Commands.Sdk.Install; -using Microsoft.DotNet.Cli.Commands.Sdk.Update; using Microsoft.DotNet.Cli.Extensions; namespace Microsoft.DotNet.Cli.Commands.Sdk; @@ -26,10 +24,8 @@ private static Command ConstructCommand() { DocumentedCommand command = new("sdk", DocsLink, CliCommandStrings.SdkAppFullName); command.Subcommands.Add(SdkCheckCommandParser.GetCommand()); - //command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); - command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); - //command.SetAction((parseResult) => parseResult.HandleMissingCommand()); + command.SetAction((parseResult) => parseResult.HandleMissingCommand()); return command; } diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index 8fcc522bcf30..4bddb07f976e 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -35,8 +35,6 @@ using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Commands.Run.Api; using Microsoft.DotNet.Cli.Commands.Sdk; -//using Microsoft.DotNet.Cli.Commands.Sdk.Install; -using Microsoft.DotNet.Cli.Commands.Sdk.Update; using Microsoft.DotNet.Cli.Commands.Solution; using Microsoft.DotNet.Cli.Commands.Store; using Microsoft.DotNet.Cli.Commands.Test; @@ -89,8 +87,6 @@ public static class Parser VSTestCommandParser.GetCommand(), HelpCommandParser.GetCommand(), SdkCommandParser.GetCommand(), - //SdkInstallCommandParser.GetRootInstallCommand(), - SdkUpdateCommandParser.GetRootUpdateCommand(), InstallSuccessCommand, WorkloadCommandParser.GetCommand(), new System.CommandLine.StaticCompletions.CompletionsCommand() diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs index 7d4418fa0ec9..9c28335cc124 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -45,7 +45,7 @@ public static Command GetSdkInstallCommand() return SdkInstallCommand; } - // Trying to use the same command object for both "dotnet install" and "dotnet sdk install" causes the following exception: + // Trying to use the same command object for both "dnup install" and "dnup sdk install" causes the following exception: // System.InvalidOperationException: Command install has more than one child named "channel". // So we create a separate instance for each case private static readonly Command RootInstallCommand = ConstructCommand(); diff --git a/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs b/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs index eb525222fd02..efd9de75d60b 100644 --- a/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs +++ b/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs @@ -6,6 +6,7 @@ using System.CommandLine; using System.Text; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Update; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk { @@ -20,10 +21,9 @@ public static Command GetCommand() private static Command ConstructCommand() { - Command command = new("sdk"); - //command.Subcommands.Add(SdkCheckCommandParser.GetCommand()); + Command command = new("sdk", "Manage sdk installations"); command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); - //command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); + command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); //command.SetAction((parseResult) => parseResult.HandleMissingCommand()); diff --git a/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs b/src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs similarity index 84% rename from src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs rename to src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs index 6cd55766d9c9..f419e0a28cf3 100644 --- a/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs +++ b/src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs @@ -2,9 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.DotNet.Tools.Bootstrapper; -namespace Microsoft.DotNet.Cli.Commands.Sdk.Update; +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Update; internal static class SdkUpdateCommandParser { @@ -21,7 +21,7 @@ internal static class SdkUpdateCommandParser Arity = ArgumentArity.Zero }; - public static readonly Option InteractiveOption = CommonOptions.InteractiveOption(); + public static readonly Option InteractiveOption = CommonOptions.InteractiveOption; private static readonly Command SdkUpdateCommand = ConstructCommand(); @@ -30,7 +30,7 @@ public static Command GetSdkUpdateCommand() return SdkUpdateCommand; } - // Trying to use the same command object for both "dotnet udpate" and "dotnet sdk update" causes an InvalidOperationException + // Trying to use the same command object for both "dnup udpate" and "dnup sdk update" causes an InvalidOperationException // So we create a separate instance for each case private static readonly Command RootUpdateCommand = ConstructCommand(); diff --git a/src/Installer/dnup/Parser.cs b/src/Installer/dnup/Parser.cs index 286a8a7d255d..7032270df288 100644 --- a/src/Installer/dnup/Parser.cs +++ b/src/Installer/dnup/Parser.cs @@ -7,6 +7,8 @@ using System.CommandLine.Completions; using System.Text; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Update; namespace Microsoft.DotNet.Tools.Bootstrapper { @@ -34,6 +36,8 @@ internal class Parser private static RootCommand ConfigureCommandLine(RootCommand rootCommand) { rootCommand.Subcommands.Add(SdkCommandParser.GetCommand()); + rootCommand.Subcommands.Add(SdkInstallCommandParser.GetRootInstallCommand()); + rootCommand.Subcommands.Add(SdkUpdateCommandParser.GetRootUpdateCommand()); return rootCommand; } From 97b7c35682603bd1bb6cd0233b1d3aa82e8cb8c4 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 2 Oct 2025 10:50:52 -0400 Subject: [PATCH 56/58] Misc cleanup --- src/Cli/dotnet/dotnet.csproj | 1 - src/Installer/dnup/BootstrapperController.cs | 19 +++++++----------- .../Commands/Sdk/Install/SdkInstallCommand.cs | 20 ++++--------------- src/Installer/dnup/DnupUtilities.cs | 16 +++++++++++++++ 4 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/Cli/dotnet/dotnet.csproj b/src/Cli/dotnet/dotnet.csproj index f133ad234ca9..156dc1027688 100644 --- a/src/Cli/dotnet/dotnet.csproj +++ b/src/Cli/dotnet/dotnet.csproj @@ -61,7 +61,6 @@ - diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index d1f431d172f1..0b9b37305912 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -34,13 +34,15 @@ public InstallType GetConfiguredInstallType(out string? currentInstallPath) string? dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT"); string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); string programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); - bool isAdminInstall = installDir.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) - || installDir.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase); - + bool isAdminInstall = installDir.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) || + installDir.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase); + if (isAdminInstall) { // Admin install: DOTNET_ROOT should not be set, or if set, should match installDir - if (!string.IsNullOrEmpty(dotnetRoot) && !PathsEqual(dotnetRoot, installDir) && !dotnetRoot.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) && !dotnetRoot.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(dotnetRoot) && !DnupUtilities.PathsEqual(dotnetRoot, installDir) && + !dotnetRoot.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) && + !dotnetRoot.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase)) { return InstallType.Inconsistent; } @@ -49,7 +51,7 @@ public InstallType GetConfiguredInstallType(out string? currentInstallPath) else { // User install: DOTNET_ROOT must be set and match installDir - if (string.IsNullOrEmpty(dotnetRoot) || !PathsEqual(dotnetRoot, installDir)) + if (string.IsNullOrEmpty(dotnetRoot) || !DnupUtilities.PathsEqual(dotnetRoot, installDir)) { return InstallType.Inconsistent; } @@ -57,13 +59,6 @@ public InstallType GetConfiguredInstallType(out string? currentInstallPath) } } - private static bool PathsEqual(string a, string b) - { - return string.Equals(Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - StringComparison.OrdinalIgnoreCase); - } - public string GetDefaultDotnetInstallPath() { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index d26da67167ad..829172b9fd59 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -27,16 +27,6 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) public override int Execute() { - //bool? updateGlobalJson = null; - - //var updateGlobalJsonOption = _parseResult.GetResult(SdkInstallCommandParser.UpdateGlobalJsonOption)!; - //if (updateGlobalJsonOption.Implicit) - //{ - - //} - - //Reporter.Output.WriteLine($"Update global.json: {_updateGlobalJson}"); - var globalJsonInfo = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory); string? currentInstallPath; @@ -50,8 +40,7 @@ public override int Execute() installPathFromGlobalJson = globalJsonInfo.SdkPath; if (installPathFromGlobalJson != null && _installPath != null && - // TODO: Is there a better way to compare paths that takes into account whether the file system is case-sensitive? - !installPathFromGlobalJson.Equals(_installPath, StringComparison.OrdinalIgnoreCase)) + !DnupUtilities.PathsEqual(installPathFromGlobalJson, _installPath)) { // TODO: Add parameter to override error Console.Error.WriteLine($"Error: The install path specified in global.json ({installPathFromGlobalJson}) does not match the install path provided ({_installPath})."); @@ -152,8 +141,7 @@ public override int Execute() } else if (defaultInstallState == InstallType.User) { - // Another case where we need to compare paths and the comparison may or may not need to be case-sensitive - if (resolvedInstallPath.Equals(currentInstallPath, StringComparison.OrdinalIgnoreCase)) + if (DnupUtilities.PathsEqual(resolvedInstallPath, currentInstallPath)) { // No need to prompt here, the default install is already set up. } @@ -193,7 +181,7 @@ public override int Execute() resolvedInstallPath, InstallType.User, InstallMode.SDK, - DnupUtilities.GetInstallArchitecture(System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture), + DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture), new ManagementCadence(ManagementCadenceType.DNUP), new InstallRequestOptions()); @@ -219,7 +207,7 @@ public override int Execute() } else { - // TODO: Add command-linen option for installing admin versions locally + // TODO: Add command-line option for installing admin versions locally } } diff --git a/src/Installer/dnup/DnupUtilities.cs b/src/Installer/dnup/DnupUtilities.cs index e759ee3eea46..0b8a2ccb5a26 100644 --- a/src/Installer/dnup/DnupUtilities.cs +++ b/src/Installer/dnup/DnupUtilities.cs @@ -18,6 +18,22 @@ public static string GetDotnetExeName() return "dotnet" + ExeSuffix; } + public static bool PathsEqual(string? a, string? b) + { + if (a == null && b == null) + { + return true; + } + else if (a == null || b == null) + { + return false; + } + + return string.Equals(Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase); + } + public static InstallArchitecture GetInstallArchitecture(System.Runtime.InteropServices.Architecture architecture) { return architecture switch From b77138252aced92bb4fd702e44945202c8df9a45 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 2 Oct 2025 20:52:08 -0400 Subject: [PATCH 57/58] Cleanup --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 2 +- .../dnup/Commands/Sdk/Install/SdkInstallCommand.cs | 6 ------ src/Installer/dnup/DnupSharedManifest.cs | 7 +++---- src/Installer/dnup/DnupUtilities.cs | 2 +- src/Installer/dnup/ReleaseManifest.cs | 2 +- 5 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 71f9a9e4d7a2..cc42922366a8 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -29,7 +29,7 @@ public void Prepare() { using var releaseManifest = new ReleaseManifest(); var archiveName = $"dotnet-{_install.Id}"; - _archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetFileExtensionForPlatform()); + _archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetArchiveFileExtensionForPlatform()); Spectre.Console.AnsiConsole.Progress() .Start(ctx => diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 829172b9fd59..cfbeb2d195c4 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -278,10 +278,4 @@ public override int Execute() return Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_CHANNEL"); } - bool IsElevated() - { - return false; - } - - // ...existing code... } diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index 863eb18a92e5..dc935366ee97 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -66,7 +66,7 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v } catch (JsonException ex) { - throw new InvalidOperationException($"The dnup manifest is corrupt or inaccessible: {ex.Message}"); + throw new InvalidOperationException($"The dnup manifest is corrupt or inaccessible", ex); } } @@ -79,10 +79,9 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v public IEnumerable GetInstalledVersions(string muxerDirectory, IInstallationValidator? validator = null) { return GetInstalledVersions(validator) - .Where(install => string.Equals( + .Where(install => DnupUtilities.PathsEqual( Path.GetFullPath(install.MuxerDirectory), - Path.GetFullPath(muxerDirectory), - StringComparison.OrdinalIgnoreCase)); + Path.GetFullPath(muxerDirectory))); } public void AddInstalledVersion(DotnetInstall version) diff --git a/src/Installer/dnup/DnupUtilities.cs b/src/Installer/dnup/DnupUtilities.cs index 0b8a2ccb5a26..c2b5a901918d 100644 --- a/src/Installer/dnup/DnupUtilities.cs +++ b/src/Installer/dnup/DnupUtilities.cs @@ -77,7 +77,7 @@ public static string GetRuntimeIdentifier(InstallArchitecture architecture) return $"{os}-{arch}"; } - public static string GetFileExtensionForPlatform() + public static string GetArchiveFileExtensionForPlatform() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 603a55b337ed..9b9dd7140c81 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -927,7 +927,7 @@ private static ProductCollection DeserializeProductCollection(string json) private static ReleaseFile? FindMatchingFile(ProductRelease release, DotnetInstall install) { var rid = DnupUtilities.GetRuntimeIdentifier(install.Architecture); - var fileExtension = DnupUtilities.GetFileExtensionForPlatform(); + var fileExtension = DnupUtilities.GetArchiveFileExtensionForPlatform(); // Determine the component type pattern to look for in file names string componentTypePattern; From deeab320dc83d11da4886d918df55211b27981ef Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 3 Oct 2025 14:43:52 -0400 Subject: [PATCH 58/58] Remove dnup to dotnet shims --- src/Layout/redist/dnup | 6 ------ src/Layout/redist/dnup.cmd | 6 ------ src/Layout/redist/targets/GenerateInstallerLayout.targets | 2 -- 3 files changed, 14 deletions(-) delete mode 100644 src/Layout/redist/dnup delete mode 100644 src/Layout/redist/dnup.cmd diff --git a/src/Layout/redist/dnup b/src/Layout/redist/dnup deleted file mode 100644 index 04758bf9f8d4..000000000000 --- a/src/Layout/redist/dnup +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh -if [ $# -eq 0 ]; then - "$(dirname "$0")/dotnet" install -else - "$(dirname "$0")/dotnet" "$@" -fi diff --git a/src/Layout/redist/dnup.cmd b/src/Layout/redist/dnup.cmd deleted file mode 100644 index 7d8cb5bc7af0..000000000000 --- a/src/Layout/redist/dnup.cmd +++ /dev/null @@ -1,6 +0,0 @@ -@echo off -if "%~1"=="" ( - "%~dp0dotnet.exe" install -) else ( - "%~dp0dotnet.exe" %* -) diff --git a/src/Layout/redist/targets/GenerateInstallerLayout.targets b/src/Layout/redist/targets/GenerateInstallerLayout.targets index 14adead05b41..4c7aa7749e3b 100644 --- a/src/Layout/redist/targets/GenerateInstallerLayout.targets +++ b/src/Layout/redist/targets/GenerateInstallerLayout.targets @@ -68,9 +68,7 @@ - -