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/dnup.slnf b/dnup.slnf new file mode 100644 index 000000000000..d68938fd2ae0 --- /dev/null +++ b/dnup.slnf @@ -0,0 +1,9 @@ +{ + "solution": { + "path": "sdk.slnx", + "projects": [ + "src\\Installer\\dnup\\dnup.csproj", + "test\\dnup.Tests\\dnup.Tests.csproj", + ] + } +} \ No newline at end of file diff --git a/sdk.slnx b/sdk.slnx index a2a04af45101..2ea3064bed5f 100644 --- a/sdk.slnx +++ b/sdk.slnx @@ -3,7 +3,6 @@ - @@ -86,6 +85,9 @@ + + + @@ -292,6 +294,7 @@ + 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/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/.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 new file mode 100644 index 000000000000..1510a597f220 --- /dev/null +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -0,0 +1,468 @@ +// 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 Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class ArchiveDotnetInstaller : IDotnetInstaller, IDisposable +{ + private readonly DotnetInstallRequest _request; + private readonly ReleaseVersion _resolvedVersion; + private string scratchDownloadDirectory; + private string? _archivePath; + + public ArchiveDotnetInstaller(DotnetInstallRequest request, ReleaseVersion resolvedVersion) + { + _request = request; + _resolvedVersion = resolvedVersion; + scratchDownloadDirectory = Directory.CreateTempSubdirectory().FullName; + } + + public void Prepare() + { + using var releaseManifest = new ReleaseManifest(); + var archiveName = $"dotnet-{Guid.NewGuid()}"; + _archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetArchiveFileExtensionForPlatform()); + + Spectre.Console.AnsiConsole.Progress() + .Start(ctx => + { + var downloadTask = ctx.AddTask($"Downloading .NET SDK {_resolvedVersion}", autoStart: true); + var reporter = new SpectreDownloadProgressReporter(downloadTask, $"Downloading .NET SDK {_resolvedVersion}"); + var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_request, _resolvedVersion, _archivePath, reporter); + if (!downloadSuccess) + { + throw new InvalidOperationException($"Failed to download .NET archive for version {_resolvedVersion}"); + } + + downloadTask.Value = 100; + }); + } + + /** + Returns a string if the archive is valid within SDL specification, false otherwise. + */ + private void VerifyArchive(string archivePath) + { + if (!File.Exists(archivePath)) // Enhancement: replace this with actual verification logic once its implemented. + { + throw new InvalidOperationException("Archive verification failed."); + } + } + + + + internal static string ConstructArchiveName(string? versionString, string rid, string 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}"; + } + + + + public void Commit() + { + Commit(GetExistingSdkVersions(_request.InstallRoot)); + } + + 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 {_resolvedVersion}", autoStart: true); + + // Extract archive directly to target directory with special handling for muxer + var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, installTask); + if (extractResult != null) + { + throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); + } + + installTask.Value = installTask.MaxValue; + }); + } + + /** + * 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)) + { + return ExtractTarArchive(archivePath, targetDir, muxerConfig, installTask); + } + else + { + return ExtractZipArchive(archivePath, targetDir, muxerConfig, installTask); + } + } + catch (Exception e) + { + return e.Message; + } + } + + /** + * Configure muxer handling by determining if it needs to be updated. + */ + private MuxerHandlingConfig ConfigureMuxerHandling(IEnumerable existingSdkVersions) + { + ReleaseVersion? existingMuxerVersion = existingSdkVersions.Any() ? existingSdkVersions.Max() : (ReleaseVersion?)null; + ReleaseVersion newRuntimeVersion = _resolvedVersion; + bool shouldUpdateMuxer = existingMuxerVersion is null || newRuntimeVersion.CompareTo(existingMuxerVersion) > 0; + + string muxerName = DnupUtilities.GetDotnetExeName(); + string muxerTargetPath = Path.Combine(_request.InstallRoot.Path!, muxerName); + + return new MuxerHandlingConfig( + muxerName, + muxerTargetPath, + shouldUpdateMuxer); + } + + /** + * Extracts a tar or tar.gz archive to the target directory. + */ + private string? ExtractTarArchive(string archivePath, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) + { + string decompressedPath = DecompressTarGzIfNeeded(archivePath, out bool needsDecompression); + + try + { + // Count files in tar for progress reporting + long totalFiles = CountTarEntries(decompressedPath); + + // Set progress maximum + if (installTask != null) + { + installTask.MaxValue = totalFiles > 0 ? totalFiles : 1; + } + + // Extract files directly to target + ExtractTarContents(decompressedPath, targetDir, muxerConfig, installTask); + + return null; + } + finally + { + // Clean up temporary decompressed file + if (needsDecompression && File.Exists(decompressedPath)) + { + File.Delete(decompressedPath); + } + } + } + + /** + * 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 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; + } + + /** + * 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) + { + var fileName = Path.GetFileName(entry.FullName); + var destPath = Path.Combine(targetDir, entry.FullName); + + // Skip directories (we'll create them for files as needed) + if (string.IsNullOrEmpty(fileName)) + { + Directory.CreateDirectory(destPath); + installTask?.Increment(1); + return; + } + + // 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); + } + + /** + * Handles updating the muxer from a zip entry, using a temporary file to avoid locking issues. + */ + private void HandleMuxerUpdateFromZip(ZipArchiveEntry entry, string muxerTargetPath) + { + 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); + } + } + } + + /** + * Configuration class for muxer handling. + */ + private readonly struct MuxerHandlingConfig + { + 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() + { + try + { + // Clean up temporary download directory + if (Directory.Exists(scratchDownloadDirectory)) + { + Directory.Delete(scratchDownloadDirectory, recursive: true); + } + } + catch + { + } + } + + // TODO: InstallerOrchestratorSingleton also checks existing installs via the manifest. Which should we use and where? + // This should be cached and more sophisticated based on vscode logic in the future + private IEnumerable GetExistingSdkVersions(DotnetInstallRoot installRoot) + { + if (installRoot.Path == null) + return Enumerable.Empty(); + + var dotnetExe = Path.Combine(installRoot.Path, 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 (ReleaseVersion.TryParse(versionStr, out var version)) + { + versions.Add(version); + } + } + } + return versions; + } + catch + { + return []; + } + } +} 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 new file mode 100644 index 000000000000..3e7a6b1e888a --- /dev/null +++ b/src/Installer/dnup/BootstrapperController.cs @@ -0,0 +1,167 @@ +// 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.Text.Json; +using Microsoft.DotNet.Cli.Utils; +using Spectre.Console; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public class BootstrapperController : IBootstrapperController +{ + private readonly IEnvironmentProvider _environmentProvider; + + public BootstrapperController(IEnvironmentProvider? environmentProvider = null) + { + _environmentProvider = environmentProvider ?? new EnvironmentProvider(); + } + + public DotnetInstallRoot GetConfiguredInstallType() + { + + string? foundDotnet = _environmentProvider.GetCommandPath("dotnet"); + if (string.IsNullOrEmpty(foundDotnet)) + { + return new(null, InstallType.None, DnupUtilities.GetDefaultInstallArchitecture()); + } + + string installDir = Path.GetDirectoryName(foundDotnet)!; + + + 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) && !DnupUtilities.PathsEqual(dotnetRoot, installDir) && + !dotnetRoot.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) && + !dotnetRoot.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase)) + { + return new(installDir, InstallType.Inconsistent, DnupUtilities.GetDefaultInstallArchitecture()); + } + return new(installDir, InstallType.Admin, DnupUtilities.GetDefaultInstallArchitecture()); + } + else + { + // User install: DOTNET_ROOT must be set and match installDir + if (string.IsNullOrEmpty(dotnetRoot) || !DnupUtilities.PathsEqual(dotnetRoot, installDir)) + { + return new(installDir, InstallType.Inconsistent, DnupUtilities.GetDefaultInstallArchitecture()); + } + return new(installDir, InstallType.User, DnupUtilities.GetDefaultInstallArchitecture()); + } + } + + public string GetDefaultDotnetInstallPath() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); + } + + public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) + { + string? directory = initialDirectory; + while (!string.IsNullOrEmpty(directory)) + { + string globalJsonPath = Path.Combine(directory, "global.json"); + if (File.Exists(globalJsonPath)) + { + using var stream = File.OpenRead(globalJsonPath); + var contents = JsonSerializer.Deserialize( + stream, + GlobalJsonContentsJsonContext.Default.GlobalJsonContents); + return new GlobalJsonInfo + { + GlobalJsonPath = globalJsonPath, + GlobalJsonContents = contents + }; + } + var parent = Directory.GetParent(directory); + if (parent == null) + break; + directory = parent.FullName; + } + return new GlobalJsonInfo(); + } + + public string? GetLatestInstalledAdminVersion() + { + // TODO: Implement this + return null; + } + + public void InstallSdks(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) + { + foreach (var channelVersion in sdkVersions) + { + InstallSDK(dotnetRoot, progressContext, new UpdateChannel(channelVersion)); + } + } + + private void InstallSDK(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, UpdateChannel channnel) + { + DotnetInstallRequest request = new DotnetInstallRequest( + dotnetRoot, + channnel, + InstallComponent.SDK, + new InstallRequestOptions() + ); + + DotnetInstall? newInstall = InstallerOrchestratorSingleton.Instance.Install(request); + if (newInstall == null) + { + throw new Exception($"Failed to install .NET SDK {channnel.Name}"); + } + else + { + Spectre.Console.AnsiConsole.MarkupLine($"[green]Installed .NET SDK {newInstall.Version}, available via {newInstall.InstallRoot}[/]"); + } + } + + 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) + { + // Get current PATH + var path = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; + var pathEntries = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).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) + { + case InstallType.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 InstallType.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 InstallType.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); + } +} 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/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs new file mode 100644 index 000000000000..4617d65eabe8 --- /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 DotnetInstallRoot GetConfiguredInstallType() + { + var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); + InstallType installtype = InstallType.None; + if (!Enum.TryParse(testHookDefaultInstall, out installtype)) + { + installtype = InstallType.None; + } + var installPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); + return new(installPath, installtype, DnupUtilities.GetDefaultInstallArchitecture()); + } + + 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(DotnetInstallRoot 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 new file mode 100644 index 000000000000..888f0f65e689 --- /dev/null +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -0,0 +1,273 @@ +// 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 System.Net.Http; +using Microsoft.Deployment.DotNet.Releases; +using Spectre.Console; + + +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; + +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); + + private readonly IBootstrapperController _dotnetInstaller = new BootstrapperController(); + private readonly IReleaseInfoProvider _releaseInfoProvider = new EnvironmentVariableMockReleaseInfoProvider(); + private readonly ManifestChannelVersionResolver _channelVersionResolver = new ManifestChannelVersionResolver(); + + public override int Execute() + { + var globalJsonInfo = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory); + + var currentDotnetInstallRoot = _dotnetInstaller.GetConfiguredInstallType(); + + string? resolvedInstallPath = null; + + string? installPathFromGlobalJson = null; + if (globalJsonInfo?.GlobalJsonPath != null) + { + installPathFromGlobalJson = globalJsonInfo.SdkPath; + + if (installPathFromGlobalJson != null && _installPath != null && + !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})."); + return 1; + } + + resolvedInstallPath = installPathFromGlobalJson; + } + + if (resolvedInstallPath == null) + { + resolvedInstallPath = _installPath; + } + + if (resolvedInstallPath == null && currentDotnetInstallRoot.Type == InstallType.User) + { + // If a user installation is already set up, we don't need to prompt for the install path + resolvedInstallPath = currentDotnetInstallRoot.Path; + } + + if (resolvedInstallPath == null) + { + if (_interactive) + { + resolvedInstallPath = SpectreAnsiConsole.Prompt( + new TextPrompt("Where should we install the .NET SDK to?)") + .DefaultValue(_dotnetInstaller.GetDefaultDotnetInstallPath())); + } + else + { + // If no install path is specified, use the default install path + resolvedInstallPath = _dotnetInstaller.GetDefaultDotnetInstallPath(); + } + } + + string? channelFromGlobalJson = null; + if (globalJsonInfo?.GlobalJsonPath != null) + { + channelFromGlobalJson = ResolveChannelFromGlobalJson(globalJsonInfo.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) + { + SpectreAnsiConsole.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonInfo?.GlobalJsonPath} specifies that version."); + + resolvedChannel = channelFromGlobalJson; + } + else if (_versionOrChannel != null) + { + resolvedChannel = _versionOrChannel; + } + else + { + if (_interactive) + { + + 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( + 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 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 (currentDotnetInstallRoot.Type == 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 (currentDotnetInstallRoot.Type == InstallType.User) + { + if (DnupUtilities.PathsEqual(resolvedInstallPath, currentDotnetInstallRoot.Path)) + { + // No need to prompt here, the default install is already set up. + } + else + { + resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( + $"The default dotnet install is currently set to {currentDotnetInstallRoot.Path}. Do you want to change it to {resolvedInstallPath}?", + defaultValue: false); + } + } + else if (currentDotnetInstallRoot.Type == InstallType.Admin) + { + SpectreAnsiConsole.WriteLine($"You have an existing admin install of .NET in {currentDotnetInstallRoot.Path}. 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."); + SpectreAnsiConsole.WriteLine("You can change this later with the \"dotnet defaultinstall\" command."); + resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( + $"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 (currentDotnetInstallRoot.Type == InstallType.Inconsistent) + { + // TODO: Figure out what to do here + resolvedSetDefaultInstall = false; + } + } + else + { + resolvedSetDefaultInstall = false; // Default to not setting the default install path if not specified + } + } + + List additionalVersionsToInstall = new(); + + // Create a request and resolve it using the channel version resolver + var installRequest = new DotnetInstallRequest( + new DotnetInstallRoot(resolvedInstallPath, InstallType.User, DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture)), + new UpdateChannel(resolvedChannel), + InstallComponent.SDK, + new InstallRequestOptions()); + + var resolvedVersion = _channelVersionResolver.Resolve(installRequest); + + if (resolvedSetDefaultInstall == true && currentDotnetInstallRoot.Type == InstallType.Admin) + { + if (_interactive) + { + var latestAdminVersion = _dotnetInstaller.GetLatestInstalledAdminVersion(); + if (latestAdminVersion != null && resolvedVersion < 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-line option for installing admin versions locally + } + } + + // TODO: Implement transaction / rollback? + + SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedVersion}[/] to [blue]{resolvedInstallPath}[/]..."); + + // 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 {resolvedVersion}[/]"); + return 1; + } + SpectreAnsiConsole.MarkupLine($"[green]Installed .NET SDK {mainInstall.Version}, available via {mainInstall.InstallRoot}[/]"); + + // Install any additional versions + foreach (var additionalVersion in additionalVersionsToInstall) + { + // Create the request for the additional version + var additionalRequest = new DotnetInstallRequest( + new DotnetInstallRoot(resolvedInstallPath, InstallType.User, DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture)), + new UpdateChannel(additionalVersion), + InstallComponent.SDK, + 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.Version}, available via {additionalInstall.InstallRoot}[/]"); + } + } + + if (resolvedSetDefaultInstall == true) + { + _dotnetInstaller.ConfigureInstallType(InstallType.User, resolvedInstallPath); + } + + if (resolvedUpdateGlobalJson == true) + { + _dotnetInstaller.UpdateGlobalJson(globalJsonInfo!.GlobalJsonPath!, resolvedVersion!.ToString(), globalJsonInfo.AllowPrerelease, globalJsonInfo.RollForward); + } + + + SpectreAnsiConsole.WriteLine($"Complete!"); + + + return 0; + } + + + + string? ResolveChannelFromGlobalJson(string globalJsonPath) + { + //return null; + //return "9.0"; + return Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_CHANNEL"); + } + +} diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs new file mode 100644 index 000000000000..9c28335cc124 --- /dev/null +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -0,0 +1,74 @@ +// 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; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; + +internal static class SdkInstallCommandParser +{ + + + 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.", + Arity = ArgumentArity.ZeroOrOne, + }; + + public static readonly Option InstallPathOption = new("--install-path") + { + HelpName = "INSTALL_PATH", + Description = "The path to install the .NET SDK to", + }; + + public static readonly Option SetDefaultInstallOption = new("--set-default-install") + { + 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; + + private static readonly Command SdkInstallCommand = ConstructCommand(); + + public static Command GetSdkInstallCommand() + { + return SdkInstallCommand; + } + + // 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(); + + public static Command GetRootInstallCommand() + { + return RootInstallCommand; + } + + private static Command ConstructCommand() + { + Command command = new("install", "Installs the .NET SDK"); + + command.Arguments.Add(ChannelArgument); + + command.Options.Add(InstallPathOption); + command.Options.Add(SetDefaultInstallOption); + command.Options.Add(UpdateGlobalJsonOption); + + command.Options.Add(InteractiveOption); + + command.SetAction(parseResult => new SdkInstallCommand(parseResult).Execute()); + + 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..efd9de75d60b --- /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; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Update; + +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", "Manage sdk installations"); + command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); + command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); + + //command.SetAction((parseResult) => parseResult.HandleMissingCommand()); + + return command; + } + } +} diff --git a/src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs b/src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs new file mode 100644 index 000000000000..f419e0a28cf3 --- /dev/null +++ b/src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Bootstrapper.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 applicable global.json files to the updated SDK version", + Arity = ArgumentArity.Zero + }; + + public static readonly Option InteractiveOption = CommonOptions.InteractiveOption; + + private static readonly Command SdkUpdateCommand = ConstructCommand(); + + public static Command GetSdkUpdateCommand() + { + return SdkUpdateCommand; + } + + // 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(); + + 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.Options.Add(InteractiveOption); + + command.SetAction(parseResult => 0); + + return command; + } +} diff --git a/src/Installer/dnup/CommonOptions.cs b/src/Installer/dnup/CommonOptions.cs new file mode 100644 index 000000000000..c643593ed8d6 --- /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 = Strings.CommandInteractiveOptionDescription, + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = _ => !IsCIEnvironmentOrRedirected() + }; + + + private static bool IsCIEnvironmentOrRedirected() => + new Cli.Telemetry.CIEnvironmentDetectorForTelemetry().IsCIEnvironment() || Console.IsOutputRedirected; +} 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/DnupManifestJsonContext.cs b/src/Installer/dnup/DnupManifestJsonContext.cs new file mode 100644 index 000000000000..2abd4eb49b68 --- /dev/null +++ b/src/Installer/dnup/DnupManifestJsonContext.cs @@ -0,0 +1,19 @@ +// 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, + 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/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs new file mode 100644 index 000000000000..a1b11a869bfa --- /dev/null +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -0,0 +1,109 @@ +// 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 +{ + 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() + { + 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); + } + } + + /// + /// Gets installed versions filtered by a specific muxer directory. + /// + /// Directory to filter by (must match the InstallRoot property) + /// Optional validator to check installation validity + /// Installations that match the specified directory + public IEnumerable GetInstalledVersions(DotnetInstallRoot installRoot, IInstallationValidator? validator = null) + { + return GetInstalledVersions(validator) + .Where(install => DnupUtilities.PathsEqual( + Path.GetFullPath(install.InstallRoot.Path!), + Path.GetFullPath(installRoot.Path!))); + } + + 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 => DnupUtilities.PathsEqual(i.InstallRoot.Path, version.InstallRoot.Path) && i.Version.Equals(version.Version)); + var json = JsonSerializer.Serialize(installs, DnupManifestJsonContext.Default.ListDotnetInstall); + File.WriteAllText(ManifestPath, json); + } +} diff --git a/src/Installer/dnup/DnupUtilities.cs b/src/Installer/dnup/DnupUtilities.cs new file mode 100644 index 000000000000..5ef1738bb4a3 --- /dev/null +++ b/src/Installer/dnup/DnupUtilities.cs @@ -0,0 +1,96 @@ +// 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; +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 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 + { + 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.") + }; + } + + public static InstallArchitecture GetDefaultInstallArchitecture() + { + return GetInstallArchitecture(RuntimeInformation.ProcessArchitecture); + } + + 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 + }; + } + + 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 GetArchiveFileExtensionForPlatform() + { + 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 new file mode 100644 index 000000000000..470b73f530b8 --- /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; +using Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public record DotnetInstallRoot( + string? Path, + InstallType Type, + InstallArchitecture Architecture) +{ + // Do we need a GUID for the ID 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. +/// +public record DotnetInstall( + DotnetInstallRoot InstallRoot, + ReleaseVersion Version, + InstallComponent Component); + +/// +/// Represents a request for a .NET installation with a channel version that will get resolved into a fully specified version. +/// +public record DotnetInstallRequest( + DotnetInstallRoot InstallRoot, + UpdateChannel Channel, + InstallComponent Component, + InstallRequestOptions Options); + +public record InstallRequestOptions() +{ + // Include things such as the custom feed here. +} diff --git a/src/Installer/dnup/DotnetVersion.cs b/src/Installer/dnup/DotnetVersion.cs new file mode 100644 index 000000000000..cdc6cb864021 --- /dev/null +++ b/src/Installer/dnup/DotnetVersion.cs @@ -0,0 +1,430 @@ +// Licensed to the .NET Foundation under one or more agreements. +// 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; + +/// +/// Represents the type of .NET version (SDK or Runtime). +/// +public 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})")] +[JsonConverter(typeof(DotnetVersionJsonConverter))] +public readonly record struct DotnetVersion : IComparable, IComparable, IEquatable +{ + 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 ?? ParseMajorDirect(); + + /// Gets the minor version component (e.g., "0" from "8.0.301"). + public int Minor => _releaseVersion?.Minor ?? ParseMinorDirect(); + + /// 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, 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 || + (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 = !string.IsNullOrEmpty(Value) && 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 + + // 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; + } + + /// + /// 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 + + // For SDK versions, patch is the last two digits + if (patchPart.Length < 3) return null; + + 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; + + 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; + } + + /// + /// 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}", ""); + } + + 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. + /// + 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; + } + + /// + /// 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; + 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; + + 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; + } + + /// + /// 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 +} 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/GlobalJsonContents.cs b/src/Installer/dnup/GlobalJsonContents.cs new file mode 100644 index 000000000000..d55b7af24c23 --- /dev/null +++ b/src/Installer/dnup/GlobalJsonContents.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public class GlobalJsonContents +{ + 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(GlobalJsonContents))] +public partial class GlobalJsonContentsJsonContext : JsonSerializerContext +{ +} diff --git a/src/Installer/dnup/IBootstrapperController.cs b/src/Installer/dnup/IBootstrapperController.cs new file mode 100644 index 000000000000..be4d7760352b --- /dev/null +++ b/src/Installer/dnup/IBootstrapperController.cs @@ -0,0 +1,46 @@ +// 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 Spectre.Console; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public interface IBootstrapperController +{ + GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory); + + string GetDefaultDotnetInstallPath(); + + DotnetInstallRoot GetConfiguredInstallType(); + + string? GetLatestInstalledAdminVersion(); + + void InstallSdks(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions); + + void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null); + + void ConfigureInstallType(InstallType installType, string? dotnetRoot = null); + + +} + +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 +{ + List GetAvailableChannels(); + string GetLatestVersion(string channel); +} 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 new file mode 100644 index 000000000000..4aa56b20dcb6 --- /dev/null +++ b/src/Installer/dnup/IDnupManifest.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 Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal interface IDnupManifest + { + IEnumerable GetInstalledVersions(IInstallationValidator? validator = null); + IEnumerable GetInstalledVersions(DotnetInstallRoot installRoot, IInstallationValidator? validator = null); + void AddInstalledVersion(DotnetInstall version); + void RemoveInstalledVersion(DotnetInstall version); + } +} 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/InstallArchitecture.cs b/src/Installer/dnup/InstallArchitecture.cs new file mode 100644 index 000000000000..a046bf3d1721 --- /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 +{ + public enum InstallArchitecture + { + x86, + x64, + arm64 + } +} diff --git a/src/Installer/dnup/InstallComponent.cs b/src/Installer/dnup/InstallComponent.cs new file mode 100644 index 000000000000..a65f29c987b5 --- /dev/null +++ b/src/Installer/dnup/InstallComponent.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 InstallComponent + { + 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 + } +} diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs new file mode 100644 index 000000000000..f17a6d64bc97 --- /dev/null +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -0,0 +1,102 @@ +// 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; +using Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class InstallerOrchestratorSingleton +{ + private static readonly InstallerOrchestratorSingleton _instance = new(); + + private InstallerOrchestratorSingleton() + { + } + + public static InstallerOrchestratorSingleton Instance => _instance; + + private ScopedMutex modifyInstallStateMutex() => new ScopedMutex(Constants.MutexNames.ModifyInstallationStates); + + // Returns null on failure, DotnetInstall on success + public DotnetInstall? Install(DotnetInstallRequest installRequest) + { + // Map InstallRequest to DotnetInstallObject by converting channel to fully specified version + ReleaseVersion? versionToInstall = new ManifestChannelVersionResolver().Resolve(installRequest); + + if (versionToInstall == null) + { + Console.WriteLine($"\nCould not resolve version for channel '{installRequest.Channel.Name}'."); + return null; + } + + DotnetInstall install = new( + installRequest.InstallRoot, + versionToInstall, + installRequest.Component); + + // 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(install)) + { + Console.WriteLine($"\n.NET SDK {versionToInstall} is already installed, skipping installation."); + return install; + } + } + + using ArchiveDotnetInstaller installer = new(installRequest, versionToInstall); + installer.Prepare(); + + // Extract and commit the install to the directory + using (var finalizeLock = modifyInstallStateMutex()) + { + if (InstallAlreadyExists(install)) + { + return install; + } + + installer.Commit(); + + ArchiveInstallationValidator validator = new(); + if (validator.Validate(install)) + { + DnupSharedManifest manifestManager = new(); + manifestManager.AddInstalledVersion(install); + } + else + { + return null; + } + } + + return install; + } + + /// + /// Gets the existing installs from the manifest. Must hold a mutex over the directory. + /// + private IEnumerable GetExistingInstalls(DotnetInstallRoot installRoot) + { + var manifestManager = new DnupSharedManifest(); + // Use the overload that filters by muxer directory + return manifestManager.GetInstalledVersions(installRoot); + } + + /// + /// Checks if the installation already exists. Must hold a mutex over the directory. + /// + private bool InstallAlreadyExists(DotnetInstall install) + { + var existingInstalls = GetExistingInstalls(install.InstallRoot); + + // Check if there's any existing installation that matches the version we're trying to install + return existingInstalls.Any(existing => + existing.Version.Equals(install.Version) && + existing.Component == install.Component); + } +} 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 new file mode 100644 index 000000000000..506bed9cd416 --- /dev/null +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -0,0 +1,24 @@ +// 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; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class ManifestChannelVersionResolver +{ + public ReleaseVersion? Resolve(DotnetInstallRequest installRequest) + { + // If not fully specified, resolve to latest using ReleaseManifest + if (!installRequest.Channel.IsFullySpecifiedVersion()) + { + var manifest = new ReleaseManifest(); + return manifest.GetLatestVersionForChannel(installRequest.Channel, installRequest.Component); + } + + return new ReleaseVersion(installRequest.Channel.Name); + } +} diff --git a/src/Installer/dnup/Parser.cs b/src/Installer/dnup/Parser.cs new file mode 100644 index 000000000000..7032270df288 --- /dev/null +++ b/src/Installer/dnup/Parser.cs @@ -0,0 +1,45 @@ +// 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; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Update; + +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()); + rootCommand.Subcommands.Add(SdkInstallCommandParser.GetRootInstallCommand()); + rootCommand.Subcommands.Add(SdkUpdateCommandParser.GetRootUpdateCommand()); + + return rootCommand; + } + } +} diff --git a/src/Installer/dnup/Program.cs b/src/Installer/dnup/Program.cs new file mode 100644 index 000000000000..ee656bfa6003 --- /dev/null +++ b/src/Installer/dnup/Program.cs @@ -0,0 +1,14 @@ + +using Microsoft.DotNet.Tools.Bootstrapper; + +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/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs new file mode 100644 index 000000000000..4909cadb65cf --- /dev/null +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -0,0 +1,1030 @@ +// 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 +{ + /// + /// Parses a version channel string into its components. + /// + /// 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(UpdateChannel channel) + { + var parts = channel.Name.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; + + // 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 _)) + { + // Fully specified version (e.g., "9.0.103") + isFullySpecified = true; + } + } + + return (major, minor, featureBand, isFullySpecified); + } + + /// + /// 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 => + { + var productParts = p.ProductVersion.Split('.'); + if (productParts.Length > 0 && int.TryParse(productParts[0], out var productMajor)) + { + return productMajor == major; + } + return false; + }).ToList(); + + // 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)) + { + 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 + /// Optional minor version filter + /// Latest SDK version string, or null if none found + private ReleaseVersion? 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; + } + + return null; + } + + /// + /// 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 ReleaseVersion? 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(); + } + + if (minorFilter.HasValue) + { + allRuntimes = allRuntimes.Where(r => r.Version.Minor == minorFilter.Value).ToList(); + } + + // 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)) + { + 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; + } + + 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 ReleaseVersion? 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; + } + + // Fallback if no actual release matches the feature band pattern + return null; + } + + /// + /// 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", "preview") + /// InstallMode.SDK or InstallMode.Runtime + /// Latest fully specified version string, or null if not found + public ReleaseVersion? GetLatestVersionForChannel(UpdateChannel channel, InstallComponent component) + { + // Check for special channel strings (case insensitive) + if (string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase)) + { + // Handle LTS (Long-Term Support) channel + var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return GetLatestVersionBySupportStatus(productIndex, isLts: true, component); + } + else if (string.Equals(channel.Name, "sts", StringComparison.OrdinalIgnoreCase)) + { + // Handle STS (Standard-Term Support) channel + var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return GetLatestVersionBySupportStatus(productIndex, isLts: false, component); + } + else if (string.Equals(channel.Name, "preview", StringComparison.OrdinalIgnoreCase)) + { + // Handle Preview channel - get the latest preview version + var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return GetLatestPreviewVersion(productIndex, component); + } // 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 new ReleaseVersion(channel.Name); + } + + // 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, component); + } + + // Case 2: Major.Minor version (e.g., "9.0") + if (minor >= 0 && featureBand == null) + { + return GetLatestVersionForMajorMinor(index, major, minor, component); + } + + // Case 3: Feature band version (e.g., "9.0.1xx") + if (minor >= 0 && featureBand != null) + { + return GetLatestVersionForFeatureBand(index, major, minor, featureBand, component); + } + + return null; + } + + /// + /// Gets the latest version for a major-only channel (e.g., "9"). + /// + private ReleaseVersion? GetLatestVersionForMajorOnly(ProductCollection index, int major, InstallComponent component) + { + // 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 (component == InstallComponent.SDK) + { + return GetLatestSdkVersion(allReleases, major); + } + else // Runtime mode + { + return GetLatestRuntimeVersion(allReleases, major); + } + } + + /// + /// 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) + /// InstallComponent.SDK or InstallComponent.Runtime + /// Latest stable version string matching the support status, or null if none found + private ReleaseVersion? GetLatestVersionBySupportStatus(ProductCollection index, bool isLts, InstallComponent component) + { + // Get all products + var allProducts = index.ToList(); + + // 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('.'); + 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 (component == InstallComponent.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; + } + } + 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; + } + } + } + + return null; // No matching versions found + } + + /// + /// Gets the latest preview version available. + /// + /// The product collection to search + /// InstallComponent.SDK or InstallComponent.Runtime + /// Latest preview version string, or null if none found + private ReleaseVersion? GetLatestPreviewVersion(ProductCollection index, InstallComponent component) + { + // 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 (component == InstallComponent.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; + } + } + 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; + } + } + } + + return null; // No preview versions found + } /// + /// Gets the latest version for a major.minor channel (e.g., "9.0"). + /// + private ReleaseVersion? GetLatestVersionForMajorMinor(ProductCollection index, int major, int minor, InstallComponent component) + { + // 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(); + + // Find the latest version based on mode + if (component == InstallComponent.SDK) + { + return GetLatestSdkVersion(releases, major, minor); + } + else // Runtime mode + { + return GetLatestRuntimeVersion(releases, major, minor); + } + } + + /// + /// Gets the latest version for a feature band channel (e.g., "9.0.1xx"). + /// + private ReleaseVersion? GetLatestVersionForFeatureBand(ProductCollection index, int major, int minor, string featureBand, InstallComponent component) + { + // 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(); + + // For SDK mode, use feature band filtering + if (component == InstallComponent.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; + 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(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) + { + var targetFile = FindReleaseFile(installRequest, resolvedVersion); + 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(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion, string destinationPath, IProgress? progress = null) + { + // Get the download URL and expected hash + string? downloadUrl = GetDownloadUrl(installRequest, resolvedVersion); + if (string.IsNullOrEmpty(downloadUrl)) + { + return false; + } + + string? expectedHash = GetArchiveHash(installRequest, resolvedVersion); + 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(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) + { + try + { + var productCollection = GetProductCollection(); + var product = FindProduct(productCollection, resolvedVersion) ?? throw new InvalidOperationException($"No product found for version {resolvedVersion}"); + var release = FindRelease(product, resolvedVersion, installRequest.Component) ?? throw new InvalidOperationException($"No release found for version {resolvedVersion}"); + return FindMatchingFile(release, installRequest, resolvedVersion); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to find an available release for install {installRequest} : ${ex.Message}", ex); + } + } + + /// + /// 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); + + // Always use the index manifest for ProductCollection + for (int attempt = 1; attempt <= MaxRetryCount; attempt++) + { + try + { + _productCollection = ProductCollection.GetAsync().GetAwaiter().GetResult(); + 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, ReleaseVersion releaseVersion) + { + 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, ReleaseVersion resolvedVersion, InstallComponent component) + { + var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + + // 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(resolvedVersion)); + 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 (component == InstallComponent.SDK) + { + foreach (var sdk in release.Sdks) + { + // Check for exact match + if (sdk.Version.Equals(resolvedVersion)) + { + foundMatch = true; + break; + } + + // Not sure what the point of the below logic was + //// 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 + { + // 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(resolvedVersion)) + { + 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; + } + + /// + /// Finds the matching file in the release for the given installation requirements. + /// + private static ReleaseFile? FindMatchingFile(ProductRelease release, DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) + { + var rid = DnupUtilities.GetRuntimeIdentifier(installRequest.InstallRoot.Architecture); + var fileExtension = DnupUtilities.GetArchiveFileExtensionForPlatform(); + + // Determine the component type pattern to look for in file names + string componentTypePattern; + if (installRequest.Component == InstallComponent.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 (installRequest.Component == InstallComponent.ASPNETCore) + { + componentTypePattern = "aspnetcore"; + } + // Check if this is specifically a Windows Desktop runtime + else if (installRequest.Component == InstallComponent.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(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 = resolvedVersion.ToString(); + 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(); + } + + /// + /// 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(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) + { + var targetFile = FindReleaseFile(installRequest, resolvedVersion); + 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; + } +} 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(); + } +} 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"; + } + } +} 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/UpdateChannel.cs b/src/Installer/dnup/UpdateChannel.cs new file mode 100644 index 000000000000..fc25144ed5b0 --- /dev/null +++ b/src/Installer/dnup/UpdateChannel.cs @@ -0,0 +1,26 @@ +// 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 Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + public class UpdateChannel + { + public string Name { get; set; } + + public UpdateChannel(string name) + { + Name = name; + } + + public bool IsFullySpecifiedVersion() + { + return ReleaseVersion.TryParse(Name, out _); + } + + } +} diff --git a/src/Installer/dnup/dnup.code-workspace b/src/Installer/dnup/dnup.code-workspace new file mode 100644 index 000000000000..6e8de4bb18ae --- /dev/null +++ b/src/Installer/dnup/dnup.code-workspace @@ -0,0 +1,78 @@ +{ + "folders": [ + { + "path": ".", + "name": "dnup" + }, + { + "path": "../../../test/dnup.Tests", + "name": "dnup.Tests" + } + ], + "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", + "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/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj new file mode 100644 index 000000000000..3d343aae531e --- /dev/null +++ b/src/Installer/dnup/dnup.csproj @@ -0,0 +1,40 @@ + + + + Exe + net10.0 + enable + enable + true + + + $(NoWarn);CS8002 + + + + Microsoft.DotNet.Tools.Bootstrapper + + + + + + + + + + + + + + + + + + + + + + + + + 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/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 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": {} +} 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/ReleaseManifestTests.cs b/test/dnup.Tests/ReleaseManifestTests.cs new file mode 100644 index 000000000000..81e88633c8dd --- /dev/null +++ b/test/dnup.Tests/ReleaseManifestTests.cs @@ -0,0 +1,107 @@ +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); + } + + [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); + } + + [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" + ); + } + } +} 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) + + + + + + + +