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)
+
+
+
+
+
+
+
+