diff --git a/.vsts-dnup-ci.yml b/.vsts-dnup-ci.yml
new file mode 100644
index 000000000000..f0523037a8d6
--- /dev/null
+++ b/.vsts-dnup-ci.yml
@@ -0,0 +1,130 @@
+# Pipeline: https://dev.azure.com/dnceng/internal/_build?definitionId=
+
+trigger:
+ batch: true
+ branches:
+ include:
+ - dnup
+ - release/dnup
+
+pr:
+ branches:
+ include:
+ - dnup
+ - release/dnup
+
+parameters:
+# When true, runs the pipeline in the same way as the PR pipeline.
+- name: runTestBuild
+ displayName: Run A Test Build
+ type: boolean
+ default: false
+- name: enableArm64Job
+ displayName: Enables the ARM64 job
+ type: boolean
+ default: false
+
+variables:
+- template: /eng/pipelines/templates/variables/sdk-defaults.yml
+# Variables used: DncEngInternalBuildPool
+- template: /eng/common/templates-official/variables/pool-providers.yml
+# Helix testing requires a token when internally run.
+# Variables used: HelixApiAccessToken
+- group: DotNet-HelixApi-Access
+- group: AzureDevOps-Artifact-Feeds-Pats
+# Allows Arcade to run a signed build by disabling post-build signing for release-branch builds or manual builds that are not running tests.
+- ${{ if and(eq(parameters.runTestBuild, false), or(startswith(variables['Build.SourceBranch'], 'refs/heads/release/'), startswith(variables['Build.SourceBranch'], 'refs/heads/internal/release/'), eq(variables['Build.Reason'], 'Manual'))) }}:
+ - name: PostBuildSign
+ value: false
+# Provides TSA variables for automatic bug reporting.
+- ${{ if ne(variables['Build.Reason'], 'PullRequest') }}:
+ - group: DotNet-CLI-SDLValidation-Params
+### LOCAL ONLY ###
+- name: _publishArgument
+ value: -publish
+- name: _signArgument
+ value: -sign /p:SignCoreSdk=true
+- name: _officialBuildProperties
+ # The OfficialBuilder property is set to Microsoft for the official build only.
+ # This property is checked in Directory.Build.props and adds the MICROSOFT_ENABLE_TELEMETRY constant.
+ # This constant is used in CompileOptions.cs to set both TelemetryOptOutDefault and TelemetryOptOutDefaultString.
+ value: /p:DotNetPublishUsingPipelines=true /p:OfficialBuilder=Microsoft /p:OfficialBuildId=$(Build.BuildNumber)
+
+resources:
+ repositories:
+ - repository: 1esPipelines
+ type: git
+ name: 1ESPipelineTemplates/1ESPipelineTemplates
+ ref: refs/tags/release
+
+extends:
+ ${{ if ne(variables['Build.Reason'], 'PullRequest') }}:
+ template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines
+ ${{ else }}:
+ template: v1/1ES.Unofficial.PipelineTemplate.yml@1esPipelines
+ parameters:
+ containers:
+ azureLinux30Amd64:
+ image: mcr.microsoft.com/dotnet-buildtools/prereqs:azurelinux-3.0-net10.0-build-amd64
+
+ sdl:
+ sourceAnalysisPool:
+ name: $(DncEngInternalBuildPool)
+ image: 1es-windows-2022
+ os: windows
+ policheck:
+ enabled: true
+ tsa:
+ enabled: true
+ binskim:
+ enabled: true
+ ${{ if or(eq(parameters.runTestBuild, true), eq(variables['Build.Reason'], 'PullRequest')) }}:
+ componentgovernance:
+ # Refdoc: https://docs.opensource.microsoft.com/tools/cg/component-detection/variables/
+ ignoreDirectories: artifacts, .packages
+
+ stages:
+ ############### BUILD STAGE ###############
+ ############### WINDOWS ###############
+ - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self
+ parameters:
+ pool:
+ name: $($(DncEngInternalBuildPool))
+ image: windows.vs2022.amd64
+ os: windows
+ emoji: 🪟
+ helixTargetQueue: windows.amd64.vs2022.pre
+ oneESCompat:
+ templateFolderName: templates-official
+ publishTaskPrefix: 1ES.
+ runtimeSourceProperties: /p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64)
+ # WORKAROUND: BinSkim requires the folder exist prior to scanning.
+ preSteps:
+ - powershell: New-Item -ItemType Directory -Path $(Build.SourcesDirectory)/artifacts/bin -Force
+ displayName: Create artifacts/bin directory
+ ${{ if and(eq(parameters.runTestBuild, false), ne(variables['Build.Reason'], 'PullRequest')) }}:
+ timeoutInMinutes: 180
+ windowsJobParameterSets:
+ ### OFFICIAL ###
+ - categoryName: Official
+ publishArgument: $(_publishArgument)
+ signArgument: $(_signArgument)
+ officialBuildProperties: $(_officialBuildProperties) /p:BuildWorkloads=true
+ enableDefaultArtifacts: true
+ runTests: false
+ publishRetryConfig: true
+ variables:
+ _SignType: real
+
+ ############### PACKAGE STAGE ###############
+ - ${{ if ne(variables['Build.Reason'], 'PullRequest') }}:
+ - stage: publish
+ displayName: Publish
+ dependsOn: []
+ jobs:
+ - template: /eng/pipelines/templates/jobs/dnup-library-package.yml@self
+ parameters:
+ pool:
+ name: $(DncEngInternalBuildPool)
+ image: 1es-windows-2022
+ os: windows
diff --git a/.vsts-dnup-pr.yml b/.vsts-dnup-pr.yml
new file mode 100644
index 000000000000..7b58f5f735c0
--- /dev/null
+++ b/.vsts-dnup-pr.yml
@@ -0,0 +1,72 @@
+# Pipeline: https://dev.azure.com/dnceng-public/public/_build?definitionId=323
+
+trigger: none
+
+pr:
+ branches:
+ include:
+ - dnup
+ paths:
+ include:
+ - src/Installer/dnup/
+ - test/dnup.Tests/
+ - global.json
+ - .vsts-dnup-tests.yml
+
+parameters:
+- name: enableArm64Job
+ displayName: Enables the ARM64 job
+ type: boolean
+ default: true
+
+variables:
+- template: /eng/pipelines/templates/variables/sdk-defaults.yml
+ # Variables used: DncEngPublicBuildPool
+- template: /eng/common/templates/variables/pool-providers.yml
+
+stages:
+- stage: dnup
+ displayName: 🏰 dnup tests
+ jobs:
+ ############### WINDOWS ###############
+ - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self
+ parameters:
+ pool:
+ name: $(DncEngPublicBuildPool)
+ demands: ImageOverride -equals windows.vs2022.amd64.open
+ os: windows
+ emoji: 🪟
+ helixTargetQueue: windows.amd64.vs2022.pre.open
+
+ ############### LINUX ###############
+ - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self
+ parameters:
+ pool:
+ name: $(DncEngPublicBuildPool)
+ demands: ImageOverride -equals build.ubuntu.2204.amd64.open
+ os: linux
+ emoji: 🐧
+ helixTargetQueue: ubuntu.2204.amd64.open
+
+ ############### MACOS ###############
+ - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self
+ parameters:
+ pool:
+ name: Azure Pipelines
+ vmImage: macOS-latest
+ os: macOS
+ emoji: 🍎
+ helixTargetQueue: osx.15.amd64.open
+
+ ### ARM64 ###
+ - ${{ if eq(parameters.enableArm64Job, true) }}:
+ - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self
+ parameters:
+ pool:
+ name: Azure Pipelines
+ vmImage: macOS-latest
+ os: macOS
+ emoji: 💪
+ helixTargetQueue: osx.13.arm64.open
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 788923bf48d6..2fc29d442726 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -114,6 +114,7 @@
+
diff --git a/dnup.slnf b/dnup.slnf
new file mode 100644
index 000000000000..8384b13f95f0
--- /dev/null
+++ b/dnup.slnf
@@ -0,0 +1,11 @@
+{
+ "solution": {
+ "path": "sdk.slnx",
+ "projects": [
+ "src\\Installer\\dnup\\dnup.csproj",
+ "src\\Installer\\Microsoft.Dotnet.Installation\\Microsoft.Dotnet.Installation.csproj",
+ "test\\dnup.Tests\\dnup.Tests.csproj",
+ "src\\Resolvers\\Microsoft.DotNet.NativeWrapper\\Microsoft.DotNet.NativeWrapper.csproj
+ ]
+ }
+}
diff --git a/eng/pipelines/templates/jobs/dnup-library-package.yml b/eng/pipelines/templates/jobs/dnup-library-package.yml
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml
new file mode 100644
index 000000000000..0477def2d581
--- /dev/null
+++ b/eng/pipelines/templates/jobs/dnup-tests.yml
@@ -0,0 +1,88 @@
+parameters:
+ ### GENERAL ###
+ variables: {}
+ dependsOn: ''
+ helixTargetQueue: ''
+ oneESCompat:
+ templateFolderName: templates
+ publishTaskPrefix: ''
+ container: ''
+ helixTargetContainer: ''
+ categoryName: dnup
+ runTests: true
+ publishRetryConfig: false
+ publishXunitResults: false
+ enableSbom: true
+ timeoutInMinutes: 150
+
+jobs:
+- template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/job/job.yml
+ parameters:
+ displayName: '${{ parameters.pool.emoji }} dnup tests: ${{ parameters.pool.os }} (${{ parameters.helixTargetQueue }})'
+ pool: ${{ parameters.pool }}
+ container: ${{ parameters.container }}
+ strategy: ${{ parameters.strategy }}
+ helixRepo: dotnet/sdk
+ timeoutInMinutes: ${{ parameters.timeoutInMinutes }}
+ enableMicrobuild: true
+ enablePublishBuildAssets: true
+ enableTelemetry: true
+ enablePublishUsingPipelines: true
+ enableSbom: ${{ parameters.enableSbom }}
+ variables:
+ - ${{ insert }}: ${{ parameters.variables }}
+ dependsOn: ${{ parameters.dependsOn }}
+ preSteps: ${{ parameters.preSteps }}
+ templateContext:
+ sdl:
+ binskim:
+ analyzeTargetGlob: +:f|eng\**\*.props;+:f|artifacts\bin\**\*.dll;+:f|artifacts\bin\**\*.exe;-:f|artifacts\bin\**\msdia140.dll;-:f|artifacts\bin\**\pgort140.dll;-:f|artifacts\bin\*Tests\**;-:f|**\Microsoft.NET.Runtime.Emscripten**\tools\**;-:f|**\CodeCoverage\**;-:f|artifacts\bin\**\capstone.dll;
+
+ steps:
+ - ${{ if eq(parameters.pool.os, 'windows') }}:
+ - powershell: |
+ & .\restore.cmd
+ displayName: 🍱 Bootstrap toolset (Windows)
+ - powershell: |
+ & .\.dotnet\dotnet restore test\dnup.Tests\dnup.Tests.csproj
+ displayName: ♻️ Restore dnup tests (Windows)
+ - powershell: |
+ & .\.dotnet\dotnet build test\dnup.Tests\dnup.Tests.csproj -c Release --no-restore
+ displayName: 💻 Build Windows
+ - powershell: |
+ New-Item -Path "$(Build.SourcesDirectory)/artifacts/dnupTestResults" -ItemType Directory -Force
+ displayName: 📁 Create test results directory (Windows)
+ - powershell: |
+ & .\.dotnet\dotnet test test\dnup.Tests\dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults
+ displayName: 🔍 Test Windows
+ - ${{ if ne(parameters.pool.os, 'windows') }}:
+ - script: |
+ ./restore.sh
+ displayName: 🍱 Bootstrap toolset (Unix)
+ - script: |
+ ./.dotnet/dotnet restore test/dnup.Tests/dnup.Tests.csproj
+ displayName: ♻️ Restore dnup tests (Unix)
+ - script: |
+ ./.dotnet/dotnet build test/dnup.Tests/dnup.Tests.csproj -c Release --no-restore
+ displayName: 🐧 Build (Unix)
+ - script: |
+ mkdir -p "$(Build.SourcesDirectory)/artifacts/dnupTestResults"
+ displayName: 📁 Create test results directory (Unix)
+ - script: |
+ ./.dotnet/dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults
+ displayName: 🔎 Test (Unix)
+ - task: PublishTestResults@2
+ displayName: 🚀 Publish test results
+ condition: always()
+ inputs:
+ testResultsFormat: VSTest
+ testResultsFiles: '**/dnup-tests.trx'
+ searchFolder: $(Build.SourcesDirectory)/artifacts/dnupTestResults
+ testRunTitle: 'dnup ${{ parameters.pool.os }}'
+ - task: PublishBuildArtifacts@1
+ displayName: ⬇️ Publish test artifacts
+ condition: always()
+ inputs:
+ PathtoPublish: $(Build.SourcesDirectory)/artifacts/dnupTestResults
+ ArtifactName: dnupTestResults_${{ parameters.pool.os }}
+ publishLocation: Container
diff --git a/sdk.slnx b/sdk.slnx
index 0246a040f447..76c8f2c407af 100644
--- a/sdk.slnx
+++ b/sdk.slnx
@@ -3,7 +3,6 @@
-
@@ -88,6 +87,10 @@
+
+
+
+
@@ -309,6 +312,7 @@
+
diff --git a/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs b/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs
index 5cd73f53abb8..5f1aab066131 100644
--- a/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs
+++ b/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs
@@ -33,7 +33,7 @@ public BooleanEnvironmentRule(params string[] variables)
public override bool IsMatch()
{
- return _variables.Any(variable =>
+ return _variables.Any(variable =>
bool.TryParse(Environment.GetEnvironmentVariable(variable), out bool value) && value);
}
}
@@ -96,8 +96,8 @@ public EnvironmentDetectionRuleWithResult(T result, params string[] variables)
/// The result value if the rule matches; otherwise, null.
public T? GetResult()
{
- return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable)))
- ? _result
+ return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable)))
+ ? _result
: null;
}
-}
\ No newline at end of file
+}
diff --git a/src/Installer/.github/copilot-instructions.md b/src/Installer/.github/copilot-instructions.md
new file mode 100644
index 000000000000..edc9547ec6c2
--- /dev/null
+++ b/src/Installer/.github/copilot-instructions.md
@@ -0,0 +1,8 @@
+
+---
+applyTo: "**"
+---
+- Environment: Windows 11 using PowerShell 7
+- Never use `&&` to chain commands; use semicolon (`;`) for PowerShell command chaining
+- Prefer PowerShell cmdlets over external utilities when available
+- Use PowerShell-style parameter syntax (-Parameter) rather than Unix-style flags
diff --git a/src/Installer/Microsoft.Dotnet.Installation/AssemblyInfo.cs b/src/Installer/Microsoft.Dotnet.Installation/AssemblyInfo.cs
new file mode 100644
index 000000000000..d90827d40301
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/AssemblyInfo.cs
@@ -0,0 +1,10 @@
+// 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.Runtime.CompilerServices;
+using System.Text;
+
+[assembly: InternalsVisibleTo("dnup, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")]
+[assembly: InternalsVisibleTo("dnup.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")]
diff --git a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs
new file mode 100644
index 000000000000..0deeb807808d
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs
@@ -0,0 +1,11 @@
+// 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.Installation;
+public record DotnetInstallRoot(
+ string Path,
+ InstallArchitecture Architecture);
diff --git a/src/Installer/Microsoft.Dotnet.Installation/DownloadProgress.cs b/src/Installer/Microsoft.Dotnet.Installation/DownloadProgress.cs
new file mode 100644
index 000000000000..6afe6cc0756a
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/DownloadProgress.cs
@@ -0,0 +1,31 @@
+using System;
+
+namespace Microsoft.Dotnet.Installation
+{
+ ///
+ /// 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/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs
new file mode 100644
index 000000000000..64c1e859f6ce
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.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;
+using Microsoft.Deployment.DotNet.Releases;
+
+namespace Microsoft.Dotnet.Installation;
+
+public interface IDotnetInstallDiscoverer
+{
+ DotnetInstallRoot GetDotnetInstallRootFromPath();
+
+ IEnumerable GetInstalledVersions(DotnetInstallRoot dotnetRoot, InstallComponent component);
+}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstaller.cs b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstaller.cs
new file mode 100644
index 000000000000..04eff3cf325a
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstaller.cs
@@ -0,0 +1,12 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using Microsoft.Deployment.DotNet.Releases;
+
+namespace Microsoft.Dotnet.Installation;
+
+public interface IDotnetInstaller
+{
+ void Install(DotnetInstallRoot dotnetRoot, InstallComponent component, ReleaseVersion version);
+ void Uninstall(DotnetInstallRoot dotnetRoot, InstallComponent component, ReleaseVersion version);
+}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs b/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs
new file mode 100644
index 000000000000..92571c2e4c8f
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs
@@ -0,0 +1,28 @@
+// 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.Installation;
+
+public interface IDotnetReleaseInfoProvider
+{
+ IEnumerable GetAvailableChannels();
+
+ ReleaseVersion? GetLatestVersion(InstallComponent component, string channel);
+
+ // Get all versions in a channel - do we have a scenario for this?
+ //IEnumerable GetAllVersions(InstallComponent component, string channel);
+
+ SupportType GetSupportType(InstallComponent component, ReleaseVersion version);
+}
+
+public enum SupportType
+{
+ OutOfSupport,
+ LongTermSupport,
+ StandardTermSupport
+}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallArchitecture.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallArchitecture.cs
new file mode 100644
index 000000000000..ddc1b5b93d0e
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/InstallArchitecture.cs
@@ -0,0 +1,15 @@
+// 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.Installation;
+
+public enum InstallArchitecture
+{
+ x86,
+ x64,
+ arm64
+}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallComponent.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallComponent.cs
new file mode 100644
index 000000000000..1cecbe90e59d
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/InstallComponent.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.
+
+namespace Microsoft.Dotnet.Installation;
+
+public enum InstallComponent
+{
+ SDK,
+ Runtime,
+ ASPNETCore,
+ WindowsDesktop
+}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallerFactory.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallerFactory.cs
new file mode 100644
index 000000000000..a27a5c2ecdaf
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/InstallerFactory.cs
@@ -0,0 +1,22 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+using Microsoft.Dotnet.Installation.Internal;
+
+namespace Microsoft.Dotnet.Installation;
+
+public static class InstallerFactory
+{
+ public static IDotnetInstaller CreateInstaller()
+ {
+ return new DotnetInstaller();
+ }
+
+ public static IDotnetReleaseInfoProvider CreateReleaseInfoProvider()
+ {
+ return new DotnetReleaseInfoProvider();
+ }
+}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallerUtilities.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallerUtilities.cs
new file mode 100644
index 000000000000..4d061d72c318
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/InstallerUtilities.cs
@@ -0,0 +1,28 @@
+// 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.Runtime.InteropServices;
+using System.Text;
+
+namespace Microsoft.Dotnet.Installation;
+
+public static class InstallerUtilities
+{
+ 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);
+ }
+}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs
new file mode 100644
index 000000000000..83b29ea5d807
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs
@@ -0,0 +1,503 @@
+// 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.Installation.Internal;
+
+internal class ArchiveDotnetExtractor : IDisposable
+{
+ private readonly DotnetInstallRequest _request;
+ private readonly ReleaseVersion _resolvedVersion;
+ private readonly bool _noProgress;
+ private string scratchDownloadDirectory;
+ private string? _archivePath;
+
+ public ArchiveDotnetExtractor(DotnetInstallRequest request, ReleaseVersion resolvedVersion, bool noProgress = false)
+ {
+ _request = request;
+ _resolvedVersion = resolvedVersion;
+ _noProgress = noProgress;
+ scratchDownloadDirectory = Directory.CreateTempSubdirectory().FullName;
+ }
+
+ public void Prepare()
+ {
+ using var releaseManifest = new ReleaseManifest();
+ var archiveName = $"dotnet-{Guid.NewGuid()}";
+ _archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetArchiveFileExtensionForPlatform());
+
+ if (_noProgress)
+ {
+ // When no-progress is enabled, download without progress display
+ Console.WriteLine($"Downloading .NET SDK {_resolvedVersion}...");
+ var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_request, _resolvedVersion, _archivePath, null);
+ if (!downloadSuccess)
+ {
+ throw new InvalidOperationException($"Failed to download .NET archive for version {_resolvedVersion}");
+ }
+ Console.WriteLine($"Download of .NET SDK {_resolvedVersion} complete.");
+ }
+ else
+ {
+ // Use progress display for normal operation
+ 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.");
+ }
+
+ if (_noProgress)
+ {
+ // When no-progress is enabled, install without progress display
+ Console.WriteLine($"Installing .NET SDK {_resolvedVersion}...");
+
+ // Extract archive directly to target directory with special handling for muxer
+ var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, null);
+ if (extractResult is not null)
+ {
+ throw new InvalidOperationException($"Failed to install SDK: {extractResult}");
+ }
+
+ Console.WriteLine($"Installation of .NET SDK {_resolvedVersion} complete.");
+ }
+ else
+ {
+ // Use progress display for normal operation
+ 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 is not 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 is not 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() is not 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()) is not 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/Microsoft.Dotnet.Installation/Internal/DnupUtilities.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DnupUtilities.cs
new file mode 100644
index 000000000000..b36f2be9e1e6
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DnupUtilities.cs
@@ -0,0 +1,80 @@
+// 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.Installation.Internal;
+
+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 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/Microsoft.Dotnet.Installation/Internal/DotnetInstall.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstall.cs
new file mode 100644
index 000000000000..7e4a391c779a
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstall.cs
@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using Microsoft.Deployment.DotNet.Releases;
+using Microsoft.Dotnet.Installation;
+
+namespace Microsoft.Dotnet.Installation.Internal;
+
+///
+/// Represents a .NET installation with a fully specified version.
+/// The MuxerDirectory is the directory of the corresponding .NET host that has visibility into this .NET installation.
+///
+internal record DotnetInstall(
+ 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.
+///
+internal record DotnetInstallRequest(
+ DotnetInstallRoot InstallRoot,
+ UpdateChannel Channel,
+ InstallComponent Component,
+ InstallRequestOptions Options);
+
+internal record InstallRequestOptions()
+{
+ // Include options such as the custom feed, manifest path, etc.
+ public string? ManifestPath { get; init; }
+}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstaller.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstaller.cs
new file mode 100644
index 000000000000..3d08a84050d6
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstaller.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;
+using System.Collections.Generic;
+using System.Text;
+using Microsoft.Deployment.DotNet.Releases;
+using Spectre.Console;
+
+namespace Microsoft.Dotnet.Installation.Internal
+{
+ internal class DotnetInstaller : IDotnetInstaller
+ {
+ public void Install(DotnetInstallRoot dotnetRoot, InstallComponent component, ReleaseVersion version)
+ {
+ var installRequest = new DotnetInstallRequest(dotnetRoot, new UpdateChannel(version.ToString()), component, new InstallRequestOptions());
+
+ using ArchiveDotnetExtractor installer = new(installRequest, version, noProgress: true);
+ installer.Prepare();
+ installer.Commit();
+ }
+ public void Uninstall(DotnetInstallRoot dotnetRoot, InstallComponent component, ReleaseVersion version) => throw new NotImplementedException();
+ }
+}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetReleaseInfoProvider.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetReleaseInfoProvider.cs
new file mode 100644
index 000000000000..168bcb95f063
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetReleaseInfoProvider.cs
@@ -0,0 +1,22 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+using Microsoft.Deployment.DotNet.Releases;
+
+namespace Microsoft.Dotnet.Installation.Internal;
+
+internal class DotnetReleaseInfoProvider : IDotnetReleaseInfoProvider
+{
+ public IEnumerable GetAvailableChannels() => throw new NotImplementedException();
+ public ReleaseVersion? GetLatestVersion(InstallComponent component, string channel)
+ {
+ var releaseManifest = new ReleaseManifest();
+ var release = releaseManifest.GetLatestVersionForChannel(new UpdateChannel(channel), component);
+
+ return release;
+ }
+ public SupportType GetSupportType(InstallComponent component, ReleaseVersion version) => throw new NotImplementedException();
+}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs
new file mode 100644
index 000000000000..243e49da0766
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs
@@ -0,0 +1,576 @@
+// 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.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Deployment.DotNet.Releases;
+using Microsoft.Dotnet.Installation;
+
+namespace Microsoft.Dotnet.Installation.Internal;
+
+///
+/// Handles downloading and parsing .NET release manifests to find the correct installer/archive for a given installation.
+///
+internal class ReleaseManifest(HttpClient httpClient) : 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);
+ }
+
+ ///
+ /// 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)
+ {
+ if (string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase) || string.Equals(channel.Name, "sts", StringComparison.OrdinalIgnoreCase))
+ {
+ var releaseType = string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase) ? ReleaseType.LTS : ReleaseType.STS;
+ var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult();
+ return GetLatestVersionByReleaseType(productIndex, releaseType, component);
+ }
+ else if (string.Equals(channel.Name, "preview", StringComparison.OrdinalIgnoreCase))
+ {
+ var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult();
+ return GetLatestPreviewVersion(productIndex, component);
+ }
+ else if (string.Equals(channel.Name, "latest", StringComparison.OrdinalIgnoreCase))
+ {
+ var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult();
+ return GetLatestActiveVersion(productIndex, component);
+ }
+
+ 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();
+ if (minor < 0)
+ {
+ return GetLatestVersionForMajorOrMajorMinor(index, major, component); // Major Only (e.g., "9")
+ }
+ else if (minor >= 0 && featureBand == null) // Major.Minor (e.g., "9.0")
+ {
+ return GetLatestVersionForMajorOrMajorMinor(index, major, component, minor);
+ }
+ else if (minor >= 0 && featureBand is not null) // Not Fully Qualified Feature band Version (e.g., "9.0.1xx")
+ {
+ return GetLatestVersionForFeatureBand(index, major, minor, featureBand, component);
+ }
+
+ return null;
+ }
+
+ private IEnumerable GetProductsInMajorOrMajorMinor(IEnumerable index, int major, int? minor = null)
+ {
+ var validProducts = index.Where(p => p.ProductVersion.StartsWith(minor is not null ? $"{major}.{minor}" : $"{major}."));
+ return validProducts;
+ }
+
+ ///
+ /// Gets the latest version for a major-only channel (e.g., "9").
+ ///
+ private ReleaseVersion? GetLatestVersionForMajorOrMajorMinor(IEnumerable index, int major, InstallComponent component, int? minor = null)
+ {
+ // Assumption: The manifest is designed so that the first product for a major version will always be latest.
+ Product? latestProductWithMajor = GetProductsInMajorOrMajorMinor(index, major, minor).FirstOrDefault();
+ return GetLatestReleaseVersionInProduct(latestProductWithMajor, component);
+ }
+
+ ///
+ /// 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 static ReleaseVersion? GetLatestVersionByReleaseType(IEnumerable index, ReleaseType releaseType, InstallComponent component)
+ {
+ var correctPhaseProducts = index?.Where(p => p.ReleaseType == releaseType) ?? Enumerable.Empty();
+ return GetLatestActiveVersion(correctPhaseProducts, component);
+ }
+
+ ///
+ /// Gets the latest preview version available.
+ ///
+ /// The product collection to search
+ /// InstallComponent.SDK or InstallComponent.Runtime
+ /// Latest preview or GoLive version string, or null if none found
+ private ReleaseVersion? GetLatestPreviewVersion(IEnumerable index, InstallComponent component)
+ {
+ ReleaseVersion? latestPreviewVersion = GetLatestVersionBySupportPhase(index, component, [SupportPhase.Preview, SupportPhase.GoLive]);
+ if (latestPreviewVersion is not null)
+ {
+ return latestPreviewVersion;
+ }
+
+ return GetLatestVersionBySupportPhase(index, component, [SupportPhase.Active]);
+ }
+
+ ///
+ /// Gets the latest version across all available products that matches the support phase.
+ ///
+ private static ReleaseVersion? GetLatestActiveVersion(IEnumerable index, InstallComponent component)
+ {
+ return GetLatestVersionBySupportPhase(index, component, [SupportPhase.Active]);
+ }
+ ///
+ /// Gets the latest version across all available products that matches the support phase.
+ ///
+ private static ReleaseVersion? GetLatestVersionBySupportPhase(IEnumerable index, InstallComponent component, SupportPhase[] acceptedSupportPhases)
+ {
+ // A version in preview/ga/rtm support is considered Go Live and not Active.
+ var activeSupportProducts = index?.Where(p => acceptedSupportPhases.Contains(p.SupportPhase));
+
+ // The manifest is designed so that the first product will always be latest.
+ Product? latestActiveSupportProduct = activeSupportProducts?.FirstOrDefault();
+
+ return GetLatestReleaseVersionInProduct(latestActiveSupportProduct, component);
+ }
+
+ private static ReleaseVersion? GetLatestReleaseVersionInProduct(Product? product, InstallComponent component)
+ {
+ // Assumption: The latest runtime version will always be the same across runtime components.
+ ReleaseVersion? latestVersion = component switch
+ {
+ InstallComponent.SDK => product?.LatestSdkVersion,
+ _ => product?.LatestRuntimeVersion
+ };
+
+ return latestVersion;
+ }
+
+ ///
+ /// Replaces user input feature band strings into the full feature band.
+ /// This would convert '1xx' into '100'.
+ /// 100 is not necessarily the latest but it is the feature band.
+ /// The other number in the band is the patch.
+ ///
+ ///
+ ///
+ private static int NormalizeFeatureBandInput(string band)
+ {
+ var bandString = band
+ .Replace("X", "x")
+ .Replace("x", "0")
+ .PadRight(3, '0')
+ .Substring(0, 3);
+ return int.Parse(bandString);
+ }
+
+
+ ///
+ /// 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)
+ {
+ if (component != InstallComponent.SDK)
+ {
+ return null;
+ }
+
+ var validProducts = GetProductsInMajorOrMajorMinor(index, major, minor);
+ var latestProduct = validProducts.FirstOrDefault();
+ var releases = latestProduct?.GetReleasesAsync().GetAwaiter().GetResult().ToList() ?? new List();
+ var normalizedFeatureBand = NormalizeFeatureBandInput(featureBand);
+
+ foreach (var release in releases)
+ {
+ foreach (var sdk in release.Sdks)
+ {
+ if (sdk.Version.SdkFeatureBand == normalizedFeatureBand)
+ {
+ return sdk.Version;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private const int MaxRetryCount = 3;
+ private const int RetryDelayMilliseconds = 1000;
+
+ private readonly HttpClient _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ private ProductCollection? _productCollection;
+
+ public ReleaseManifest()
+ : this(CreateDefaultHttpClient())
+ {
+ }
+
+ ///
+ /// Creates an HttpClient with enhanced proxy support for enterprise environments.
+ ///
+ private static HttpClient CreateDefaultHttpClient()
+ {
+ var handler = new HttpClientHandler()
+ {
+ UseProxy = true,
+ UseDefaultCredentials = true,
+ AllowAutoRedirect = true,
+ MaxAutomaticRedirections = 10,
+ AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
+ };
+
+ var client = new HttpClient(handler)
+ {
+ Timeout = TimeSpan.FromMinutes(10)
+ };
+
+ // Set user-agent to identify dnup in telemetry
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("dnup-dotnet-installer");
+
+ return client;
+ }
+
+ ///
+ /// 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
+ protected 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
+ protected 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)
+ {
+ var targetFile = FindReleaseFile(installRequest, resolvedVersion);
+ string? downloadUrl = targetFile?.Address.ToString();
+ string? expectedHash = targetFile?.Hash.ToString();
+
+ if (string.IsNullOrEmpty(expectedHash) || string.IsNullOrEmpty(downloadUrl))
+ {
+ 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 is not null)
+ {
+ return _productCollection;
+ }
+
+ _productCollection = ProductCollection.GetAsync().GetAwaiter().GetResult();
+ return _productCollection;
+ }
+
+ ///
+ /// 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 ReleaseComponent? FindRelease(Product product, ReleaseVersion resolvedVersion, InstallComponent component)
+ {
+ var releases = product.GetReleasesAsync().GetAwaiter().GetResult().ToList();
+
+ foreach (var release in releases)
+ {
+ if (component == InstallComponent.SDK)
+ {
+ foreach (var sdk in release.Sdks)
+ {
+ if (sdk.Version.Equals(resolvedVersion))
+ {
+ return sdk;
+ }
+ }
+ }
+ else
+ {
+ var runtimesQuery = component switch
+ {
+ InstallComponent.ASPNETCore => release.Runtimes
+ .Where(r => r.Name.Contains("ASP", StringComparison.OrdinalIgnoreCase)),
+ InstallComponent.WindowsDesktop => release.Runtimes
+ .Where(r => r.Name.Contains("Desktop", StringComparison.OrdinalIgnoreCase)),
+ _ => release.Runtimes
+ .Where(r => r.Name.Contains(".NET Runtime", StringComparison.OrdinalIgnoreCase) ||
+ r.Name.Contains(".NET Core Runtime", StringComparison.OrdinalIgnoreCase)),
+ };
+ foreach (var runtime in runtimesQuery)
+ {
+ if (runtime.Version.Equals(resolvedVersion))
+ {
+ return runtime;
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Finds the matching file in the release for the given installation requirements.
+ ///
+ private static ReleaseFile? FindMatchingFile(ReleaseComponent release, DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion)
+ {
+ var rid = DnupUtilities.GetRuntimeIdentifier(installRequest.InstallRoot.Architecture);
+ var fileExtension = DnupUtilities.GetArchiveFileExtensionForPlatform();
+
+ var matchingFiles = release.Files
+ .Where(f => f.Rid == rid) // TODO: Do we support musl here?
+ .Where(f => f.Name.EndsWith(fileExtension, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (matchingFiles.Count == 0)
+ {
+ return null;
+ }
+
+ return matchingFiles.First();
+ }
+
+ ///
+ /// 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);
+ // TODO: Older runtime versions use a different SHA algorithm.
+ // Eventually the manifest should indicate which algorithm to use.
+ 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();
+ }
+}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs
new file mode 100644
index 000000000000..e7194e234736
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs
@@ -0,0 +1,63 @@
+// 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.Installation.Internal;
+
+public class ScopedMutex : IDisposable
+{
+ private readonly Mutex _mutex;
+ private bool _hasHandle;
+ // Track recursive holds on a per-thread basis so we can assert manifest access without re-acquiring.
+ private static readonly ThreadLocal _holdCount = new(() => 0);
+
+ public ScopedMutex(string name)
+ {
+ try
+ {
+ // On Linux and Mac, "Global\" prefix doesn't work - strip it if present
+ string mutexName = name;
+ if (Environment.OSVersion.Platform != PlatformID.Win32NT && mutexName.StartsWith("Global\\"))
+ {
+ mutexName = mutexName.Substring(7);
+ }
+
+ _mutex = new Mutex(false, mutexName);
+ _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(300), false);
+ if (_hasHandle)
+ {
+ _holdCount.Value = _holdCount.Value + 1;
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Warning: Could not create or acquire mutex '{name}': {ex.Message}");
+ throw;
+ }
+ }
+
+ public bool HasHandle => _hasHandle;
+ public static bool CurrentThreadHoldsMutex => _holdCount.Value > 0;
+
+ public void Dispose()
+ {
+ if (_hasHandle)
+ {
+ try
+ {
+ _mutex.ReleaseMutex();
+ }
+ finally
+ {
+ // Decrement hold count even if release throws.
+ if (_holdCount.Value > 0)
+ {
+ _holdCount.Value = _holdCount.Value - 1;
+ }
+ }
+ }
+ _mutex.Dispose();
+ }
+}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs
new file mode 100644
index 000000000000..9a0fa920ad5d
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs
@@ -0,0 +1,46 @@
+using System;
+using Spectre.Console;
+using Microsoft.Dotnet.Installation;
+
+namespace Microsoft.Dotnet.Installation.Internal
+{
+ 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/Microsoft.Dotnet.Installation/Internal/UpdateChannel.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/UpdateChannel.cs
new file mode 100644
index 000000000000..6c66d0a58d4e
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/UpdateChannel.cs
@@ -0,0 +1,25 @@
+// 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.Installation.Internal;
+
+internal 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/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj
new file mode 100644
index 000000000000..ec1119864671
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+ $(NoWarn);CS8002
+ true
+ true
+ Microsoft.Dotnet.Installation
+ false
+ .NET Installation Library
+ 1.0.0-alpha
+ $(Version)
+
+
+
+
+
+
+
+
+
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/ArchiveInstallationValidator.cs b/src/Installer/dnup/ArchiveInstallationValidator.cs
new file mode 100644
index 000000000000..f6909756300d
--- /dev/null
+++ b/src/Installer/dnup/ArchiveInstallationValidator.cs
@@ -0,0 +1,145 @@
+// 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.Diagnostics;
+using System.IO;
+using System.Linq;
+using Microsoft.Dotnet.Installation.Internal;
+using System.Runtime.InteropServices;
+using Microsoft.Deployment.DotNet.Releases;
+using Microsoft.Dotnet.Installation;
+using Microsoft.DotNet.NativeWrapper;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper;
+
+internal class ArchiveInstallationValidator : IInstallationValidator
+{
+ private const string HostFxrRuntimeProperty = "HOSTFXR_PATH";
+ private static readonly Dictionary RuntimeMonikerByComponent = new()
+ {
+ [InstallComponent.Runtime] = "Microsoft.NETCore.App",
+ [InstallComponent.ASPNETCore] = "Microsoft.AspNetCore.App",
+ [InstallComponent.WindowsDesktop] = "Microsoft.WindowsDesktop.App"
+ };
+
+ public bool Validate(DotnetInstall install)
+ {
+ string? installRoot = install.InstallRoot.Path;
+ if (string.IsNullOrEmpty(installRoot))
+ {
+ return false;
+ }
+
+ string dotnetMuxerPath = Path.Combine(installRoot, DnupUtilities.GetDotnetExeName());
+ if (!File.Exists(dotnetMuxerPath))
+ {
+ return false;
+ }
+
+ string resolvedVersion = install.Version.ToString();
+ if (!ValidateComponentLayout(installRoot, resolvedVersion, install.Component))
+ {
+ return false;
+ }
+
+ if (!ValidateWithHostFxr(installRoot, install.Version, install.Component))
+ {
+ return false;
+ }
+
+ // We should also validate whether the host is the maximum version or higher than all installed versions.
+
+ return true;
+ }
+
+ private static bool ValidateComponentLayout(string installRoot, string resolvedVersion, InstallComponent component)
+ {
+ if (component == InstallComponent.SDK)
+ {
+ string sdkDirectory = Path.Combine(installRoot, "sdk", resolvedVersion);
+ return Directory.Exists(sdkDirectory);
+ }
+
+ if (RuntimeMonikerByComponent.TryGetValue(component, out string? runtimeMoniker))
+ {
+ string runtimeDirectory = Path.Combine(installRoot, "shared", runtimeMoniker, resolvedVersion);
+ return Directory.Exists(runtimeDirectory);
+ }
+
+ return false;
+ }
+
+ private bool ValidateWithHostFxr(string installRoot, ReleaseVersion resolvedVersion, InstallComponent component)
+ {
+ try
+ {
+ ConfigureHostFxrResolution(installRoot);
+
+ var bundleProvider = new NETBundlesNativeWrapper();
+ NetEnvironmentInfo environmentInfo = bundleProvider.GetDotnetEnvironmentInfo(installRoot);
+
+ if (component == InstallComponent.SDK)
+ {
+ string expectedPath = Path.Combine(installRoot, "sdk", resolvedVersion.ToString());
+ return environmentInfo.SdkInfo.Any(sdk =>
+ string.Equals(sdk.Version.ToString(), resolvedVersion.ToString(), StringComparison.OrdinalIgnoreCase) &&
+ DnupUtilities.PathsEqual(sdk.Path, expectedPath));
+ }
+
+ if (!RuntimeMonikerByComponent.TryGetValue(component, out string? runtimeMoniker))
+ {
+ return false;
+ }
+
+ string expectedRuntimePath = Path.Combine(installRoot, "shared", runtimeMoniker, resolvedVersion.ToString());
+ return environmentInfo.RuntimeInfo.Any(runtime =>
+ string.Equals(runtime.Name, runtimeMoniker, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(runtime.Version.ToString(), resolvedVersion.ToString(), StringComparison.OrdinalIgnoreCase) &&
+ DnupUtilities.PathsEqual(runtime.Path, expectedRuntimePath));
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static void ConfigureHostFxrResolution(string installRoot)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return;
+ }
+
+ if (AppContext.GetData(HostFxrRuntimeProperty) is not null)
+ {
+ return;
+ }
+
+ string? hostFxrPath = FindHostFxrLibrary(installRoot);
+ if (hostFxrPath is not null)
+ {
+ AppContext.SetData(HostFxrRuntimeProperty, hostFxrPath);
+ }
+ }
+
+ private static string? FindHostFxrLibrary(string installRoot)
+ {
+ string hostFxrDirectory = Path.Combine(installRoot, "host", "fxr");
+ if (!Directory.Exists(hostFxrDirectory))
+ {
+ return null;
+ }
+
+ string libraryName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? "hostfxr.dll"
+ : RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
+ ? "libhostfxr.dylib"
+ : "libhostfxr.so";
+
+ return Directory.EnumerateFiles(hostFxrDirectory, libraryName, SearchOption.AllDirectories)
+ .OrderByDescending(File.GetLastWriteTimeUtc)
+ .FirstOrDefault();
+ }
+}
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..37fc7435c741
--- /dev/null
+++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using Microsoft.Dotnet.Installation.Internal;
+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 : IDotnetInstallManager
+ {
+ 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 DotnetInstallRootConfiguration? GetConfiguredInstallType()
+ {
+ var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL");
+ InstallType installtype;
+ if (!Enum.TryParse(testHookDefaultInstall, out installtype))
+ {
+ return null;
+ }
+ var installPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH") ?? GetDefaultDotnetInstallPath();
+ return new(new(installPath, InstallerUtilities.GetDefaultInstallArchitecture()), installtype, true, true);
+ }
+
+ 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..78a2b9a819dd
--- /dev/null
+++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Deployment.DotNet.Releases;
+using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install
+{
+ internal class EnvironmentVariableMockReleaseInfoProvider : IDotnetReleaseInfoProvider
+ {
+ IEnumerable IDotnetReleaseInfoProvider.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 ReleaseVersion GetLatestVersion(InstallComponent component, string channel)
+ {
+ if (component != InstallComponent.SDK)
+ {
+ throw new NotImplementedException("Only SDK component is supported in this mock provider");
+ }
+
+ string version;
+ if (channel == "preview")
+ {
+ version = "11.0.100-preview.1.42424";
+ }
+ else if (channel == "latest" || channel == "10" || channel == "10.0.2xx")
+ {
+ version = "10.0.0-preview.7";
+ }
+ else if (channel == "10.0.1xx")
+ {
+ version = "10.0.106";
+ }
+ else if (channel == "9" || channel == "9.0.3xx")
+ {
+ version = "9.0.309";
+ }
+ else if (channel == "9.0.2xx")
+ {
+ version = "9.0.212";
+ }
+ else if (channel == "9.0.1xx")
+ {
+ version = "9.0.115";
+ }
+
+ version = channel;
+
+ return new ReleaseVersion(version);
+ }
+
+ public SupportType GetSupportType(InstallComponent component, ReleaseVersion version) => throw new NotImplementedException();
+
+ }
+}
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..e1cb04ec401d
--- /dev/null
+++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs
@@ -0,0 +1,277 @@
+// 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 System.Runtime.InteropServices;
+using Microsoft.Dotnet.Installation.Internal;
+using Microsoft.Deployment.DotNet.Releases;
+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 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 string? _manifestPath = result.GetValue(SdkInstallCommandParser.ManifestPathOption);
+ private readonly bool _interactive = result.GetValue(SdkInstallCommandParser.InteractiveOption);
+ private readonly bool _noProgress = result.GetValue(SdkInstallCommandParser.NoProgressOption);
+
+ private readonly IDotnetInstallManager _dotnetInstaller = new DotnetInstallManager();
+ private readonly IDotnetReleaseInfoProvider _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 is not null)
+ {
+ installPathFromGlobalJson = globalJsonInfo.SdkPath;
+
+ if (installPathFromGlobalJson is not null && _installPath is not 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 is not null && currentDotnetInstallRoot.InstallType == 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 is not null)
+ {
+ channelFromGlobalJson = ResolveChannelFromGlobalJson(globalJsonInfo.GlobalJsonPath);
+ }
+
+ bool? resolvedUpdateGlobalJson = null;
+
+ if (channelFromGlobalJson is not null && _versionOrChannel is not 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 is not null)
+ {
+ SpectreAnsiConsole.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonInfo?.GlobalJsonPath} specifies that version.");
+
+ resolvedChannel = channelFromGlobalJson;
+ }
+ else if (_versionOrChannel is not 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 == null)
+ {
+ 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.InstallType == 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.InstallType == 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);
+ }
+
+ // TODO: Add checks for whether PATH and DOTNET_ROOT need to be updated, or if the install is in an inconsistent state
+ }
+ 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, InstallerUtilities.GetDefaultInstallArchitecture()),
+ new UpdateChannel(resolvedChannel),
+ InstallComponent.SDK,
+ new InstallRequestOptions
+ {
+ ManifestPath = _manifestPath
+ });
+
+ var resolvedVersion = _channelVersionResolver.Resolve(installRequest);
+
+ if (resolvedSetDefaultInstall == true && currentDotnetInstallRoot?.InstallType == InstallType.Admin)
+ {
+ if (_interactive)
+ {
+ var latestAdminVersion = _dotnetInstaller.GetLatestInstalledAdminVersion();
+ if (latestAdminVersion is not 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}[/]...");
+
+ DotnetInstall? mainInstall;
+
+ // Pass the _noProgress flag to the InstallerOrchestratorSingleton
+ // The orchestrator will handle installation with or without progress based on the flag
+ mainInstall = InstallerOrchestratorSingleton.Instance.Install(installRequest, _noProgress);
+ 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, InstallerUtilities.GetDefaultInstallArchitecture()),
+ new UpdateChannel(additionalVersion),
+ InstallComponent.SDK,
+ new InstallRequestOptions
+ {
+ ManifestPath = _manifestPath
+ });
+
+ // Install the additional version with the same progress settings as the main installation
+ 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..1b7c3e9c2a96
--- /dev/null
+++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs
@@ -0,0 +1,83 @@
+// 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 ManifestPathOption = new("--manifest-path")
+ {
+ HelpName = "MANIFEST_PATH",
+ Description = "Custom path to the manifest file for tracking .NET SDK installations",
+ };
+
+ public static readonly Option InteractiveOption = CommonOptions.InteractiveOption;
+ public static readonly Option NoProgressOption = CommonOptions.NoProgressOption;
+
+ 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(ManifestPathOption);
+
+ command.Options.Add(InteractiveOption);
+ command.Options.Add(NoProgressOption);
+
+ 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..855a5a8aaa45
--- /dev/null
+++ b/src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs
@@ -0,0 +1,57 @@
+// 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;
+ public static readonly Option NoProgressOption = CommonOptions.NoProgressOption;
+
+ 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.Options.Add(NoProgressOption);
+
+ 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..943642c8a47a
--- /dev/null
+++ b/src/Installer/dnup/CommonOptions.cs
@@ -0,0 +1,28 @@
+// 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()
+ };
+
+ public static Option NoProgressOption = new("--no-progress")
+ {
+ Description = "Disables progress display for operations",
+ Arity = ArgumentArity.ZeroOrOne
+ };
+
+ 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..cc612544b292
--- /dev/null
+++ b/src/Installer/dnup/Constants.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.
+
+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).
+ /// Mutex names MUST be valid file names on Unix. https://learn.microsoft.com/dotnet/api/system.threading.mutex.openexisting?view=net-9.0
+ ///
+ public const string ModifyInstallationStates = "Global\\DnupFinalize";
+ }
+ }
+}
diff --git a/src/Installer/dnup/DnupDebugHelper.cs b/src/Installer/dnup/DnupDebugHelper.cs
new file mode 100644
index 000000000000..08810333fa3c
--- /dev/null
+++ b/src/Installer/dnup/DnupDebugHelper.cs
@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Diagnostics;
+using System.Linq;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper;
+
+// Copy of DebugHelper.cs in the SDK - port eventually.
+internal static class DnupDebugHelper
+{
+ [Conditional("DEBUG")]
+ public static void HandleDebugSwitch(ref string[] args)
+ {
+ if (args.Length > 0 && string.Equals("--debug", args[0], StringComparison.OrdinalIgnoreCase))
+ {
+ args = [.. args.Skip(1)];
+ WaitForDebugger();
+ }
+ }
+
+ public static void WaitForDebugger()
+ {
+ int processId = Environment.ProcessId;
+
+ Console.WriteLine("Waiting for debugger to attach. Press ENTER to continue");
+ Console.WriteLine($"Process ID: {processId}");
+ Console.ReadLine();
+ }
+}
diff --git a/src/Installer/dnup/DnupManifestJsonContext.cs b/src/Installer/dnup/DnupManifestJsonContext.cs
new file mode 100644
index 000000000000..0c7809151c1f
--- /dev/null
+++ b/src/Installer/dnup/DnupManifestJsonContext.cs
@@ -0,0 +1,18 @@
+// 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.Dotnet.Installation.Internal;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper
+{
+ [JsonSourceGenerationOptions(WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ Converters = new[] { typeof(ReleaseVersionJsonConverter) })]
+ [JsonSerializable(typeof(List))]
+ [JsonSerializable(typeof(InstallComponent))]
+ [JsonSerializable(typeof(InstallArchitecture))]
+ [JsonSerializable(typeof(InstallType))]
+ [JsonSerializable(typeof(ManagementCadence))]
+ internal partial class DnupManifestJsonContext : JsonSerializerContext { }
+}
diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs
new file mode 100644
index 000000000000..b3b31d9ee354
--- /dev/null
+++ b/src/Installer/dnup/DnupSharedManifest.cs
@@ -0,0 +1,135 @@
+// 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.Text.Json;
+using System.Threading;
+using Microsoft.Dotnet.Installation.Internal;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper;
+
+internal class DnupSharedManifest : IDnupManifest
+{
+ private string ManifestPath => GetManifestPath();
+
+ public DnupSharedManifest(string? manifestPath = null)
+ {
+ _customManifestPath = manifestPath;
+ EnsureManifestExists();
+ }
+
+ private void EnsureManifestExists()
+ {
+ if (!File.Exists(ManifestPath))
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(ManifestPath)!);
+ File.WriteAllText(ManifestPath, JsonSerializer.Serialize(new List(), DnupManifestJsonContext.Default.ListDotnetInstall));
+ }
+ }
+
+ private string? _customManifestPath;
+
+ private string GetManifestPath()
+ {
+ // Use explicitly provided path first (constructor argument)
+ if (!string.IsNullOrEmpty(_customManifestPath))
+ {
+ return _customManifestPath;
+ }
+
+ // Fall back to environment variable override
+ var overridePath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_MANIFEST_PATH");
+ if (!string.IsNullOrEmpty(overridePath))
+ {
+ return overridePath;
+ }
+
+ // Default location
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "dnup",
+ "dnup_manifest.json");
+ }
+
+ private void AssertHasFinalizationMutex()
+ {
+ // Instead of attempting to reacquire the named mutex (which can create race conditions
+ // or accidentally succeed when we *don't* hold it), rely on the thread-local tracking
+ // implemented in ScopedMutex. This ensures we only assert based on a lock we actually obtained.
+ if (!ScopedMutex.CurrentThreadHoldsMutex)
+ {
+ throw new InvalidOperationException("The dnup manifest was accessed without holding the installation state mutex.");
+ }
+ }
+
+ 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 ?? [];
+
+ if (validator is not 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)
+ {
+ // TODO: Manifest read operations should protect against data structure changes and be able to reformat an old manifest version.
+ var installedVersions = GetInstalledVersions(validator);
+ var expectedInstallRootPath = Path.GetFullPath(installRoot.Path);
+ var installedVersionsInRoot = installedVersions
+ .Where(install => DnupUtilities.PathsEqual(Path.GetFullPath(install.InstallRoot.Path!), expectedInstallRootPath));
+ return installedVersionsInRoot;
+ }
+
+ 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/DotnetInstallManager.cs b/src/Installer/dnup/DotnetInstallManager.cs
new file mode 100644
index 000000000000..67e1652f37b4
--- /dev/null
+++ b/src/Installer/dnup/DotnetInstallManager.cs
@@ -0,0 +1,149 @@
+// 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.Installation.Internal;
+using Microsoft.DotNet.Cli.Utils;
+using Spectre.Console;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper;
+
+public class DotnetInstallManager : IDotnetInstallManager
+{
+ private readonly IEnvironmentProvider _environmentProvider;
+
+ public DotnetInstallManager(IEnvironmentProvider? environmentProvider = null)
+ {
+ _environmentProvider = environmentProvider ?? new EnvironmentProvider();
+ }
+
+ public DotnetInstallRootConfiguration? GetConfiguredInstallType()
+ {
+
+ string? foundDotnet = _environmentProvider.GetCommandPath("dotnet");
+ if (string.IsNullOrEmpty(foundDotnet))
+ {
+ return null;
+ }
+
+ 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);
+
+ var installRoot = new DotnetInstallRoot(installDir, InstallerUtilities.GetDefaultInstallArchitecture());
+
+ bool isSetAsDotnetRoot = DnupUtilities.PathsEqual(dotnetRoot, installDir);
+
+ return new(installRoot, isAdminInstall ? InstallType.Admin : InstallType.User, IsOnPath: true, isSetAsDotnetRoot);
+ }
+
+ 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;
+ 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/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/IDnupManifest.cs b/src/Installer/dnup/IDnupManifest.cs
new file mode 100644
index 000000000000..b17c026fa4d0
--- /dev/null
+++ b/src/Installer/dnup/IDnupManifest.cs
@@ -0,0 +1,17 @@
+// 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;
+using Microsoft.Dotnet.Installation.Internal;
+
+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/IDotnetInstallManager.cs b/src/Installer/dnup/IDotnetInstallManager.cs
new file mode 100644
index 000000000000..4b8c742dbb7c
--- /dev/null
+++ b/src/Installer/dnup/IDotnetInstallManager.cs
@@ -0,0 +1,57 @@
+// 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;
+
+// Install process
+// - Resolve version to install from channel
+// - Handle writing to install manifest and garbage collection
+// - Orchestrate installation so that only one install happens at a time
+// - Call into installer implementation
+
+
+public interface IDotnetInstallManager
+{
+ GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory);
+
+ string GetDefaultDotnetInstallPath();
+
+ DotnetInstallRootConfiguration? 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 is not null && GlobalJsonContents.Sdk.Paths.Length > 0) ? GlobalJsonContents.Sdk.Paths[0] : null;
+}
+
+public record DotnetInstallRootConfiguration(
+ DotnetInstallRoot InstallRoot,
+ InstallType InstallType,
+ bool IsOnPath,
+ // We may also need additional information to handle the case of whether DOTNET_ROOT is not set or whether it's set to a different path
+ bool IsSetAsDotnetRoot)
+{
+ public string Path => InstallRoot.Path;
+}
diff --git a/src/Installer/dnup/IInstallationValidator.cs b/src/Installer/dnup/IInstallationValidator.cs
new file mode 100644
index 000000000000..b49d87a1571c
--- /dev/null
+++ b/src/Installer/dnup/IInstallationValidator.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;
+using Microsoft.Dotnet.Installation.Internal;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper
+{
+ internal interface IInstallationValidator
+ {
+ bool Validate(DotnetInstall install);
+ }
+}
diff --git a/src/Installer/dnup/InstallType.cs b/src/Installer/dnup/InstallType.cs
new file mode 100644
index 000000000000..c63605e859f7
--- /dev/null
+++ b/src/Installer/dnup/InstallType.cs
@@ -0,0 +1,10 @@
+// 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.Installation;
+
+public enum InstallType
+{
+ User,
+ Admin,
+}
diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs
new file mode 100644
index 000000000000..a1610c21c0c5
--- /dev/null
+++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs
@@ -0,0 +1,104 @@
+// 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 Microsoft.Deployment.DotNet.Releases;
+using Microsoft.Dotnet.Installation.Internal;
+
+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, bool noProgress = false)
+ {
+ // 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);
+
+ string? customManifestPath = installRequest.Options.ManifestPath;
+
+ // 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, customManifestPath))
+ {
+ Console.WriteLine($"\n.NET SDK {versionToInstall} is already installed, skipping installation.");
+ return install;
+ }
+ }
+
+ using ArchiveDotnetExtractor installer = new(installRequest, versionToInstall, noProgress);
+ installer.Prepare();
+
+ // Extract and commit the install to the directory
+ using (var finalizeLock = modifyInstallStateMutex())
+ {
+ if (InstallAlreadyExists(install, customManifestPath))
+ {
+ return install;
+ }
+
+ installer.Commit();
+
+ ArchiveInstallationValidator validator = new();
+ if (validator.Validate(install))
+ {
+ DnupSharedManifest manifestManager = new(customManifestPath);
+ 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, string? customManifestPath = null)
+ {
+ var manifestManager = new DnupSharedManifest(customManifestPath);
+ // 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, string? customManifestPath = null)
+ {
+ var existingInstalls = GetExistingInstalls(install.InstallRoot, customManifestPath);
+
+ // 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..2a8b24aceb49
--- /dev/null
+++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs
@@ -0,0 +1,25 @@
+// 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.Installation.Internal;
+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..4969b276d7ef
--- /dev/null
+++ b/src/Installer/dnup/Program.cs
@@ -0,0 +1,18 @@
+
+using Microsoft.DotNet.Tools.Bootstrapper;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper
+{
+ internal class DnupProgram
+ {
+ public static int Main(string[] args)
+ {
+ // Handle --debug flag using the standard .NET SDK pattern
+ // This is DEBUG-only and removes the --debug flag from args
+ DnupDebugHelper.HandleDebugSwitch(ref args);
+
+ var parseResult = Parser.Parse(args);
+ return Parser.Invoke(parseResult);
+ }
+ }
+}
diff --git a/src/Installer/dnup/ReleaseVersionJsonConverter.cs b/src/Installer/dnup/ReleaseVersionJsonConverter.cs
new file mode 100644
index 000000000000..3e20fa619541
--- /dev/null
+++ b/src/Installer/dnup/ReleaseVersionJsonConverter.cs
@@ -0,0 +1,28 @@
+// 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;
+using Microsoft.Deployment.DotNet.Releases;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper;
+
+internal sealed class ReleaseVersionJsonConverter : JsonConverter
+{
+ public override ReleaseVersion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var value = reader.GetString();
+ if (string.IsNullOrEmpty(value))
+ {
+ throw new JsonException("ReleaseVersion value cannot be null or empty.");
+ }
+
+ return new ReleaseVersion(value);
+ }
+
+ public override void Write(Utf8JsonWriter writer, ReleaseVersion value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value.ToString());
+ }
+}
diff --git a/src/Installer/dnup/Strings.resx b/src/Installer/dnup/Strings.resx
new file mode 100644
index 000000000000..b522f258c0d5
--- /dev/null
+++ b/src/Installer/dnup/Strings.resx
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Allows the command to stop and wait for user input or action.
+
+
\ No newline at end of file
diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj
new file mode 100644
index 000000000000..a272529118cb
--- /dev/null
+++ b/src/Installer/dnup/dnup.csproj
@@ -0,0 +1,50 @@
+
+
+
+ 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..a38782dc9f20
--- /dev/null
+++ b/src/Installer/installer.code-workspace
@@ -0,0 +1,169 @@
+{
+ "folders": [
+ {
+ "path": ".",
+ "name": "installer"
+ },
+ {
+ "path": "../../test/dnup.Tests",
+ "name": "dnup.Tests"
+ },
+ {
+ "path": "../.."
+ }
+ ],
+ "settings": {
+ "dotnet.defaultSolution": "dnup/dnup.csproj",
+ "csharp.debug.console": "integratedTerminal",
+ "debug.terminal.clearBeforeReusing": true,
+ "editor.formatOnSave": true,
+ "omnisharp.useModernNet": true
+ },
+ "launch": {
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Launch dnup (Default)",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build",
+ "program": "${workspaceFolder:installer}/../../artifacts/bin/dnup/Debug/net10.0/dnup.dll",
+ "args": [
+ "sdk",
+ "install"
+ ],
+ "cwd": "${workspaceFolder:installer}/dnup",
+ "console": "integratedTerminal",
+ "stopAtEntry": false,
+ "logging": {
+ "moduleLoad": false
+ }
+ },
+ {
+ "name": "Run dnup tests (launch)",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build",
+ "program": "dotnet",
+ "args": [
+ "test",
+ "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj",
+ "/p:ContinuousIntegrationBuild=false",
+ ],
+ "cwd": "${workspaceFolder:dnup.Tests}",
+ "console": "integratedTerminal",
+ "stopAtEntry": false,
+ "env": {
+ "DNUP_TEST_DEBUG": "0"
+ }
+ },
+ {
+ "name": "Debug dnup test",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build",
+ "program": "dotnet",
+ "args": [
+ "test",
+ "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj",
+ "--no-build",
+ "--filter",
+ "FullyQualifiedName~${input:testName}",
+ "/p:ContinuousIntegrationBuild=false",
+ "/p:UseSharedCompilation=true",
+ "/p:Deterministic=false",
+ "/p:RunAnalyzers=false",
+ "/p:NoRestore=true"
+ ],
+ "cwd": "${workspaceFolder:dnup.Tests}",
+ "console": "integratedTerminal",
+ "stopAtEntry": false,
+ "logging": {
+ "moduleLoad": false
+ },
+ "env": {
+ "DNUP_TEST_DEBUG": "1"
+ }
+ }
+ ],
+ "compounds": [],
+ "inputs": [
+ {
+ "id": "testName",
+ "type": "promptString",
+ "description": "Enter test name or partial test name to debug (e.g., 'InstallChannelVersionTest', 'Install', etc.)"
+ },
+ {
+ "id": "commandLineArgs",
+ "type": "promptString",
+ "description": "Command line arguments for dnup (e.g., 'sdk install 9.0', 'runtime install lts', '--help')",
+ "default": "sdk install"
+ }
+ ]
+ },
+ "tasks": {
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "build",
+ "type": "process",
+ "command": "dotnet",
+ "args": [
+ "build",
+ "dnup/dnup.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary",
+ "/p:ContinuousIntegrationBuild=false",
+ "/p:UseSharedCompilation=true",
+ "/p:Deterministic=false",
+ "/p:RunAnalyzers=false",
+ "/p:NoRestore=true"
+ ],
+ "options": {
+ "cwd": "${workspaceFolder:installer}"
+ },
+ "presentation": {
+ "reveal": "always",
+ "panel": "shared",
+ "showReuseMessage": false,
+ "clear": true
+ },
+ "problemMatcher": "$msCompile",
+ "group": {
+ "kind": "build",
+ "isDefault": true
+ }
+ },
+ {
+ "label": "test",
+ "type": "process",
+ "command": "dotnet",
+ "args": [
+ "test",
+ "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary",
+ "/p:ContinuousIntegrationBuild=false",
+ "/p:UseSharedCompilation=true",
+ "/p:Deterministic=false",
+ "/p:RunAnalyzers=false",
+ "/p:NoRestore=true"
+ ],
+ "options": {
+ "cwd": "${workspaceFolder:installer}"
+ },
+ "presentation": {
+ "reveal": "always",
+ "panel": "shared",
+ "showReuseMessage": false,
+ "clear": true
+ },
+ "problemMatcher": "$msCompile",
+ "group": {
+ "kind": "test",
+ "isDefault": true
+ }
+ },
+ ]
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md
index c5820fde91ae..75e0f77588e2 100644
--- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md
+++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md
@@ -2,7 +2,3 @@
Rule ID | Missing Help Link | Title |
--------|-------------------|-------|
-CA1873 | | Avoid potentially expensive logging |
-CA1874 | | Use 'Regex.IsMatch' |
-CA1875 | | Use 'Regex.Count' |
-CA2023 | | Invalid braces in message template |
diff --git a/test/dnup.Tests/DnupCollections.cs b/test/dnup.Tests/DnupCollections.cs
new file mode 100644
index 000000000000..f81f54444d47
--- /dev/null
+++ b/test/dnup.Tests/DnupCollections.cs
@@ -0,0 +1,36 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Xunit;
+
+namespace Microsoft.DotNet.Tools.Dnup.Tests;
+
+///
+/// Collection definition that allows tests to run in parallel.
+///
+[CollectionDefinition("DnupInstallCollection", DisableParallelization = false)]
+public class DnupInstallCollection
+{
+ // This class has no code, and is never created. Its purpose is to be the place to apply
+ // [CollectionDefinition] and all the collection settings.
+}
+
+///
+/// Collection definition for reuse tests that allows tests to run in parallel.
+///
+[CollectionDefinition("DnupReuseCollection", DisableParallelization = false)]
+public class DnupReuseCollection
+{
+ // This class has no code, and is never created. Its purpose is to be the place to apply
+ // [CollectionDefinition] and all the collection settings.
+}
+
+///
+/// Collection definition for concurrency tests that allows tests to run in parallel.
+///
+[CollectionDefinition("DnupConcurrencyCollection", DisableParallelization = false)]
+public class DnupConcurrencyCollection
+{
+ // This class has no code, and is never created. Its purpose is to be the place to apply
+ // [CollectionDefinition] and all the collection settings.
+}
diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs
new file mode 100644
index 000000000000..01790ded49f4
--- /dev/null
+++ b/test/dnup.Tests/DnupE2Etest.cs
@@ -0,0 +1,262 @@
+// 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.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.Deployment.DotNet.Releases;
+using Microsoft.Dotnet.Installation;
+using Microsoft.DotNet.Tools.Bootstrapper;
+using Microsoft.DotNet.Tools.Dnup.Tests.Utilities;
+using Xunit;
+using Microsoft.Dotnet.Installation.Internal;
+
+namespace Microsoft.DotNet.Tools.Dnup.Tests;
+
+///
+/// Tests for installing different .NET SDK versions using dnup.
+/// Each test run can happen in parallel with other tests in different collections.
+///
+[Collection("DnupInstallCollection")]
+public class InstallEndToEndTests
+{
+ public static IEnumerable