diff --git a/.editorconfig b/.editorconfig
index 9c8c2945a35b..4a042e4d6e10 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -535,3 +535,15 @@ indent_style = unset
insert_final_newline = false
tab_width = unset
trim_trailing_whitespace = false
+
+# Meziantou.Analyzer suppressions for shared src/Cli code linked into dotnetup.
+# These rules do not apply to code owned by other teams.
+[{src/Cli/**/*.cs,src/Resolvers/**/*.cs}]
+dotnet_diagnostic.MA0006.severity = none
+dotnet_diagnostic.MA0008.severity = none
+dotnet_diagnostic.MA0011.severity = none
+dotnet_diagnostic.MA0016.severity = none
+dotnet_diagnostic.MA0048.severity = none
+dotnet_diagnostic.MA0084.severity = none
+dotnet_diagnostic.MA0099.severity = none
+dotnet_diagnostic.MA0144.severity = none
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 10c00a865f0c..71593bdd4a3d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -115,6 +115,7 @@
+
diff --git a/documentation/general/dotnetup/designs/dotnetup-dotnet.md b/documentation/general/dotnetup/designs/dotnetup-dotnet.md
new file mode 100644
index 000000000000..a15660447db2
--- /dev/null
+++ b/documentation/general/dotnetup/designs/dotnetup-dotnet.md
@@ -0,0 +1,49 @@
+# `dotnetup dotnet`
+
+# Motivation
+
+Manipulating the `PATH` environment variable can be tricky when Visual Studio and other installers are involved. These applications are automatically run with updates. They override the system level path on a regular basis which blocks `dotnetup` installs from being used.
+
+To provide an experience during the prototype of `dotnetup` before any official product is changed to work well with `dotnetup`, we propose 'aliasing' or 'shadowing' dotnet commands via `dotnetup` as one option.
+
+One downside to this is that IDE components whose processes call `dotnet` would still be broken by a change to the `PATH`. However, this prevents user interactions with terminals and scripts from being broken.
+
+Another downside is that this requires scripts to be updated to call `dotnetup dotnet` instead of `dotnet`. For many individuals, this is a no-go.
+This also adds the overhead of an additional process call.
+
+Yet, until the `dotnet` muxer itself or .NET Installer can be modified, this provides a consistent way for the user to enforce their intended install of `dotnet` when running commands.
+
+This also enables the `PATH` to have the admin install and for the two install types to co-exist - such that
+`dotnetup` based installs can still be used by the local user. That makes `dotnetup` useful even when IT Admins prevent the user from overriding the system path, such that it can be used in tandem with admin installs. This also gives `dotnetup` full control over the process call, such that environment variables like `DOTNET_ROOT` can be set.
+
+# Commands
+
+`dotnetup dotnet <>`
+`dotnetup do <>` (alias for the same thing)
+
+Arguments in `<>` are forwarded transparently to `dotnet.exe` in the determined location which limits our ability to configure the command itself.
+The `dotnet.exe` hive used will follow the logic `dotnetup` uses for installation location. (e.g. `global.json` vs `dotnetup hive` priority.)
+
+# Technical Details
+
+### Permissions
+
+The subprocess inherits the current elevation level. We considered de-elevating when `dotnetup` runs under elevation, but decided against it: it's confusing for an admin prompt to de-elevate, and workload commands often require elevation. There is also more risk trying obscure methods to de-elevate. The subprocess will run with whatever privileges `dotnetup` currently has.
+
+### Return values
+
+We should return with the return value of `dotnet` and mimic that behavior.
+
+### Interactive Mode
+
+The command uses `shell=true` (UseShellExecute=false with shell dispatch) so that interactive commands (e.g. `dotnetup dotnet interactive`) work correctly with stdin/stdout/stderr streaming. This also supports shell redirection techniques such as `<<` and piping.
+
+### Environment Settings
+
+The spawned process should modify the `PATH` and set `DOTNET_ROOT` to the value of the determined `dotnet.exe` location, so that `runtime` based interactions (debug, test, run) can work as expected. This would override any custom `DOTNET_ROOT` to ensure the hive is used, for scenarios like when an admin install is side by side with `dotnetup` installs. It should also preserve the `cwd` of the shell and the other environment variables contained within. Since we use `shell=true`, environment variables are expanded by the shell and we don't need to call `ExpandEnvironmentVariables` manually.
+
+When cross-architecture support is added, we should consider setting variables such as `DOTNET_ROOT_x64`.
+
+### Future: Native AOT In-Process Invocation
+
+If `dotnetup` is published as native AOT, we could potentially invoke the dotnet hive's `hostfxr` directly in-process instead of spawning a subprocess. This would eliminate the process overhead entirely. This is a future work item — for now, we use the subprocess approach.
diff --git a/documentation/general/dotnetup/designs/dotnetup-multi-architecture.md b/documentation/general/dotnetup/designs/dotnetup-multi-architecture.md
new file mode 100644
index 000000000000..a19ac8cdb506
--- /dev/null
+++ b/documentation/general/dotnetup/designs/dotnetup-multi-architecture.md
@@ -0,0 +1,6 @@
+### Cross Architecture Considerations
+
+One scenario our admin installers support is installing the x64 .NET SDK on an ARM64 device.
+We should consider how to handle these hives in `dotnetup`.
+
+When making this implementation, please also consider whether we should set `DOTNET_ROOT_x64` with `dotnetup dotnet <>` as defined by [`the dotnetup dotnet command design`](./dotnetup-dotnet.md).
diff --git a/eng/restore-toolset.ps1 b/eng/restore-toolset.ps1
index d4481156073b..cece3837b488 100644
--- a/eng/restore-toolset.ps1
+++ b/eng/restore-toolset.ps1
@@ -26,11 +26,8 @@ function InitializeCustomSDKToolset {
if ($env:DOTNET_INSTALL_TEST_RUNTIMES -ne 'false') {
# Build dotnetup if not already present (needs SDK to be installed first)
EnsureDotnetupBuilt
- InstallDotNetSharedFramework "6.0.0"
- InstallDotNetSharedFramework "7.0.0"
- InstallDotNetSharedFramework "8.0.0"
- InstallDotNetSharedFramework "9.0.0"
- InstallDotNetSharedFramework "10.0.0"
+
+ InstallDotNetSharedFrameworks "6.0.0", "7.0.0", "8.0.0", "9.0.0", "10.0.0"
}
CreateBuildEnvScripts
@@ -151,18 +148,26 @@ function CreateVSShortcut() {
$shortcut.Save()
}
-function InstallDotNetSharedFramework([string]$version) {
+function InstallDotNetSharedFrameworks([string[]]$versions) {
$dotnetRoot = $env:DOTNET_INSTALL_DIR
- $fxDir = Join-Path $dotnetRoot "shared\Microsoft.NETCore.App\$version"
+ $dotnetupExe = Join-Path $PSScriptRoot "dotnetup\dotnetup.exe"
- if (!(Test-Path $fxDir)) {
- $dotnetupExe = Join-Path $PSScriptRoot "dotnetup\dotnetup.exe"
+ $versionsToInstall = @()
+ foreach ($version in $versions) {
+ $fxDir = Join-Path $dotnetRoot "shared\Microsoft.NETCore.App\$version"
+ if (!(Test-Path $fxDir)) {
+ $versionsToInstall += $version
+ }
+ }
- & $dotnetupExe runtime install "$version" --install-path $dotnetRoot --no-progress --set-default-install false --untracked
+ if ($versionsToInstall.Count -eq 0) {
+ return
+ }
- if ($lastExitCode -ne 0) {
- throw "Failed to install shared Framework $version to '$dotnetRoot' using dotnetup (exit code '$lastExitCode')."
- }
+ & $dotnetupExe runtime install @versionsToInstall --install-path $dotnetRoot --no-progress --set-default-install false --untracked --interactive false
+
+ if ($lastExitCode -ne 0) {
+ throw "Failed to install shared frameworks ($($versionsToInstall -join ', ')) to '$dotnetRoot' using dotnetup (exit code '$lastExitCode')."
}
}
diff --git a/eng/restore-toolset.sh b/eng/restore-toolset.sh
index 3d448b2cf57a..9134acb93444 100644
--- a/eng/restore-toolset.sh
+++ b/eng/restore-toolset.sh
@@ -31,11 +31,7 @@ function InitializeCustomSDKToolset {
if [[ "${DOTNET_INSTALL_TEST_RUNTIMES:-true}" != "false" ]]; then
# Build dotnetup if not already present (needs SDK to be installed first)
EnsureDotnetupBuilt
- InstallDotNetSharedFramework "6.0.0"
- InstallDotNetSharedFramework "7.0.0"
- InstallDotNetSharedFramework "8.0.0"
- InstallDotNetSharedFramework "9.0.0"
- InstallDotNetSharedFramework "10.0.0"
+ InstallDotNetSharedFrameworks "6.0.0" "7.0.0" "8.0.0" "9.0.0" "10.0.0"
fi
CreateBuildEnvScript
@@ -78,23 +74,31 @@ function EnsureDotnetupBuilt {
fi
}
-# Installs additional shared frameworks for testing purposes
-function InstallDotNetSharedFramework {
- local version=$1
+# Installs additional shared frameworks for testing purposes (batched, concurrent)
+function InstallDotNetSharedFrameworks {
local dotnet_root=$DOTNET_INSTALL_DIR
- local fx_dir="$dotnet_root/shared/Microsoft.NETCore.App/$version"
+ local versions_to_install=()
- if [[ ! -d "$fx_dir" ]]; then
- local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
- local dotnetup_exe="$script_dir/dotnetup/dotnetup"
+ for version in "$@"; do
+ local fx_dir="$dotnet_root/shared/Microsoft.NETCore.App/$version"
+ if [[ ! -d "$fx_dir" ]]; then
+ versions_to_install+=("$version")
+ fi
+ done
- "$dotnetup_exe" runtime install "$version" --install-path "$dotnet_root" --no-progress --set-default-install false --untracked
- local lastexitcode=$?
+ if [[ ${#versions_to_install[@]} -eq 0 ]]; then
+ return
+ fi
- if [[ $lastexitcode != 0 ]]; then
- echo "Failed to install Shared Framework $version to '$dotnet_root' using dotnetup (exit code '$lastexitcode')."
- ExitWithExitCode $lastexitcode
- fi
+ local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ local dotnetup_exe="$script_dir/dotnetup/dotnetup"
+
+ "$dotnetup_exe" runtime install "${versions_to_install[@]}" --install-path "$dotnet_root" --no-progress --set-default-install false --untracked --interactive false
+ local lastexitcode=$?
+
+ if [[ $lastexitcode != 0 ]]; then
+ echo "Failed to install shared frameworks (${versions_to_install[*]}) to '$dotnet_root' using dotnetup (exit code '$lastexitcode')."
+ ExitWithExitCode $lastexitcode
fi
}
diff --git a/src/Installer/.editorconfig b/src/Installer/.editorconfig
index af4854297069..6830a0145eae 100644
--- a/src/Installer/.editorconfig
+++ b/src/Installer/.editorconfig
@@ -673,4 +673,44 @@ dotnet_diagnostic.IDE0005.severity = silent
dotnet_diagnostic.CA2007.severity = silent
[*.{cs,vb}]
-dotnet_diagnostic.ASPIREEVENTING001.severity = none
\ No newline at end of file
+dotnet_diagnostic.ASPIREEVENTING001.severity = none
+
+# Meziantou.Analyzer rules scoped to dotnetup projects only
+[{dotnetup/**.cs,Microsoft.Dotnet.Installation/**.cs,dotnetup.Tests/**.cs}]
+
+# MA0051: Method is too long (max 60 lines)
+dotnet_diagnostic.MA0051.severity = warning
+dotnet_code_quality.MA0051.maximum_lines_per_method = 60
+dotnet_code_quality.MA0051.skip_local_functions = true
+
+# Meziantou.Analyzer rules scoped to dotnetup projects only
+[{dotnetup/**.cs,Microsoft.Dotnet.Installation/**.cs,dotnetup.Tests/**.cs}]
+
+# MA0051: Method is too long (max 60 lines; enforced as build error via TreatWarningsAsErrors)
+dotnet_diagnostic.MA0051.severity = warning
+dotnet_code_quality.MA0051.maximum_lines_per_method = 60
+dotnet_code_quality.MA0051.skip_local_functions = true
+
+# Suppress rules we don't want to enforce in dotnetup
+# MA0026/MA0025: TODO/FIXME comments and throw NotImplementedException are acceptable
+dotnet_diagnostic.MA0026.severity = none
+dotnet_diagnostic.MA0025.severity = none
+# MA0048: Multiple types per file is an accepted C# convention here
+dotnet_diagnostic.MA0048.severity = none
+# MA0008: StructLayout not required for our value types
+dotnet_diagnostic.MA0008.severity = none
+# MA0009: Regex DoS - our patterns are anchored/bounded
+dotnet_diagnostic.MA0009.severity = none
+# MA0006/MA0099/MA0144/MA0011: Fired in linked NativeWrapper code we don't own
+dotnet_diagnostic.MA0006.severity = none
+dotnet_diagnostic.MA0099.severity = none
+dotnet_diagnostic.MA0144.severity = none
+dotnet_diagnostic.MA0011.severity = none
+# MA0002: Distinct() without IEqualityComparer - string default equality is correct here
+dotnet_diagnostic.MA0002.severity = none
+# MA0016: Prefer collection abstraction - suppressed, concrete types used intentionally here
+dotnet_diagnostic.MA0016.severity = none
+# MA0084: Local variable hiding parameter suppressed - handled case-by-case
+dotnet_diagnostic.MA0084.severity = none
+# MA0158: Use System.Threading.Lock - suppressed, lock(object) is acceptable for our use case
+dotnet_diagnostic.MA0158.severity = none
diff --git a/src/Installer/.github/copilot-instructions.md b/src/Installer/.github/copilot-instructions.md
index a1b295c089de..b794bfafe14b 100644
--- a/src/Installer/.github/copilot-instructions.md
+++ b/src/Installer/.github/copilot-instructions.md
@@ -6,6 +6,11 @@ applyTo: "**"
- 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
+- New class definitions should live inside a separate file. Unless specified, you should almost NEVER create a new class or type definition inside of an existing class. If you do, it should NEVER be public - if that's the case it should live in a separate class file.
+- Refrain from returning tuples from methods in most cases - either separate the method, or if it makes sense to share the responsibility, then a record, struct, or class should be returned.
+- Always look for existing code which can be used to make sure you're not inventing something already done.
+- Follow the single responsibility principle. The name of a class should strictly delegate its purpose. A class should NEVER solve more than one core purpose or type of logic. Example: a class named `InstallPathResolver` should only be responsible for resolving install paths, and should not also be responsible for prompting the user for input. Add comments to the top of classes specifically outlining their responsibility and assumptions.
+- If you're about to add a compiler or style warning disable inline, RECONSIDER. Please see if you can minimally fix the code to follow the rule instead. If you still decide to add something like #pragma warning disable, STOP and ask for permission stating why you want to do this.
Code Style:
- An `.editorconfig` at `src/Installer/.editorconfig` governs all dotnetup code. Follow it strictly for new code.
@@ -14,3 +19,74 @@ Code Style:
- To auto-format a project: `d:\sdk\.dotnet\dotnet format --no-restore`
- When debugging or iterating on a bug fix, it is fine to temporarily ignore style issues and fix them afterward. Prefer working code over perfect formatting during active troubleshooting.
- Do not reformat unrelated code in the same commit as a bug fix — keep formatting changes in separate commits to preserve clean git blame.
+
+Testing:
+- When running tests after a change, first run only the tests relevant to the code you modified. Use `--filter` to target specific test classes or methods (e.g., `--filter "FullyQualifiedName~ParserTests"`) rather than running the entire test suite.
+- Only run the full test suite if the targeted tests pass and you have reason to believe the change could affect other areas.
+
+Concurrency:
+- Multiple agents or terminals may be building or running tests concurrently in this workspace. To avoid file-lock conflicts on the dotnetup executable and build outputs, **always build and test into an isolated output directory** using `/p:ArtifactsDir=`.
+- Choose a short, descriptive name based on what you are working on (e.g., the bug, feature, or test class name). Use that name to create a unique artifacts path under `d:\sdk\artifacts\tmp\`.
+- Build command: `d:\sdk\.dotnet\dotnet build d:\sdk\src\Installer\dotnetup\dotnetup.csproj "/p:ArtifactsDir=d:\sdk\artifacts\tmp\\"`
+- Test command: `d:\sdk\.dotnet\dotnet test d:\sdk\test\dotnetup.Tests\dotnetup.Tests.csproj "/p:ArtifactsDir=d:\sdk\artifacts\tmp\\"`
+- Use the **same** `/p:ArtifactsDir=` value for both the build and the test so the test project picks up the build output.
+- Example for a parser fix:
+ ```
+ d:\sdk\.dotnet\dotnet build d:\sdk\src\Installer\dotnetup\dotnetup.csproj "/p:ArtifactsDir=d:\sdk\artifacts\tmp\parser-fix\"
+ d:\sdk\.dotnet\dotnet test d:\sdk\test\dotnetup.Tests\dotnetup.Tests.csproj "/p:ArtifactsDir=d:\sdk\artifacts\tmp\parser-fix\" --filter "FullyQualifiedName~ParserTests"
+ ```
+- Clean up temporary artifacts directories when you are done: `Remove-Item -Recurse -Force d:\sdk\artifacts\tmp\`
+
+Terminal output handling:
+- The `run_in_terminal` tool uses a **single shared shell** for all non-background calls. It reads the entire accumulated buffer — NOT just your command's output. After many commands, the buffer exceeds 60KB and gets truncated with `[... PREVIOUS OUTPUT TRUNCATED ...]`.
+- **Do NOT** try to work around this by launching a sub-shell (`pwsh -Command "..."`). The tool still reads the outer terminal's full buffer.
+- **Recommended: redirect to file**. Pipe output to a temp file, then use `read_file` to read the results:
+ ```
+ d:\sdk\.dotnet\dotnet build "/p:ArtifactsDir=..." 2>&1 | Out-File d:\sdk\artifacts\tmp\\build-output.txt
+ ```
+ Then use the `read_file` tool on `d:\sdk\artifacts\tmp\\build-output.txt` to inspect results. Read the last ~50 lines first to check for errors.
+- **Alternative: filter inline** if you only need pass/fail: `2>&1 | Select-String "error|Build succeeded" | Select-Object -Last 15`
+- **Alternative: background terminal**. Use `isBackground: true` and then `get_terminal_output` with the returned ID. Each background call gets a fresh terminal with no buffer pollution.
+
+PR Feedback Resolution:
+- When asked to resolve PR feedback (e.g., "resolve PR feedback for https://github.com/dotnet/sdk/pull/12345"), follow this workflow:
+
+1. **Gather comments** — Use the GitHub MCP server tools (`mcp_github_pull_request_read`, `mcp_github_list_pull_requests`, etc.) or git MCP tools to fetch all review comments and conversation threads from the PR. Identify which comments are unresolved vs already resolved/outdated.
+
+2. **Create a plan document** — Generate `src/Installer/pr-feedback-plan.md` with all comments organized by size category:
+ - **Already Resolved** — Comments that are outdated or already addressed. Table format with columns: #, File, Comment, Link, Status.
+ - **Quick Fixes** — Renames, comment additions, single-line changes.
+ - **Medium Fixes** — Multi-file refactors, method extractions, logic changes.
+ - **Large / Investigation Items** — Architecture changes, cross-cutting concerns, items needing research.
+ Each item gets a unique ID (Q1, Q2, ..., M1, M2, ..., L1, L2, etc.), a link to the GitHub discussion comment, the reviewer's comment text, and a status field.
+
+3. **Execute fixes** — Work through items in size order (Quick first, then Medium, then Large). Use subagents (the `Explore` agent) for research on larger items. For each item:
+ - Build after each fix: `d:\sdk\.dotnet\dotnet build d:\sdk\src\Installer\dotnetup\dotnetup.csproj "/p:ArtifactsDir=d:\sdk\artifacts\tmp\pr-feedback\"`
+ - Run relevant tests after groups of related fixes.
+ - Mark items ✅ Done in the plan document as they are completed.
+
+4. **Update the plan document** — After all items are complete, **reorder** the entries in `pr-feedback-plan.md` to match the order they appear on the GitHub PR conversation page (i.e., by file path and line number as GitHub displays them, not by size category). This makes it easy for the author to walk through the PR on github.com and close/resolve each comment in order. Include a summary section containing:
+ - Total comments, counts per category, all marked ✅
+ - A "Files Modified" list
+ - Build and test status
+
+5. **Link format in the plan** — Use workspace-relative links for code references so they open in the IDE, NOT GitHub blob URLs. Include specific line numbers for the changed code. Example:
+ - ✅ Correct: `[InstallWalkthrough.cs](dotnetup/Commands/Shared/InstallWalkthrough.cs)`
+ - ✅ Correct with line: `[InstallWalkthrough.cs L36](dotnetup/Commands/Shared/InstallWalkthrough.cs#L36)`
+ - ❌ Wrong: `[InstallWalkthrough.cs](https://github.com/dotnet/sdk/blob/.../InstallWalkthrough.cs)`
+ - For GitHub discussion links, use the `r` format: `[r2948551306](https://github.com/dotnet/sdk/pull/53464#discussion_r2948551306)`
+
+6. **Handle TODOs** — For items that cannot be fully resolved and require a TODO:
+ - Do NOT leave silent TODOs in code. Instead, create a separate `.md` file under `src/Installer/issues/` describing the follow-up work.
+ - Format the `.md` as a GitHub issue body: title, description, acceptance criteria, and relevant code links.
+ - Include the `dotnetup` label in the frontmatter or title so it can be filed with that label.
+ - Be prepared to file these issues via the GitHub MCP server tools when asked.
+
+7. **Example plan entry** (for a completed Quick Fix):
+ ```markdown
+ ### Q1: InstallWalkthrough.cs L36 — Remove unused parameter
+ **Link:** [r2948948414](https://github.com/dotnet/sdk/pull/53464#discussion_r2948948414)
+ **Comment:** "Why are we reserving this?"
+ **Status:** ✅ Done — Removed `channelVersionResolver` parameter from constructor and the discard assignment. Updated caller in InstallWorkflow.cs.
+ **Code:** [InstallWalkthrough.cs](dotnetup/Commands/Shared/InstallWalkthrough.cs#L36), [InstallWorkflow.cs](dotnetup/Commands/Shared/InstallWorkflow.cs#L42)
+ ```
diff --git a/src/Installer/Directory.Build.props b/src/Installer/Directory.Build.props
index 314c3f8d687f..dfac2680bfaf 100644
--- a/src/Installer/Directory.Build.props
+++ b/src/Installer/Directory.Build.props
@@ -1,11 +1,14 @@
-
+
+
+ $([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)', '..', '..'))
+
+
-
- 0.1.1
+ 0.1.2preview
-
diff --git a/src/Installer/Microsoft.Dotnet.Installation/IProgressTarget.cs b/src/Installer/Microsoft.Dotnet.Installation/IProgressTarget.cs
index 19b87b461057..b4da177efdce 100644
--- a/src/Installer/Microsoft.Dotnet.Installation/IProgressTarget.cs
+++ b/src/Installer/Microsoft.Dotnet.Installation/IProgressTarget.cs
@@ -44,3 +44,35 @@ public NullProgressTask(string description)
public double MaxValue { get; set; }
}
}
+
+///
+/// Defers creation of the underlying until the first task is added.
+/// This avoids Spectre.Console rendering an empty progress bar (and leaving a blank line)
+/// when an error occurs before any progress is reported.
+///
+public sealed class LazyProgressReporter : IProgressReporter
+{
+ private readonly IProgressTarget _target;
+ private readonly object _lock = new();
+ private IProgressReporter? _inner;
+
+ public LazyProgressReporter(IProgressTarget target)
+ {
+ _target = target;
+ }
+
+ public IProgressTask AddTask(string description, double maxValue)
+ {
+ lock (_lock)
+ {
+ _inner ??= _target.CreateProgressReporter();
+ }
+
+ return _inner.AddTask(description, maxValue);
+ }
+
+ public void Dispose()
+ {
+ _inner?.Dispose();
+ }
+}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallComponent.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallComponent.cs
index 22a14324e471..5876cc217b06 100644
--- a/src/Installer/Microsoft.Dotnet.Installation/InstallComponent.cs
+++ b/src/Installer/Microsoft.Dotnet.Installation/InstallComponent.cs
@@ -13,6 +13,18 @@ public enum InstallComponent
public static class InstallComponentExtensions
{
+ // ── Framework-name constants (shared by GetFrameworkName / FromFrameworkName) ──
+ public const string RuntimeFrameworkName = "Microsoft.NETCore.App";
+ public const string AspNetCoreFrameworkName = "Microsoft.AspNetCore.App";
+ public const string WindowsDesktopFrameworkName = "Microsoft.WindowsDesktop.App";
+
+ ///
+ /// The longest display name across all components. Used to pad shorter names
+ /// so progress rows align when multiple component types are shown together.
+ ///
+ private static readonly int s_maxDisplayNameLength =
+ Enum.GetValues().Max(c => c.GetDisplayName().Length);
+
///
/// Gets the human-readable display name for the component.
/// Uses shorter, punchier names rather than the full Microsoft.* framework names.
@@ -26,6 +38,30 @@ public static class InstallComponentExtensions
_ => component.ToString()
};
+ ///
+ /// Gets the display name right-padded to the length of the longest component name,
+ /// so that version numbers align when multiple component types appear together.
+ ///
+ public static string GetPaddedDisplayName(this InstallComponent component) =>
+ component.GetDisplayName().PadRight(s_maxDisplayNameLength);
+
+ ///
+ /// Formats a version string to a fixed display width so progress rows align.
+ /// Short versions like "9.0.312" are left-padded; long versions like
+ /// "11.0.100-preview.2.26159.112" are truncated to "..59.112".
+ /// Target width matches the common format "10.0.201" (8 chars).
+ ///
+ public static string FormatVersionForDisplay(string version)
+ {
+ const int targetWidth = 8;
+ if (version.Length <= targetWidth)
+ {
+ return version.PadLeft(targetWidth);
+ }
+
+ return ".." + version[^(targetWidth - 2)..];
+ }
+
///
/// Gets the official framework name for the component (e.g., "Microsoft.NETCore.App").
/// Used for JSON/machine-readable output.
@@ -33,9 +69,21 @@ public static class InstallComponentExtensions
public static string GetFrameworkName(this InstallComponent component) => component switch
{
InstallComponent.SDK => ".NET SDK",
- InstallComponent.Runtime => "Microsoft.NETCore.App",
- InstallComponent.ASPNETCore => "Microsoft.AspNetCore.App",
- InstallComponent.WindowsDesktop => "Microsoft.WindowsDesktop.App",
+ InstallComponent.Runtime => RuntimeFrameworkName,
+ InstallComponent.ASPNETCore => AspNetCoreFrameworkName,
+ InstallComponent.WindowsDesktop => WindowsDesktopFrameworkName,
_ => component.ToString()
};
+
+ ///
+ /// Resolves a framework name (e.g. "Microsoft.NETCore.App") to the corresponding .
+ /// Returns null when the name is not recognized.
+ ///
+ public static InstallComponent? FromFrameworkName(string frameworkName) => frameworkName switch
+ {
+ RuntimeFrameworkName => InstallComponent.Runtime,
+ AspNetCoreFrameworkName => InstallComponent.ASPNETCore,
+ WindowsDesktopFrameworkName => InstallComponent.WindowsDesktop,
+ _ => null
+ };
}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs
index 6ae34e30e08f..1f23a873f31f 100644
--- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs
@@ -127,41 +127,35 @@ public static bool IsValidChannelFormat(string channel)
}
}
- if (parts.Length >= 3)
+ if (parts.Length >= 3 && !IsValidPatchPart(parts[2], hasPrerelease))
{
- var patch = parts[2];
- if (string.IsNullOrEmpty(patch))
- {
- return false;
- }
+ return false;
+ }
- // Allow either:
- // - a fully specified numeric patch (e.g., "103"), optionally with a prerelease suffix, or
- // - a feature band pattern with a numeric prefix and "xx" suffix (e.g., "1xx", "101xx"),
- // but NOT with a prerelease suffix (wildcards with prerelease not supported).
- if (patch.EndsWith("xx", StringComparison.OrdinalIgnoreCase))
- {
- if (hasPrerelease)
- {
- return false;
- }
+ return true;
+ }
- var prefix = patch.Substring(0, patch.Length - 2);
- if (prefix.Length == 0 || !int.TryParse(prefix, out _))
- {
- return false;
- }
- }
- else
+ private static bool IsValidPatchPart(string patch, bool hasPrerelease)
+ {
+ if (string.IsNullOrEmpty(patch))
+ {
+ return false;
+ }
+
+ // Allow feature band pattern (e.g., "1xx", "101xx"), but NOT with a prerelease suffix.
+ if (patch.EndsWith("xx", StringComparison.OrdinalIgnoreCase))
+ {
+ if (hasPrerelease)
{
- if (!int.TryParse(patch, out var numericPatch) || numericPatch < 0)
- {
- return false;
- }
+ return false;
}
+
+ var prefix = patch.Substring(0, patch.Length - 2);
+ return prefix.Length > 0 && int.TryParse(prefix, out _);
}
- return true;
+ // Allow fully specified numeric patch (e.g., "103"), optionally with a prerelease suffix.
+ return int.TryParse(patch, out var numericPatch) && numericPatch >= 0;
}
///
@@ -181,7 +175,7 @@ private static (int Major, int Minor, string? FeatureBand, bool IsFullySpecified
if (parts.Length >= 3)
{
- if (parts[2].EndsWith("xx"))
+ if (parts[2].EndsWith("xx", StringComparison.OrdinalIgnoreCase))
{
// Feature band pattern (e.g., "1xx")
featureBand = parts[2].Substring(0, parts[2].Length - 2);
@@ -254,7 +248,7 @@ private static (int Major, int Minor, string? FeatureBand, bool IsFullySpecified
private static IEnumerable GetProductsInMajorOrMajorMinor(IEnumerable index, int major, int? minor = null)
{
- var validProducts = index.Where(p => minor is not null ? p.ProductVersion.Equals($"{major}.{minor}") : p.ProductVersion.StartsWith($"{major}."));
+ var validProducts = index.Where(p => minor is not null ? p.ProductVersion.Equals($"{major}.{minor}") : p.ProductVersion.StartsWith($"{major}.", StringComparison.Ordinal));
return validProducts;
}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs
index 4941d834a6c4..874cf3a6a387 100644
--- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs
@@ -26,10 +26,10 @@ public DotnetArchiveDownloader()
{
}
- public DotnetArchiveDownloader(ReleaseManifest releaseManifest, HttpClient? httpClient = null)
+ public DotnetArchiveDownloader(ReleaseManifest releaseManifest, HttpClient? httpClient = null, string? cacheDirectory = null)
{
_releaseManifest = releaseManifest ?? throw new ArgumentNullException(nameof(releaseManifest));
- _downloadCache = new DownloadCache();
+ _downloadCache = new DownloadCache(cacheDirectory);
if (httpClient == null)
{
_httpClient = CreateDefaultHttpClient();
@@ -83,73 +83,21 @@ private static HttpClient CreateDefaultHttpClient()
/// Optional progress reporting
private 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";
+ Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
for (int attempt = 1; attempt <= MaxRetryCount; attempt++)
{
try
{
- // Ensure the directory exists
- Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
-
- // Content length for progress reporting
- long? totalBytes = null;
-
- // Make the actual download request
- using var response = await _httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
- response.EnsureSuccessStatusCode();
-
- if (response.Content.Headers.ContentLength.HasValue)
- {
- totalBytes = response.Content.Headers.ContentLength.Value;
- }
-
- using var contentStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
- 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.MinValue;
-
- while ((read = await contentStream.ReadAsync(buffer).ConfigureAwait(false)) > 0)
- {
- await fileStream.WriteAsync(buffer.AsMemory(0, read)).ConfigureAwait(false);
-
- 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().ConfigureAwait(false);
- fileStream.Close();
-
- // Atomic move to final destination
- if (File.Exists(destinationPath))
- {
- File.Delete(destinationPath);
- }
- File.Move(tempPath, destinationPath);
-
+ await DownloadAttemptAsync(downloadUrl, tempPath, destinationPath, progress).ConfigureAwait(false);
return;
}
catch (Exception)
{
if (attempt < MaxRetryCount)
{
- await Task.Delay(RetryDelayMilliseconds * attempt).ConfigureAwait(false); // Linear backoff
+ await Task.Delay(RetryDelayMilliseconds * attempt).ConfigureAwait(false);
}
else
{
@@ -158,22 +106,59 @@ private async Task DownloadArchiveAsync(string downloadUrl, string destinationPa
}
finally
{
- // Delete the partial download if it exists
- try
- {
- if (File.Exists(tempPath))
- {
- File.Delete(tempPath);
- }
- }
- catch
- {
- // Ignore cleanup errors
- }
+ try { if (File.Exists(tempPath)) { File.Delete(tempPath); } }
+ catch { /* Ignore cleanup errors */ }
+ }
+ }
+ }
+ private async Task DownloadAttemptAsync(string downloadUrl, string tempPath, string destinationPath, IProgress? progress)
+ {
+ using var response = await _httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ long? totalBytes = response.Content.Headers.ContentLength;
+ using var contentStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ using var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
+
+ await CopyStreamWithProgressAsync(contentStream, fileStream, totalBytes, progress).ConfigureAwait(false);
+
+ await fileStream.FlushAsync().ConfigureAwait(false);
+ fileStream.Close();
+
+ CommitDownload(tempPath, destinationPath);
+ }
+
+ private static async Task CopyStreamWithProgressAsync(Stream source, Stream destination, long? totalBytes, IProgress? progress)
+ {
+ var buffer = new byte[81920]; // 80KB buffer
+ long bytesRead = 0;
+ int read;
+ var lastProgressReport = DateTime.MinValue;
+
+ while ((read = await source.ReadAsync(buffer).ConfigureAwait(false)) > 0)
+ {
+ await destination.WriteAsync(buffer.AsMemory(0, read)).ConfigureAwait(false);
+ bytesRead += read;
+
+ var now = DateTime.UtcNow;
+ if ((now - lastProgressReport).TotalMilliseconds > 100)
+ {
+ lastProgressReport = now;
+ progress?.Report(new DownloadProgress(bytesRead, totalBytes));
}
}
+ progress?.Report(new DownloadProgress(bytesRead, totalBytes));
+ }
+
+ private static void CommitDownload(string tempPath, string destinationPath)
+ {
+ if (File.Exists(destinationPath))
+ {
+ File.Delete(destinationPath);
+ }
+ File.Move(tempPath, destinationPath);
}
///
@@ -200,6 +185,28 @@ public void DownloadArchiveWithVerification(
ReleaseVersion resolvedVersion,
string destinationPath,
IProgress? progress = null)
+ {
+ var (downloadUrl, expectedHash) = ResolveManifestEntry(installRequest, resolvedVersion);
+
+ Activity.Current?.SetTag("download.url_domain", UrlSanitizer.SanitizeDomain(downloadUrl));
+
+ if (TryServeCachedArchive(downloadUrl, expectedHash, destinationPath, progress))
+ {
+ return;
+ }
+
+ DownloadArchive(downloadUrl, destinationPath, progress);
+ VerifyFileHash(destinationPath, expectedHash);
+
+ var fileInfo = new FileInfo(destinationPath);
+ Activity.Current?.SetTag("download.bytes", fileInfo.Length);
+ Activity.Current?.SetTag("download.from_cache", false);
+
+ try { _downloadCache.AddToCache(downloadUrl, destinationPath); }
+ catch { /* Ignore errors adding to cache - it's not critical */ }
+ }
+
+ private (string DownloadUrl, string ExpectedHash) ResolveManifestEntry(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion)
{
var targetFile = _releaseManifest.FindReleaseFile(installRequest, resolvedVersion);
@@ -212,8 +219,8 @@ public void DownloadArchiveWithVerification(
component: installRequest.Component.ToString());
}
- string? downloadUrl = targetFile.Address.ToString();
- string? expectedHash = targetFile.Hash.ToString();
+ string downloadUrl = targetFile.Address.ToString();
+ string expectedHash = targetFile.Hash.ToString();
if (string.IsNullOrEmpty(expectedHash))
{
@@ -223,6 +230,7 @@ public void DownloadArchiveWithVerification(
version: resolvedVersion.ToString(),
component: installRequest.Component.ToString());
}
+
if (string.IsNullOrEmpty(downloadUrl))
{
throw new DotnetInstallException(
@@ -232,53 +240,32 @@ public void DownloadArchiveWithVerification(
component: installRequest.Component.ToString());
}
- Activity.Current?.SetTag("download.url_domain", UrlSanitizer.SanitizeDomain(downloadUrl));
+ return (downloadUrl, expectedHash);
+ }
- // Check the cache first
+ private bool TryServeCachedArchive(string downloadUrl, string expectedHash, string destinationPath, IProgress? progress)
+ {
string? cachedFilePath = _downloadCache.GetCachedFilePath(downloadUrl);
- if (cachedFilePath != null)
+ if (cachedFilePath == null)
{
- try
- {
- // Verify the cached file's hash
- VerifyFileHash(cachedFilePath, expectedHash);
-
- // Copy from cache to destination
- File.Copy(cachedFilePath, destinationPath, overwrite: true);
-
- // Report 100% progress immediately since we're using cache
- var cachedFileSize = new FileInfo(cachedFilePath).Length;
- progress?.Report(new DownloadProgress(cachedFileSize, cachedFileSize));
-
- var cachedFileInfo = new FileInfo(cachedFilePath);
- Activity.Current?.SetTag("download.bytes", cachedFileInfo.Length);
- Activity.Current?.SetTag("download.from_cache", true);
- return;
- }
- catch
- {
- // If cached file is corrupted, fall through to download
- }
+ return false;
}
- // Download the file if not in cache or cache is invalid
- DownloadArchive(downloadUrl, destinationPath, progress);
-
- // Verify the downloaded file
- VerifyFileHash(destinationPath, expectedHash);
-
- var fileInfo = new FileInfo(destinationPath);
- Activity.Current?.SetTag("download.bytes", fileInfo.Length);
- Activity.Current?.SetTag("download.from_cache", false);
-
- // Add the verified file to the cache
try
{
- _downloadCache.AddToCache(downloadUrl, destinationPath);
+ VerifyFileHash(cachedFilePath, expectedHash);
+ File.Copy(cachedFilePath, destinationPath, overwrite: true);
+
+ var cachedFileSize = new FileInfo(cachedFilePath).Length;
+ progress?.Report(new DownloadProgress(cachedFileSize, cachedFileSize));
+
+ Activity.Current?.SetTag("download.bytes", cachedFileSize);
+ Activity.Current?.SetTag("download.from_cache", true);
+ return true;
}
catch
{
- // Ignore errors adding to cache - it's not critical
+ return false; // Cached file corrupted — fall through to download
}
}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs
index 6d32fe803761..efbe80cc5b22 100644
--- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs
@@ -11,9 +11,10 @@ internal class DotnetArchiveExtractor : IDisposable
{
private readonly DotnetInstallRequest _request;
private readonly ReleaseVersion _resolvedVersion;
- private readonly IProgressTarget _progressTarget;
+ private readonly IProgressTarget? _progressTarget;
private readonly IArchiveDownloader _archiveDownloader;
private readonly bool _shouldDisposeDownloader;
+ private readonly bool _ownsProgressReporter = true;
private MuxerHandler? MuxerHandler { get; set; }
private string? _archivePath;
private IProgressReporter? _progressReporter;
@@ -29,11 +30,39 @@ public DotnetArchiveExtractor(
ReleaseVersion resolvedVersion,
ReleaseManifest releaseManifest,
IProgressTarget progressTarget,
- IArchiveDownloader? archiveDownloader = null)
+ IArchiveDownloader? archiveDownloader = null,
+ string? cacheDirectory = null)
+ : this(request, resolvedVersion, releaseManifest, archiveDownloader, cacheDirectory)
+ {
+ _progressTarget = progressTarget;
+ }
+
+ ///
+ /// Constructor that accepts a shared IProgressReporter, allowing multiple extractors
+ /// to display progress tasks within the same progress widget.
+ ///
+ public DotnetArchiveExtractor(
+ DotnetInstallRequest request,
+ ReleaseVersion resolvedVersion,
+ ReleaseManifest releaseManifest,
+ IProgressReporter sharedReporter,
+ IArchiveDownloader? archiveDownloader = null,
+ string? cacheDirectory = null)
+ : this(request, resolvedVersion, releaseManifest, archiveDownloader, cacheDirectory)
+ {
+ _progressReporter = sharedReporter;
+ _ownsProgressReporter = false;
+ }
+
+ private DotnetArchiveExtractor(
+ DotnetInstallRequest request,
+ ReleaseVersion resolvedVersion,
+ ReleaseManifest releaseManifest,
+ IArchiveDownloader? archiveDownloader,
+ string? cacheDirectory = null)
{
_request = request;
_resolvedVersion = resolvedVersion;
- _progressTarget = progressTarget;
ScratchDownloadDirectory = Directory.CreateTempSubdirectory().FullName;
if (archiveDownloader != null)
@@ -43,7 +72,7 @@ public DotnetArchiveExtractor(
}
else
{
- _archiveDownloader = new DotnetArchiveDownloader(releaseManifest);
+ _archiveDownloader = new DotnetArchiveDownloader(releaseManifest, cacheDirectory: cacheDirectory);
_shouldDisposeDownloader = true;
}
}
@@ -56,8 +85,9 @@ public DotnetArchiveExtractor(
///
/// Gets or creates the shared progress reporter for both Prepare and Commit phases.
/// This avoids multiple newlines from Spectre.Console Progress between phases.
+ /// When a shared reporter was provided via the constructor, that instance is returned directly.
///
- private IProgressReporter ProgressReporter => _progressReporter ??= _progressTarget.CreateProgressReporter();
+ private IProgressReporter ProgressReporter => _progressReporter ??= _progressTarget!.CreateProgressReporter();
public void Prepare()
{
@@ -67,9 +97,9 @@ public void Prepare()
var archiveName = $"dotnet-{Guid.NewGuid()}";
_archivePath = Path.Combine(ScratchDownloadDirectory, archiveName + DotnetupUtilities.GetArchiveFileExtensionForPlatform());
- string componentDescription = _request.Component.GetDisplayName();
- var downloadTask = ProgressReporter.AddTask($"Downloading {componentDescription} {_resolvedVersion}", 100);
- var reporter = new DownloadProgressReporter(downloadTask, $"Downloading {componentDescription} {_resolvedVersion}");
+ string description = ProgressFormatting.FormatProgressDescription("Downloading", _request.Component, _resolvedVersion.ToString());
+ var downloadTask = ProgressReporter.AddTask(description, 100);
+ var reporter = new DownloadProgressReporter(downloadTask, description);
try
{
@@ -99,6 +129,9 @@ public void Prepare()
}
downloadTask.Value = 100;
+ long archiveBytes = new FileInfo(_archivePath).Length;
+ string downloadedDesc = ProgressFormatting.FormatProgressDescription("Downloaded", _request.Component, _resolvedVersion.ToString());
+ downloadTask.Description = $"{downloadedDesc} ({ProgressFormatting.FormatMB(archiveBytes)} / {ProgressFormatting.FormatMB(archiveBytes)})";
}
public void Commit()
@@ -108,18 +141,28 @@ public void Commit()
_extractedSubcomponents.Clear();
- string componentDescription = _request.Component.GetDisplayName();
- var installTask = ProgressReporter.AddTask($"Installing {componentDescription} {_resolvedVersion}", maxValue: 100);
+ string description = ProgressFormatting.FormatProgressDescription("Installing", _request.Component, _resolvedVersion.ToString());
+ // Pad to match the width of "Downloading" rows (which have an MB suffix)
+ // so all progress rows stay aligned within the shared Spectre column.
+ string paddedDescription = description + new string(' ', ProgressFormatting.DownloadSuffixWidth);
+ var installTask = ProgressReporter.AddTask(paddedDescription, maxValue: 100);
- try
+ if (_archivePath is null)
{
- if (_archivePath is null)
- {
- throw new InvalidOperationException("Prepare() must be called before Commit().");
- }
+ throw new InvalidOperationException("Prepare() must be called before Commit().");
+ }
- // Extract archive directly to target directory with special handling for muxer
- ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, installTask);
+ ExtractWithExceptionHandling(_archivePath, _request.InstallRoot.Path!, installTask);
+
+ // Switch to past tense and drop the trailing padding now that the task is complete.
+ installTask.Description = ProgressFormatting.FormatProgressDescription("Installed", _request.Component, _resolvedVersion.ToString());
+ }
+
+ private void ExtractWithExceptionHandling(string archivePath, string targetPath, IProgressTask installTask)
+ {
+ try
+ {
+ ExtractArchiveDirectlyToTarget(archivePath, targetPath, installTask);
installTask.Value = installTask.MaxValue;
}
catch (DotnetInstallException)
@@ -128,7 +171,6 @@ public void Commit()
}
catch (InvalidDataException ex)
{
- // Archive is corrupted (invalid zip/tar format)
throw new DotnetInstallException(
DotnetInstallErrorCode.ArchiveCorrupted,
$"Archive is corrupted or truncated for version {_resolvedVersion}: {ex.Message}",
@@ -138,7 +180,6 @@ public void Commit()
}
catch (UnauthorizedAccessException ex)
{
- // User environment issue — insufficient permissions to write to install directory
throw new DotnetInstallException(
DotnetInstallErrorCode.PermissionDenied,
$"Permission denied while extracting .NET archive for version {_resolvedVersion}: {ex.Message}",
@@ -148,9 +189,6 @@ public void Commit()
}
catch (IOException ex)
{
- // IO errors during extraction (disk full, permission denied, path too long, etc.)
- // Wrap as ExtractionFailed and preserve the original IOException.
- // The telemetry layer classifies the inner IOException by HResult via ErrorCategoryClassifier.
throw new DotnetInstallException(
DotnetInstallErrorCode.ExtractionFailed,
$"Failed to extract .NET archive for version {_resolvedVersion}: {ex.Message}",
@@ -160,7 +198,6 @@ public void Commit()
}
catch (Exception ex)
{
- // Genuine extraction issue we should investigate — product error
throw new DotnetInstallException(
DotnetInstallErrorCode.ExtractionFailed,
$"Failed to extract .NET archive for version {_resolvedVersion}: {ex.Message}",
@@ -188,7 +225,7 @@ private void ExtractArchiveDirectlyToTarget(string archivePath, string targetDir
// Build a predicate that skips entries whose subcomponent already exists on disk.
// The archive is still downloaded and all subcomponents are tracked for the manifest,
// but extraction is skipped to avoid overwriting files from an earlier installation.
- var shouldSkipEntry = CreateExistingSubcomponentSkipPredicate(targetDir);
+ var shouldSkipEntry = CreateExistingSubcomponentSkipPredicate(targetDir, _request.Options.Verbosity);
// Extract archive, redirecting muxer to temp path and skipping existing subcomponents
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
@@ -211,11 +248,11 @@ private void ExtractArchiveDirectlyToTarget(string archivePath, string targetDir
/// with an already-installed SDK). The entry is still reported to the
/// onEntryExtracted callback so the subcomponent is recorded in the manifest.
///
- private static Func CreateExistingSubcomponentSkipPredicate(string targetDir)
+ private static Func CreateExistingSubcomponentSkipPredicate(string targetDir, Verbosity verbosity)
{
var cache = new Dictionary(StringComparer.Ordinal);
- return (string entryName) =>
+ return entryName =>
{
var subcomponentId = SubcomponentResolver.Resolve(entryName);
if (subcomponentId is null)
@@ -229,9 +266,9 @@ private static Func CreateExistingSubcomponentSkipPredicate(string
exists = Directory.Exists(subcomponentPath);
cache[subcomponentId] = exists;
- if (exists)
+ if (exists && verbosity >= Verbosity.Detailed)
{
- Console.WriteLine($"Subcomponent '{subcomponentId}' already exists on disk, skipping extraction.");
+ Console.Error.WriteLine($"Subcomponent '{subcomponentId}' already exists on disk, skipping extraction.");
}
}
@@ -347,60 +384,64 @@ internal static void ExtractTarContents(string tarPath, string targetDir, IProgr
var tarReader = new TarReader(tarStream);
TarEntry? entry;
- // Defer hard link creation until after all regular files are extracted,
- // since the target file may not exist yet when the hard link entry is encountered.
var deferredHardLinks = new List<(string DestPath, string TargetPath)>();
while ((entry = tarReader.GetNextEntry()) is not null)
{
bool skip = shouldSkipEntry?.Invoke(entry.Name) ?? false;
-
if (!skip)
{
- if (entry.EntryType == TarEntryType.RegularFile)
- {
- string destPath = ResolveEntryDestPath(entry.Name, targetDir, muxerHandler);
- Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
- // ExtractToFile handles Unix permissions automatically via entry.Mode
- entry.ExtractToFile(destPath, overwrite: true);
- }
- else if (entry.EntryType == TarEntryType.Directory)
- {
- string dirPath = ResolveEntryDestPath(entry.Name, targetDir, muxerHandler);
- Directory.CreateDirectory(dirPath);
+ ProcessTarEntry(entry, targetDir, muxerHandler, deferredHardLinks);
+ }
- if (entry.Mode != default && !OperatingSystem.IsWindows())
- {
- File.SetUnixFileMode(dirPath, entry.Mode);
- }
- }
- else if (entry.EntryType == TarEntryType.SymbolicLink)
- {
- string destPath = ResolveEntryDestPath(entry.Name, targetDir, muxerHandler);
- Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
+ onEntryExtracted?.Invoke(entry.Name);
+ installTask?.Value += 1;
+ }
- // Remove existing file/link before creating symlink
- if (File.Exists(destPath) || Directory.Exists(destPath))
- {
- File.Delete(destPath);
- }
+ CreateDeferredHardLinks(deferredHardLinks);
+ }
- File.CreateSymbolicLink(destPath, entry.LinkName!);
- }
- else if (entry.EntryType == TarEntryType.HardLink)
- {
- string destPath = ResolveEntryDestPath(entry.Name, targetDir, muxerHandler);
- string linkTargetPath = ResolveEntryDestPath(entry.LinkName!, targetDir, muxerHandler);
- Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
- deferredHardLinks.Add((destPath, linkTargetPath));
- }
+ private static void ProcessTarEntry(TarEntry entry, string targetDir, MuxerHandler? muxerHandler, List<(string DestPath, string TargetPath)> deferredHardLinks)
+ {
+ if (entry.EntryType == TarEntryType.RegularFile)
+ {
+ string destPath = ResolveEntryDestPath(entry.Name, targetDir, muxerHandler);
+ Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
+ entry.ExtractToFile(destPath, overwrite: true);
+ }
+ else if (entry.EntryType == TarEntryType.Directory)
+ {
+ string dirPath = ResolveEntryDestPath(entry.Name, targetDir, muxerHandler);
+ Directory.CreateDirectory(dirPath);
+
+ if (entry.Mode != default && !OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(dirPath, entry.Mode);
}
+ }
+ else if (entry.EntryType == TarEntryType.SymbolicLink)
+ {
+ string destPath = ResolveEntryDestPath(entry.Name, targetDir, muxerHandler);
+ Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
- onEntryExtracted?.Invoke(entry.Name);
- installTask?.Value += 1;
+ if (File.Exists(destPath) || Directory.Exists(destPath))
+ {
+ File.Delete(destPath);
+ }
+
+ File.CreateSymbolicLink(destPath, entry.LinkName!);
+ }
+ else if (entry.EntryType == TarEntryType.HardLink)
+ {
+ string destPath = ResolveEntryDestPath(entry.Name, targetDir, muxerHandler);
+ string linkTargetPath = ResolveEntryDestPath(entry.LinkName!, targetDir, muxerHandler);
+ Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
+ deferredHardLinks.Add((destPath, linkTargetPath));
}
+ }
- // Create hard links after all regular files have been extracted
+ private static void CreateDeferredHardLinks(List<(string DestPath, string TargetPath)> deferredHardLinks)
+ {
foreach (var (destPath, targetPath) in deferredHardLinks)
{
if (File.Exists(destPath))
@@ -468,8 +509,11 @@ public void Dispose()
{
try
{
- // Dispose the progress reporter to finalize progress display
- _progressReporter?.Dispose();
+ // Dispose the progress reporter only if we own it (not shared)
+ if (_ownsProgressReporter)
+ {
+ _progressReporter?.Dispose();
+ }
}
catch
{
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstall.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstall.cs
index 3e0021682183..fedbbb8f6c97 100644
--- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstall.cs
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstall.cs
@@ -9,27 +9,42 @@ 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(
+public record DotnetInstall(
DotnetInstallRoot InstallRoot,
ReleaseVersion Version,
InstallComponent Component);
///
-/// Represents a request for a .NET installation with a channel version that will get resolved into a fully specified version.
+/// A lightweight specification for an install request before path resolution and
+/// version resolution have occurred. Holds only the component type and an optional
+/// version/channel string as provided by the user (e.g. "10.0", "latest", or null).
+///
+internal record MinimalInstallSpec(
+ InstallComponent Component,
+ string? VersionOrChannel);
+
+///
+/// Represents a request for a .NET installation.
+/// is the unresolved version channel string provided by the user
+/// (e.g. "latest", "10.0", "9.0.3xx", or a specific version like "9.0.304").
+/// It has NOT been resolved to a concrete .
+/// To obtain a resolved version, create a
+/// by resolving this request through a ChannelVersionResolver.
///
internal record DotnetInstallRequest(
DotnetInstallRoot InstallRoot,
UpdateChannel Channel,
InstallComponent Component,
- InstallRequestOptions Options)
-{
- ///
- /// Optional pre-resolved version. When set, the orchestrator uses this directly
- /// instead of resolving the channel again. The Channel is still needed for
- /// recording the install spec in the manifest.
- ///
- public ReleaseVersion? ResolvedVersion { get; init; }
-}
+ InstallRequestOptions Options);
+
+///
+/// An install request whose has been resolved to a concrete
+/// . Created by the install workflow after version
+/// resolution succeeds. The is guaranteed non-null.
+///
+internal record ResolvedInstallRequest(
+ DotnetInstallRequest Request,
+ ReleaseVersion ResolvedVersion);
internal record InstallRequestOptions()
{
@@ -65,6 +80,26 @@ internal record InstallRequestOptions()
/// already exists (e.g., during updates) to avoid creating duplicates.
///
public bool SkipInstallSpecRecording { get; init; }
+
+ ///
+ /// Controls the level of diagnostic output during installation.
+ /// Corresponds to the --verbosity CLI option.
+ ///
+ public Verbosity Verbosity { get; init; }
+}
+
+///
+/// Controls the amount of output produced during installation operations.
+/// Currently only (default) and are
+/// implemented. Future levels such as Quiet and Diagnostic can be added between
+/// or after the existing values when needed.
+///
+internal enum Verbosity
+{
+ // Future: Quiet = 0,
+ Normal = 1,
+ Detailed = 2,
+ // Future: Diagnostic = 3,
}
///
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DownloadCache.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DownloadCache.cs
index 6d9626c1d72e..e68f23c35a5e 100644
--- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DownloadCache.cs
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DownloadCache.cs
@@ -20,17 +20,17 @@ internal partial class DownloadCacheJsonContext : JsonSerializerContext
///
internal class DownloadCache
{
- private static readonly string s_cacheDirectory = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "dotnetup",
- "downloadcache");
-
- private static readonly string s_cacheIndexPath = Path.Combine(s_cacheDirectory, "cache-index.json");
-
+ private readonly string _cacheDirectory;
+ private readonly string _cacheIndexPath;
private readonly Dictionary _cacheIndex;
- public DownloadCache()
+ public DownloadCache(string? cacheDirectory = null)
{
+ _cacheDirectory = cacheDirectory ?? Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "dotnetup",
+ "downloadcache");
+ _cacheIndexPath = Path.Combine(_cacheDirectory, "cache-index.json");
_cacheIndex = LoadCacheIndex();
}
@@ -43,7 +43,7 @@ public DownloadCache()
{
if (_cacheIndex.TryGetValue(downloadUrl, out string? fileName))
{
- string filePath = Path.Combine(s_cacheDirectory, fileName);
+ string filePath = Path.Combine(_cacheDirectory, fileName);
if (File.Exists(filePath))
{
return filePath;
@@ -63,11 +63,11 @@ public DownloadCache()
public void AddToCache(string downloadUrl, string sourceFilePath)
{
// Ensure cache directory exists
- Directory.CreateDirectory(s_cacheDirectory);
+ Directory.CreateDirectory(_cacheDirectory);
// Use the filename from the download URL
string fileName = GetFileNameFromUrl(downloadUrl);
- string cachedFilePath = Path.Combine(s_cacheDirectory, fileName);
+ string cachedFilePath = Path.Combine(_cacheDirectory, fileName);
// Skip if this filename is already cached for a different URL
// (collision case - we'll download the right file when needed and hash check will catch it)
@@ -104,16 +104,16 @@ private static string GetFileNameFromUrl(string downloadUrl)
///
/// Loads the cache index from disk.
///
- private static Dictionary LoadCacheIndex()
+ private Dictionary LoadCacheIndex()
{
- if (!File.Exists(s_cacheIndexPath))
+ if (!File.Exists(_cacheIndexPath))
{
return [];
}
try
{
- string json = File.ReadAllText(s_cacheIndexPath);
+ string json = File.ReadAllText(_cacheIndexPath);
var index = JsonSerializer.Deserialize(json, DownloadCacheJsonContext.Default.DictionaryStringString);
return index ?? [];
}
@@ -131,9 +131,9 @@ private void SaveCacheIndex()
{
try
{
- Directory.CreateDirectory(s_cacheDirectory);
+ Directory.CreateDirectory(_cacheDirectory);
string json = JsonSerializer.Serialize(_cacheIndex, DownloadCacheJsonContext.Default.DictionaryStringString);
- File.WriteAllText(s_cacheIndexPath, json);
+ File.WriteAllText(_cacheIndexPath, json);
}
catch
{
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DownloadProgressReporter.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DownloadProgressReporter.cs
index 9abf0a8f7b1d..994ab7ea0582 100644
--- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DownloadProgressReporter.cs
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DownloadProgressReporter.cs
@@ -13,6 +13,10 @@ public DownloadProgressReporter(IProgressTask task, string description)
{
_task = task;
_description = description;
+ // Show placeholder MB values so the row has the same visible width as
+ // rows that are actively downloading — trailing spaces get ignored by
+ // Spectre's column layout, so we need real characters for alignment.
+ _task.Description = $"{description} ({ProgressFormatting.FormatMB(0)} / {ProgressFormatting.FormatMB(0)})";
}
public void Report(DownloadProgress value)
@@ -21,30 +25,13 @@ public void Report(DownloadProgress value)
{
_totalBytes = value.TotalBytes;
}
- if (_totalBytes.HasValue && _totalBytes.Value > 0)
+ long total = _totalBytes ?? 0;
+ if (total > 0)
{
- double percent = (double)value.BytesDownloaded / _totalBytes.Value * 100.0;
+ double percent = (double)value.BytesDownloaded / total * 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 FormattableString.Invariant($"{bytes / (1024.0 * 1024.0):F1} MB");
- }
-
- if (bytes > 1024)
- {
- return FormattableString.Invariant($"{bytes / 1024.0:F1} KB");
- }
-
- return $"{bytes} B";
+ // Always use the full two-value format to keep row width consistent
+ _task.Description = $"{_description} ({ProgressFormatting.FormatMB(value.BytesDownloaded)} / {ProgressFormatting.FormatMB(total)})";
}
}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/HostFxrWrapper.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/HostFxrWrapper.cs
index 6b283cd1bd13..5e702ada2381 100644
--- a/src/Installer/Microsoft.Dotnet.Installation/Internal/HostFxrWrapper.cs
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/HostFxrWrapper.cs
@@ -39,10 +39,11 @@ public static IEnumerable getInstalls(string installRoot)
foreach (var runtime in environmentInfo.RuntimeInfo.ToList())
{
+ var component = InstallComponentExtensions.FromFrameworkName(runtime.Name) ?? InstallComponent.Runtime;
installs.Add(new DotnetInstall(
new DotnetInstallRoot(installRoot, InstallerUtilities.GetDefaultInstallArchitecture()),
runtime.Version,
- InstallComponent.Runtime)); // TODO: Determine the correct InstallComponent based on runtime.Name like release manifest does
+ component));
}
return installs;
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/MuxerHandler.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/MuxerHandler.cs
index dda10637279c..a96bcd0f9e93 100644
--- a/src/Installer/Microsoft.Dotnet.Installation/Internal/MuxerHandler.cs
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/MuxerHandler.cs
@@ -124,23 +124,8 @@ public void FinalizeAfterExtraction()
return;
}
- // Determine if we should update the muxer
- bool shouldUpdateMuxer;
- if (_preExtractionHighestRuntimeVersion == null)
- {
- // No runtime existed before - we need the muxer
- shouldUpdateMuxer = true;
- }
- else if (postExtractionHighestRuntimeVersion > _preExtractionHighestRuntimeVersion)
- {
- // A higher runtime version was installed - update the muxer
- shouldUpdateMuxer = true;
- }
- else
- {
- // Existing runtime is same or higher - keep existing muxer
- shouldUpdateMuxer = false;
- }
+ bool shouldUpdateMuxer = _preExtractionHighestRuntimeVersion == null ||
+ postExtractionHighestRuntimeVersion > _preExtractionHighestRuntimeVersion;
if (!shouldUpdateMuxer)
{
@@ -151,6 +136,11 @@ public void FinalizeAfterExtraction()
return;
}
+ ApplyMuxerReplacement(postExtractionHighestRuntimeVersion);
+ }
+
+ private void ApplyMuxerReplacement(ReleaseVersion postVersion)
+ {
// Move the existing muxer out of the way if it exists
if (_hadExistingMuxer)
{
@@ -187,7 +177,7 @@ public void FinalizeAfterExtraction()
Activity.Current?.SetTag("muxer.action", "updated");
Activity.Current?.SetTag("muxer.previous_version", VersionSanitizer.Sanitize(_preExtractionHighestRuntimeVersion?.ToString()));
- Activity.Current?.SetTag("muxer.new_version", VersionSanitizer.Sanitize(postExtractionHighestRuntimeVersion?.ToString()));
+ Activity.Current?.SetTag("muxer.new_version", VersionSanitizer.Sanitize(postVersion?.ToString()));
// Clean up the backup
if (_movedExistingMuxer && File.Exists(_existingMuxerBackupPath))
@@ -220,7 +210,7 @@ private void TryDeleteTempMuxer()
///
internal static ReleaseVersion? GetLatestRuntimeVersionFromInstallRoot(string installRoot)
{
- var runtimePath = Path.Combine(installRoot, "shared", "Microsoft.NETCore.App");
+ var runtimePath = Path.Combine(installRoot, "shared", InstallComponentExtensions.RuntimeFrameworkName);
if (!Directory.Exists(runtimePath))
{
return null;
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs
index a1b3c34526ca..5dbdfda0f173 100644
--- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs
@@ -36,7 +36,7 @@ public ReleaseManifest()
var release = FindRelease(product, resolvedVersion, installRequest.Component)
?? throw new DotnetInstallException(
DotnetInstallErrorCode.ReleaseNotFound,
- $"No release found for version {resolvedVersion}",
+ $"No release found for version {resolvedVersion}. Daily build versions are not yet supported.",
version: resolvedVersion.ToString(),
component: installRequest.Component.ToString());
return FindMatchingFile(release, installRequest);
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs
index 5d55a1b897ed..327cb5a9e90c 100644
--- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs
@@ -22,6 +22,17 @@ public class ScopedMutex : IDisposable
///
public static Action? OnWaitingForMutex { get; set; }
+ ///
+ /// When true, the callback is never invoked.
+ /// Set this during multi-install operations where in-process mutex contention
+ /// is expected and the "waiting" message would be misleading.
+ ///
+ public static bool SuppressWaitingCallback { get; set; }
+
+ // Process-wide count of non-reentrant mutex holds across all async contexts.
+ // Used to suppress the "waiting" callback when the holder is within this same process.
+ private static int s_processActiveHolds;
+
public ScopedMutex(string name)
{
Name = name;
@@ -33,7 +44,7 @@ public ScopedMutex(string name)
// Re-entrant: this thread already holds this mutex
++state.HoldCount;
_isReentrant = true;
- _mutex = null!;
+ _mutex = null!; // null! needed: _mutex is non-nullable Mutex but unused in re-entrant path
return;
}
@@ -42,7 +53,7 @@ public ScopedMutex(string name)
// 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\\"))
+ if (Environment.OSVersion.Platform != PlatformID.Win32NT && mutexName.StartsWith("Global\\", StringComparison.Ordinal))
{
mutexName = mutexName.Substring(7);
}
@@ -54,8 +65,13 @@ public ScopedMutex(string name)
// First try immediate acquisition to see if we need to wait
if (!_mutex.WaitOne(0, false))
{
- // Another process holds the mutex - notify caller before blocking
- OnWaitingForMutex?.Invoke();
+ // Another process holds the mutex — only invoke the callback when an
+ // *external* process holds the mutex. Suppress when another task within
+ // this process holds it so we don't show misleading "waiting" text.
+ if (!SuppressWaitingCallback && Volatile.Read(ref s_processActiveHolds) == 0)
+ {
+ OnWaitingForMutex?.Invoke();
+ }
// Now wait for the full timeout
if (!_mutex.WaitOne(TimeSpan.FromSeconds(TimeoutSeconds), false))
@@ -71,6 +87,7 @@ public ScopedMutex(string name)
// The OS still grants ownership to this thread, so we can proceed safely.
}
+ Interlocked.Increment(ref s_processActiveHolds);
held[name] = new MutexState { Mutex = _mutex, HoldCount = 1 };
}
@@ -93,6 +110,10 @@ public void Dispose()
// This shouldn't normally happen (it means too many Disposes),
// but clean up anyway
held.Remove(Name);
+ if (held.Count == 0)
+ {
+ s_heldMutexCounts.Value = null!;
+ }
}
}
return;
@@ -100,11 +121,18 @@ public void Dispose()
try
{
+ Interlocked.Decrement(ref s_processActiveHolds);
_mutex.ReleaseMutex();
}
finally
{
- s_heldMutexCounts.Value?.Remove(Name);
+ var held = s_heldMutexCounts.Value;
+ held?.Remove(Name);
+ if (held is not null && held.Count == 0)
+ {
+ s_heldMutexCounts.Value = null!;
+ }
+
_mutex.Dispose();
}
}
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/SubcomponentResolver.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/SubcomponentResolver.cs
index 5c552bd82079..75a8cb12f5f6 100644
--- a/src/Installer/Microsoft.Dotnet.Installation/Internal/SubcomponentResolver.cs
+++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/SubcomponentResolver.cs
@@ -16,6 +16,14 @@ internal enum SubcomponentResolveResult
UnknownFolder,
/// Known folder but path is not deep enough to identify a subcomponent.
TooShallow,
+ ///
+ /// Intermediate directory entry in a known subcomponent hierarchy (e.g., "shared/" or "host/fxr/").
+ /// Tar archives (produced on both Linux and Windows) include explicit directory entries
+ /// for every level of the hierarchy; zip archives historically omit these intermediate
+ /// entries and jump straight to files (e.g., "shared/Microsoft.NETCore.App/9.0.11/System.dll").
+ /// See dotnet/sdk#52910 for Windows tar.gz production.
+ ///
+ IntermediateDirectory,
/// Known non-subcomponent folder (e.g., swidtag, metadata) — expected to be ignored.
IgnoredFolder,
/// Input resolved to an empty path after normalization (e.g., "/", ".//").
@@ -62,16 +70,9 @@ internal static class SubcomponentResolver
return null;
}
- // Normalize to forward slashes, strip leading "./" (common in tar archives), and trim trailing slashes
- var normalized = relativeEntryPath.Replace('\\', '/');
- if (normalized.StartsWith("./", StringComparison.Ordinal))
- {
- normalized = normalized.Substring(2);
- }
- normalized = normalized.TrimEnd('/');
+ var (normalized, isDirectoryEntry) = NormalizeEntryPath(relativeEntryPath);
- // Split into segments. RemoveEmptyEntries ensures inputs like "/" or "//"
- // produce an empty array rather than arrays of empty strings.
+ // RemoveEmptyEntries ensures inputs like "/" or "//" produce an empty array.
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
@@ -91,22 +92,25 @@ internal static class SubcomponentResolver
if (!s_subcomponentDepthByFolder.TryGetValue(topLevelFolder, out int requiredDepth))
{
+ // Single-segment path that isn't a known folder (e.g., "dotnet.exe", "LICENSE.txt")
if (segments.Length < 2)
{
- // Single-segment path that isn't a known folder (e.g., "dotnet.exe", "LICENSE.txt")
result = SubcomponentResolveResult.RootLevelFile;
return null;
}
- // Unknown top-level folder — not a recognized subcomponent
+ // Unknown top-level folder — not a recognized subcomponent area
result = SubcomponentResolveResult.UnknownFolder;
return null;
}
if (segments.Length < requiredDepth)
{
- // Entry is inside a known folder but not deep enough to identify a subcomponent
- result = SubcomponentResolveResult.TooShallow;
+ // Directory entries (e.g., "shared/", "host/fxr/") are expected intermediate
+ // parts of the hierarchy and get a distinct classification.
+ result = isDirectoryEntry
+ ? SubcomponentResolveResult.IntermediateDirectory
+ : SubcomponentResolveResult.TooShallow;
return null;
}
@@ -115,6 +119,28 @@ internal static class SubcomponentResolver
return string.Join('/', segments, 0, requiredDepth);
}
+ ///
+ /// Normalizes an archive entry path: converts backslashes to forward slashes,
+ /// strips leading "./" prefixes (common in tar archives), and detects/trims
+ /// trailing directory separators.
+ ///
+ ///
+ /// Both tar and zip formats use forward slashes in entry names (the ZIP
+ /// specification requires it), so the trailing-'/' directory detection is
+ /// cross-platform. Backslashes are normalized first for robustness.
+ ///
+ private static (string normalized, bool isDirectoryEntry) NormalizeEntryPath(string entryPath)
+ {
+ var normalized = entryPath.Replace('\\', '/');
+ if (normalized.StartsWith("./", StringComparison.Ordinal))
+ {
+ normalized = normalized.Substring(2);
+ }
+
+ bool isDirectoryEntry = normalized.EndsWith('/');
+ return (normalized.TrimEnd('/'), isDirectoryEntry);
+ }
+
///
/// Resolves a relative archive entry path to its subcomponent identifier.
/// Convenience overload that discards the result classification.
diff --git a/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj
index 8a8d5d99b48d..e12cb9e34188 100644
--- a/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj
+++ b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj
@@ -14,6 +14,7 @@
falsefalse
+ $(WarningsNotAsErrors);MA0051truetrue
@@ -26,11 +27,17 @@
true
+
+
+
+
+
+
diff --git a/src/Installer/Microsoft.Dotnet.Installation/ProgressFormatting.cs b/src/Installer/Microsoft.Dotnet.Installation/ProgressFormatting.cs
new file mode 100644
index 000000000000..ed067e6682d8
--- /dev/null
+++ b/src/Installer/Microsoft.Dotnet.Installation/ProgressFormatting.cs
@@ -0,0 +1,38 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Dotnet.Installation;
+
+///
+/// Formatting utilities for progress bar descriptions. These constants and methods
+/// ensure consistent alignment across download, install, and completion progress rows.
+///
+public static class ProgressFormatting
+{
+ ///
+ /// Width of the " (nnn.n MB / nnn.n MB)" suffix appended during download progress.
+ /// Used to pad non-download descriptions (e.g. "Installing") to the same total width
+ /// so all progress rows stay aligned.
+ ///
+ public const int DownloadSuffixWidth = 22;
+
+ ///
+ /// Fixed width for action verbs in progress descriptions ("Downloading", "Downloaded",
+ /// "Installing", "Installed"). Matches the longest verb so shorter ones are right-padded.
+ ///
+ private const int ActionWidth = 11; // "Downloading".Length
+
+ ///
+ /// Builds a progress-bar description such as "Downloading aspnet (runtime) 9.0.312".
+ /// Component names and versions are padded so all rows align vertically.
+ ///
+ public static string FormatProgressDescription(string action, InstallComponent component, string version) =>
+ $"{action.PadRight(ActionWidth)} {component.GetPaddedDisplayName()} {InstallComponentExtensions.FormatVersionForDisplay(version)}";
+
+ ///
+ /// Formats bytes as MB, right-aligned to 8 characters (e.g. " 0.7 MB", "290.4 MB").
+ /// Always uses MB so the unit width is consistent across all progress rows.
+ ///
+ public static string FormatMB(long bytes) =>
+ FormattableString.Invariant($"{bytes / (1024.0 * 1024.0),5:F1} MB");
+}
diff --git a/src/Installer/dotnetup/.vscode/launch.json b/src/Installer/dotnetup/.vscode/launch.json
index 61220038bc4c..22300f3bc183 100644
--- a/src/Installer/dotnetup/.vscode/launch.json
+++ b/src/Installer/dotnetup/.vscode/launch.json
@@ -36,4 +36,4 @@
"default": "net11.0"
}
]
-}
\ No newline at end of file
+}
diff --git a/src/Installer/dotnetup/ArchiveInstallationValidator.cs b/src/Installer/dotnetup/ArchiveInstallationValidator.cs
index 604df4729b09..47f3d244eb90 100644
--- a/src/Installer/dotnetup/ArchiveInstallationValidator.cs
+++ b/src/Installer/dotnetup/ArchiveInstallationValidator.cs
@@ -10,9 +10,9 @@ internal class ArchiveInstallationValidator : IInstallationValidator
{
private static readonly Dictionary s_runtimeMonikerByComponent = new()
{
- [InstallComponent.Runtime] = "Microsoft.NETCore.App",
- [InstallComponent.ASPNETCore] = "Microsoft.AspNetCore.App",
- [InstallComponent.WindowsDesktop] = "Microsoft.WindowsDesktop.App"
+ [InstallComponent.Runtime] = InstallComponentExtensions.RuntimeFrameworkName,
+ [InstallComponent.ASPNETCore] = InstallComponentExtensions.AspNetCoreFrameworkName,
+ [InstallComponent.WindowsDesktop] = InstallComponentExtensions.WindowsDesktopFrameworkName
};
#pragma warning disable CA1822 // Validate methods implement IInstallationValidator and cannot be made static
diff --git a/src/Installer/dotnetup/CommandBase.cs b/src/Installer/dotnetup/CommandBase.cs
index bb4a3a42e8ce..db80bb8127cf 100644
--- a/src/Installer/dotnetup/CommandBase.cs
+++ b/src/Installer/dotnetup/CommandBase.cs
@@ -44,14 +44,14 @@ public int Execute()
{
// Known installation errors - print a clean user-friendly message
DotnetupTelemetry.Instance.RecordException(_commandActivity, ex);
- AnsiConsole.MarkupLine($"[red]Error: {ex.Message.EscapeMarkup()}[/]");
+ AnsiConsole.MarkupLine(DotnetupTheme.Error($"Error: {ex.Message.EscapeMarkup()}"));
return 1;
}
catch (Exception ex)
{
// Unexpected errors - still record telemetry so error_type is populated
DotnetupTelemetry.Instance.RecordException(_commandActivity, ex);
- AnsiConsole.MarkupLine($"[red]Error: {ex.Message.EscapeMarkup()}[/]");
+ AnsiConsole.MarkupLine(DotnetupTheme.Error($"Error: {ex.Message.EscapeMarkup()}"));
#if DEBUG
Console.Error.WriteLine(ex.StackTrace);
#endif
diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs
index 38dfc459f374..4c4446c6a3b8 100644
--- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs
+++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs
@@ -10,10 +10,10 @@ internal class DefaultInstallCommand : CommandBase
private readonly string _installType;
private readonly InstallRootManager _installRootManager;
- public DefaultInstallCommand(ParseResult result, IDotnetInstallManager? dotnetInstaller = null) : base(result)
+ public DefaultInstallCommand(ParseResult result, IDotnetEnvironmentManager? dotnetEnvironment = null) : base(result)
{
_installType = result.GetValue(DefaultInstallCommandParser.InstallTypeArgument)!;
- _installRootManager = new InstallRootManager(dotnetInstaller);
+ _installRootManager = new InstallRootManager(dotnetEnvironment);
}
protected override string GetCommandName() => "defaultinstall";
diff --git a/src/Installer/dotnetup/Commands/Dotnet/DotnetCommand.cs b/src/Installer/dotnetup/Commands/Dotnet/DotnetCommand.cs
new file mode 100644
index 000000000000..601dc92346f1
--- /dev/null
+++ b/src/Installer/dotnetup/Commands/Dotnet/DotnetCommand.cs
@@ -0,0 +1,109 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+using System.Diagnostics;
+using System.Globalization;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Dotnet;
+
+///
+/// Forwards commands to the dotnet executable managed by dotnetup.
+/// This allows users to run dotnetup dotnet build (or dotnetup do build)
+/// and have it resolve to the correct dotnet installation, even when the system PATH
+/// has been overridden by other installers (e.g., Visual Studio).
+///
+internal class DotnetCommand : CommandBase
+{
+ private readonly IDotnetEnvironmentManager _dotnetEnvironment;
+ private readonly string[] _forwardedArgs;
+
+ public DotnetCommand(ParseResult parseResult, IDotnetEnvironmentManager? dotnetEnvironment = null) : base(parseResult)
+ {
+ _dotnetEnvironment = dotnetEnvironment ?? new DotnetEnvironmentManager();
+
+ // Collect all unmatched/forwarded tokens after the "dotnet" or "do" subcommand.
+ _forwardedArgs = [.. parseResult.UnmatchedTokens];
+ }
+
+ protected override string GetCommandName() => "dotnet";
+
+ protected override int ExecuteCore()
+ {
+ string dotnetPath = ResolveDotnetPath();
+ string dotnetExe = GetDotnetExecutable(dotnetPath);
+
+ if (!File.Exists(dotnetExe))
+ {
+ Console.Error.WriteLine(string.Format(CultureInfo.InvariantCulture, Strings.DotnetCommandDotnetNotFound, dotnetExe));
+ Console.Error.WriteLine(Strings.DotnetCommandInstallFirst);
+ return 1;
+ }
+
+ return RunDotnet(dotnetExe, dotnetPath, _forwardedArgs);
+ }
+
+ ///
+ /// Resolves the dotnet installation path using the same logic as other dotnetup commands:
+ /// configured install type (user install) falls back to the default install path.
+ ///
+ private string ResolveDotnetPath()
+ {
+ var configuredRoot = _dotnetEnvironment.GetCurrentPathConfiguration();
+ if (configuredRoot is not null && configuredRoot.InstallType == InstallType.User)
+ {
+ return configuredRoot.Path;
+ }
+
+ return _dotnetEnvironment.GetDefaultDotnetInstallPath();
+ }
+
+ ///
+ /// Gets the full path to the dotnet executable within the install root.
+ ///
+ private static string GetDotnetExecutable(string dotnetPath)
+ {
+ string exeName = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet";
+ return Path.Combine(dotnetPath, exeName);
+ }
+
+ ///
+ /// Spawns dotnet with the forwarded arguments, setting DOTNET_ROOT and prepending
+ /// the install path to PATH. Uses shell execution so that shell features
+ /// (redirection, piping, interactive mode) work transparently.
+ ///
+ private static int RunDotnet(string dotnetExe, string dotnetRoot, string[] args)
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = dotnetExe,
+ UseShellExecute = false,
+ RedirectStandardInput = false,
+ RedirectStandardOutput = false,
+ RedirectStandardError = false,
+ };
+
+ // Forward all arguments
+ foreach (var arg in args)
+ {
+ startInfo.ArgumentList.Add(arg);
+ }
+
+ // Set DOTNET_ROOT to the resolved hive so runtime interactions work correctly
+ startInfo.Environment["DOTNET_ROOT"] = dotnetRoot;
+
+ // Prepend the install path to PATH so child processes also resolve correctly
+ var currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
+ startInfo.Environment["PATH"] = dotnetRoot + Path.PathSeparator + currentPath;
+
+ using var process = Process.Start(startInfo);
+ if (process is null)
+ {
+ Console.Error.WriteLine(Strings.DotnetCommandDotnetStartFailed);
+ return 1;
+ }
+
+ process.WaitForExit();
+ return process.ExitCode;
+ }
+}
diff --git a/src/Installer/dotnetup/Commands/Dotnet/DotnetCommandParser.cs b/src/Installer/dotnetup/Commands/Dotnet/DotnetCommandParser.cs
new file mode 100644
index 000000000000..c9c97d6e931d
--- /dev/null
+++ b/src/Installer/dotnetup/Commands/Dotnet/DotnetCommandParser.cs
@@ -0,0 +1,42 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Dotnet;
+
+internal static class DotnetCommandParser
+{
+ private static readonly Command s_dotnetCommand = ConstructCommand();
+ private static readonly Command s_doCommand = ConstructAliasCommand();
+
+ public static Command GetCommand() => s_dotnetCommand;
+ public static Command GetAliasCommand() => s_doCommand;
+
+ private static Command ConstructCommand()
+ {
+ var command = new Command("dotnet", Strings.DotnetCommandDescription)
+ {
+ // No arguments or options defined — all tokens after the subcommand name
+ // are captured via TreatUnmatchedTokensAsErrors = false and read from
+ // ParseResult.UnmatchedTokens in DotnetCommand.
+ TreatUnmatchedTokensAsErrors = false
+ };
+
+ command.SetAction(parseResult => new DotnetCommand(parseResult).Execute());
+
+ return command;
+ }
+
+ private static Command ConstructAliasCommand()
+ {
+ var command = new Command("do", Strings.DotnetCommandDescription)
+ {
+ TreatUnmatchedTokensAsErrors = false
+ };
+
+ command.SetAction(parseResult => new DotnetCommand(parseResult).Execute());
+
+ return command;
+ }
+}
diff --git a/src/Installer/dotnetup/Commands/List/ListCommand.cs b/src/Installer/dotnetup/Commands/List/ListCommand.cs
index 72ae30335e57..17d9dcb4eefb 100644
--- a/src/Installer/dotnetup/Commands/List/ListCommand.cs
+++ b/src/Installer/dotnetup/Commands/List/ListCommand.cs
@@ -53,9 +53,6 @@ internal static class InstallationLister
///
public static ListData GetListData(bool verify = false, string? manifestPath = null, string? installPath = null)
{
- var installSpecs = new List();
- var installations = new List();
-
DotnetupManifestData manifestData;
using (var mutex = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates))
{
@@ -70,52 +67,59 @@ public static ListData GetListData(bool verify = false, string? manifestPath = n
Path.GetFullPath(r.Path), Path.GetFullPath(installPath), StringComparison.OrdinalIgnoreCase))
: manifestData.DotnetRoots;
- foreach (var root in roots)
+ var allData = roots.Select(r => CollectRootData(r, verify, validator)).ToList();
+
+ return new ListData
{
- var installRoot = new DotnetInstallRoot(root.Path, root.Architecture);
+ InstallSpecs = [.. allData.SelectMany(d => d.Specs)],
+ Installations = [.. allData.SelectMany(d => d.Installations)]
+ };
+ }
- // Collect install specs
- foreach (var spec in root.InstallSpecs)
+ private static (List Specs, List Installations) CollectRootData(
+ DotnetRootEntry root,
+ bool verify,
+ ArchiveInstallationValidator validator)
+ {
+ var installRoot = new DotnetInstallRoot(root.Path, root.Architecture);
+
+ var specs = root.InstallSpecs.Select(spec => new InstallSpecInfo
+ {
+ Component = spec.Component,
+ VersionOrChannel = spec.VersionOrChannel,
+ Source = spec.InstallSource,
+ GlobalJsonPath = spec.GlobalJsonPath,
+ InstallRoot = root.Path,
+ Architecture = root.Architecture
+ }).ToList();
+
+ var installations = new List();
+ foreach (var installation in root.Installations)
+ {
+ bool? isValid = null;
+ string? validationFailure = null;
+
+ if (verify)
{
- installSpecs.Add(new InstallSpecInfo
- {
- Component = spec.Component,
- VersionOrChannel = spec.VersionOrChannel,
- Source = spec.InstallSource,
- GlobalJsonPath = spec.GlobalJsonPath,
- InstallRoot = root.Path,
- Architecture = root.Architecture
- });
+ var dotnetInstall = new DotnetInstall(
+ installRoot,
+ new Microsoft.Deployment.DotNet.Releases.ReleaseVersion(installation.Version),
+ installation.Component);
+ isValid = validator.Validate(dotnetInstall, out validationFailure);
}
- // Collect installations
- foreach (var installation in root.Installations)
+ installations.Add(new InstallationInfo
{
- bool? isValid = null;
- string? validationFailure = null;
-
- if (verify)
- {
- var dotnetInstall = new DotnetInstall(
- installRoot,
- new Microsoft.Deployment.DotNet.Releases.ReleaseVersion(installation.Version),
- installation.Component);
- isValid = validator.Validate(dotnetInstall, out validationFailure);
- }
-
- installations.Add(new InstallationInfo
- {
- Component = installation.Component,
- Version = installation.Version,
- InstallRoot = root.Path,
- Architecture = root.Architecture,
- IsValid = isValid,
- ValidationFailure = validationFailure
- });
- }
+ Component = installation.Component,
+ Version = installation.Version,
+ InstallRoot = root.Path,
+ Architecture = root.Architecture,
+ IsValid = isValid,
+ ValidationFailure = validationFailure
+ });
}
- return new ListData { InstallSpecs = installSpecs, Installations = installations };
+ return (specs, installations);
}
private const int IndentSize = 2;
@@ -124,8 +128,14 @@ public static ListData GetListData(bool verify = false, string? manifestPath = n
public static void WriteHumanReadable(TextWriter writer, ListData listData)
{
+ // Create an AnsiConsole that writes to our TextWriter
+ var console = AnsiConsole.Create(new AnsiConsoleSettings
+ {
+ Out = new AnsiConsoleOutput(writer)
+ });
+
writer.WriteLine();
- writer.WriteLine(Strings.ListHeader);
+ console.MarkupLine($"Installations [{DotnetupTheme.Current.Dim}](managed by dotnetup)[/]:");
writer.WriteLine();
if (listData.InstallSpecs.Count == 0 && listData.Installations.Count == 0)
@@ -134,11 +144,6 @@ public static void WriteHumanReadable(TextWriter writer, ListData listData)
}
else
{
- // Create an AnsiConsole that writes to our TextWriter
- var console = AnsiConsole.Create(new AnsiConsoleSettings
- {
- Out = new AnsiConsoleOutput(writer)
- });
// Group by install root for cleaner display
var allRoots = listData.InstallSpecs.Select(s => s.InstallRoot)
@@ -178,9 +183,8 @@ private static void DisplayInstallSpecs(TextWriter writer, IAnsiConsole console,
: spec.Source.ToString().ToLowerInvariant();
specGrid.AddRow(
- spec.Component.GetDisplayName(),
- spec.VersionOrChannel,
- $"[dim](source: {sourceDisplay})[/]"
+ $"{spec.Component.GetDisplayName()} {spec.VersionOrChannel}",
+ $"[{DotnetupTheme.Current.Dim}](source: {sourceDisplay})[/]"
);
}
@@ -200,12 +204,11 @@ private static void DisplayInstallations(TextWriter writer, IAnsiConsole console
foreach (var install in installs.OrderBy(i => i.Component).ThenBy(i => i.Version))
{
string status = install.IsValid == false
- ? $"[red]({install.Architecture} — invalid: {install.ValidationFailure})[/]"
+ ? $"[{DotnetupTheme.Current.Error}]({install.Architecture} — invalid: {install.ValidationFailure})[/]"
: $"({install.Architecture})";
installGrid.AddRow(
- install.Component.GetDisplayName(),
- install.Version,
+ $"{install.Component.GetDisplayName()} {install.Version}",
status
);
}
@@ -218,7 +221,6 @@ private static Grid CreateIndentedGrid()
{
var grid = new Grid();
grid.AddColumn(new GridColumn().PadLeft(3 * IndentSize).PadRight(IndentSize).NoWrap());
- grid.AddColumn(new GridColumn().PadRight(IndentSize).NoWrap());
grid.AddColumn(new GridColumn().NoWrap());
return grid;
}
diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs
index bcb8e6f560ac..81bbfea6aa33 100644
--- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs
+++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs
@@ -9,11 +9,11 @@ internal class PrintEnvScriptCommand : CommandBase
{
private readonly IEnvShellProvider? _shellProvider;
private readonly string? _dotnetInstallPath;
- private readonly IDotnetInstallManager _dotnetInstaller;
+ private readonly IDotnetEnvironmentManager _dotnetEnvironment;
- public PrintEnvScriptCommand(ParseResult result, IDotnetInstallManager? dotnetInstaller = null) : base(result)
+ public PrintEnvScriptCommand(ParseResult result, IDotnetEnvironmentManager? dotnetEnvironment = null) : base(result)
{
- _dotnetInstaller = dotnetInstaller ?? new DotnetInstallManager();
+ _dotnetEnvironment = dotnetEnvironment ?? new DotnetEnvironmentManager();
_shellProvider = result.GetValue(PrintEnvScriptCommandParser.ShellOption);
_dotnetInstallPath = result.GetValue(PrintEnvScriptCommandParser.DotnetInstallPathOption);
}
@@ -44,7 +44,7 @@ protected override int ExecuteCore()
}
// Determine the dotnet install path
- string installPath = _dotnetInstallPath ?? _dotnetInstaller.GetDefaultDotnetInstallPath();
+ string installPath = _dotnetInstallPath ?? _dotnetEnvironment.GetDefaultDotnetInstallPath();
// Generate the shell script
string script = _shellProvider.GenerateEnvScript(installPath);
diff --git a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs
index f46b09ddd40c..770edafe9ddd 100644
--- a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs
+++ b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs
@@ -7,19 +7,9 @@
namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Runtime.Install;
-internal class RuntimeInstallCommand(ParseResult result) : CommandBase(result)
+internal class RuntimeInstallCommand(ParseResult result) : InstallCommand(result)
{
- private readonly string? _componentSpec = result.GetValue(RuntimeInstallCommandParser.ComponentSpecArgument);
- private readonly string? _installPath = result.GetValue(CommonOptions.InstallPathOption);
- private readonly bool? _setDefaultInstall = result.GetValue(CommonOptions.SetDefaultInstallOption);
- private readonly string? _manifestPath = result.GetValue(CommonOptions.ManifestPathOption);
- private readonly bool _interactive = result.GetValue(CommonOptions.InteractiveOption);
- private readonly bool _noProgress = result.GetValue(CommonOptions.NoProgressOption);
- private readonly bool _requireMuxerUpdate = result.GetValue(CommonOptions.RequireMuxerUpdateOption);
- private readonly bool _untracked = result.GetValue(CommonOptions.UntrackedOption);
-
- private readonly IDotnetInstallManager _dotnetInstaller = new DotnetInstallManager();
- private readonly ChannelVersionResolver _channelVersionResolver = new();
+ private readonly string[] _componentSpecs = result.GetValue(RuntimeInstallCommandParser.ComponentSpecsArgument) ?? [];
///
/// Maps user-friendly runtime type names to InstallComponent enum values.
@@ -29,25 +19,42 @@ internal class RuntimeInstallCommand(ParseResult result) : CommandBase(result)
{
["runtime"] = InstallComponent.Runtime,
["aspnetcore"] = InstallComponent.ASPNETCore,
+ ["aspnet"] = InstallComponent.ASPNETCore,
+ ["core"] = InstallComponent.ASPNETCore,
["windowsdesktop"] = InstallComponent.WindowsDesktop,
+ ["desktop"] = InstallComponent.WindowsDesktop,
};
+ /// Primary (non-alias) names shown in help text and error messages.
+ private static readonly string[] s_primaryRuntimeTypes = ["runtime", "aspnetcore", "windowsdesktop"];
+
protected override string GetCommandName() => "runtime/install";
protected override int ExecuteCore()
{
- // Parse the component spec to determine runtime type and version
- var (component, versionOrChannel) = ParseComponentSpec(_componentSpec);
+ // Parse and validate all specs upfront before any downloads begin.
+ // If none provided, default to a single core runtime with no channel.
+ var specs = _componentSpecs.Length > 0
+ ? _componentSpecs.Select(s => ParseAndValidateComponentSpec(s)).ToArray()
+ : [ParseAndValidateComponentSpec(null)];
+
+ var workflow = new InstallWorkflow(this);
+ workflow.Execute(specs);
+ return 0;
+ }
- // Windows Desktop Runtime is only available on Windows
+ private static void ValidateComponentForPlatform(InstallComponent component)
+ {
if (component == InstallComponent.WindowsDesktop && !OperatingSystem.IsWindows())
{
throw new DotnetInstallException(
DotnetInstallErrorCode.PlatformNotSupported,
$"Windows Desktop Runtime is only available on Windows. Valid component types for this platform are: {string.Join(", ", GetValidRuntimeTypes())}");
}
+ }
- // SDK versions and feature bands (like 9.0.103, 9.0.1xx) are SDK-specific and not valid for runtimes
+ private static void ValidateNotSdkVersion(string? versionOrChannel)
+ {
if (!string.IsNullOrEmpty(versionOrChannel) && new UpdateChannel(versionOrChannel).IsSdkVersionOrFeatureBand())
{
throw new DotnetInstallException(
@@ -55,26 +62,17 @@ protected override int ExecuteCore()
$"'{versionOrChannel}' looks like an SDK version or feature band, which is not valid for runtime installations. "
+ "Use a version channel like '9.0', 'latest', 'lts', or a specific runtime version like '9.0.12'.");
}
+ }
- // Use GetDisplayName() from InstallComponentExtensions for consistent descriptions
- string componentDescription = component.GetDisplayName();
-
- InstallWorkflow workflow = new(_dotnetInstaller, _channelVersionResolver);
-
- InstallWorkflow.InstallWorkflowOptions options = new(
- versionOrChannel,
- _installPath,
- _setDefaultInstall,
- _manifestPath,
- _interactive,
- _noProgress,
- component,
- componentDescription,
- RequireMuxerUpdate: _requireMuxerUpdate,
- Untracked: _untracked);
-
- workflow.Execute(options);
- return 0;
+ ///
+ /// Parses and validates a component specification, checking platform support and SDK version conflicts.
+ ///
+ private static MinimalInstallSpec ParseAndValidateComponentSpec(string? spec)
+ {
+ var (component, versionOrChannel) = ParseComponentSpec(spec);
+ ValidateComponentForPlatform(component);
+ ValidateNotSdkVersion(versionOrChannel);
+ return new MinimalInstallSpec(component, versionOrChannel);
}
///
@@ -132,14 +130,15 @@ internal static (InstallComponent Component, string? VersionOrChannel) ParseComp
///
internal static IEnumerable GetValidRuntimeTypes()
{
- foreach (var kvp in s_runtimeTypeMap)
+ foreach (var name in s_primaryRuntimeTypes)
{
- // Windows Desktop is only valid on Windows
- if (kvp.Value == InstallComponent.WindowsDesktop && !OperatingSystem.IsWindows())
+ if (s_runtimeTypeMap.TryGetValue(name, out var component) &&
+ component == InstallComponent.WindowsDesktop && !OperatingSystem.IsWindows())
{
continue;
}
- yield return kvp.Key;
+
+ yield return name;
}
}
}
diff --git a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs
index 10a708fdc296..e79c40d442c0 100644
--- a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs
+++ b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs
@@ -7,8 +7,8 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Runtime.Install;
internal static class RuntimeInstallCommandParser
{
- public static readonly Argument ComponentSpecArgument =
- CommonOptions.CreateRuntimeComponentSpecArgument(required: false, actionVerb: "install");
+ public static readonly Argument ComponentSpecsArgument =
+ CommonOptions.CreateRuntimeComponentSpecsArgument(actionVerb: "install");
private static readonly Command s_command = ConstructCommand();
@@ -21,12 +21,13 @@ private static Command ConstructCommand()
{
Command command = new("install", "Installs a .NET Runtime");
- command.Arguments.Add(ComponentSpecArgument);
+ command.Arguments.Add(ComponentSpecsArgument);
command.Options.Add(CommonOptions.InstallPathOption);
command.Options.Add(CommonOptions.SetDefaultInstallOption);
command.Options.Add(CommonOptions.ManifestPathOption);
command.Options.Add(CommonOptions.InteractiveOption);
command.Options.Add(CommonOptions.NoProgressOption);
+ command.Options.Add(CommonOptions.VerbosityOption);
command.Options.Add(CommonOptions.RequireMuxerUpdateOption);
command.Options.Add(CommonOptions.UntrackedOption);
diff --git a/src/Installer/dotnetup/Commands/Runtime/Update/RuntimeUpdateCommand.cs b/src/Installer/dotnetup/Commands/Runtime/Update/RuntimeUpdateCommand.cs
index e633ebdf17e5..e105480fa4ca 100644
--- a/src/Installer/dotnetup/Commands/Runtime/Update/RuntimeUpdateCommand.cs
+++ b/src/Installer/dotnetup/Commands/Runtime/Update/RuntimeUpdateCommand.cs
@@ -10,6 +10,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Runtime.Update;
internal class RuntimeUpdateCommand(ParseResult result) : CommandBase(result)
{
private readonly bool _noProgress = result.GetValue(CommonOptions.NoProgressOption);
+ private readonly Verbosity _verbosity = result.GetValue(CommonOptions.VerbosityOption);
private readonly string? _manifestPath = result.GetValue(CommonOptions.ManifestPathOption);
private readonly string? _installPath = result.GetValue(CommonOptions.InstallPathOption);
@@ -28,10 +29,10 @@ protected override int ExecuteCore()
int exitCode = 0;
foreach (var component in components)
{
- int result = workflow.Execute(_manifestPath, _installPath, component, _noProgress);
- if (result != 0)
+ int componentExitCode = workflow.Execute(_manifestPath, _installPath, component, _noProgress, verbosity: _verbosity);
+ if (componentExitCode != 0)
{
- exitCode = result;
+ exitCode = componentExitCode;
}
}
diff --git a/src/Installer/dotnetup/Commands/Runtime/Update/RuntimeUpdateCommandParser.cs b/src/Installer/dotnetup/Commands/Runtime/Update/RuntimeUpdateCommandParser.cs
index 7ce59d6a70ec..d5f52c7e12d4 100644
--- a/src/Installer/dotnetup/Commands/Runtime/Update/RuntimeUpdateCommandParser.cs
+++ b/src/Installer/dotnetup/Commands/Runtime/Update/RuntimeUpdateCommandParser.cs
@@ -28,6 +28,7 @@ private static Command ConstructCommand()
command.Options.Add(CommonOptions.ManifestPathOption);
command.Options.Add(CommonOptions.InstallPathOption);
command.Options.Add(CommonOptions.NoProgressOption);
+ command.Options.Add(CommonOptions.VerbosityOption);
command.SetAction(parseResult => new RuntimeUpdateCommand(parseResult).Execute());
diff --git a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs
index 60858bc0cfaf..3056fbc2b71f 100644
--- a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs
+++ b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs
@@ -7,42 +7,24 @@
namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install;
-internal class SdkInstallCommand(ParseResult result) : CommandBase(result)
+internal class SdkInstallCommand(ParseResult result) : InstallCommand(result)
{
- private readonly string? _versionOrChannel = result.GetValue(SdkInstallCommandParser.ChannelArgument);
- private readonly string? _installPath = result.GetValue(CommonOptions.InstallPathOption);
- private readonly bool? _setDefaultInstall = result.GetValue(CommonOptions.SetDefaultInstallOption);
- private readonly bool? _updateGlobalJson = result.GetValue(SdkInstallCommandParser.UpdateGlobalJsonOption);
- private readonly string? _manifestPath = result.GetValue(CommonOptions.ManifestPathOption);
- private readonly bool _interactive = result.GetValue(CommonOptions.InteractiveOption);
- private readonly bool _noProgress = result.GetValue(CommonOptions.NoProgressOption);
- private readonly bool _requireMuxerUpdate = result.GetValue(CommonOptions.RequireMuxerUpdateOption);
- private readonly bool _untracked = result.GetValue(CommonOptions.UntrackedOption);
+ private readonly string[] _channels = result.GetValue(SdkInstallCommandParser.ChannelArguments) ?? [];
- private readonly IDotnetInstallManager _dotnetInstaller = new DotnetInstallManager();
- private readonly ChannelVersionResolver _channelVersionResolver = new();
+ public override bool UpdateGlobalJson { get; } = result.GetValue(SdkInstallCommandParser.UpdateGlobalJsonOption) ?? false;
protected override string GetCommandName() => "sdk/install";
protected override int ExecuteCore()
{
- var workflow = new InstallWorkflow(_dotnetInstaller, _channelVersionResolver);
-
- var options = new InstallWorkflow.InstallWorkflowOptions(
- _versionOrChannel,
- _installPath,
- _setDefaultInstall,
- _manifestPath,
- _interactive,
- _noProgress,
- InstallComponent.SDK,
- ".NET SDK",
- _updateGlobalJson,
- GlobalJsonChannelResolver.ResolveChannel,
- _requireMuxerUpdate,
- _untracked);
-
- workflow.Execute(options);
+ // Map each channel to a MinimalInstallSpec. If none provided, a single null-channel
+ // entry lets the workflow fall back to global.json or "latest".
+ var specs = _channels.Length > 0
+ ? _channels.Select(c => new MinimalInstallSpec(InstallComponent.SDK, c)).ToArray()
+ : [new MinimalInstallSpec(InstallComponent.SDK, null)];
+
+ var workflow = new InstallWorkflow(this);
+ workflow.Execute(specs);
return 0;
}
}
diff --git a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommandParser.cs
index b6362e79ff53..7527f7812460 100644
--- a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommandParser.cs
+++ b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommandParser.cs
@@ -7,8 +7,8 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install;
internal static class SdkInstallCommandParser
{
- public static readonly Argument ChannelArgument =
- CommonOptions.CreateSdkChannelArgument(required: false, actionVerb: "install");
+ public static readonly Argument ChannelArguments =
+ CommonOptions.CreateSdkChannelArguments(actionVerb: "install");
public static readonly Option UpdateGlobalJsonOption = new("--update-global-json")
{
@@ -38,7 +38,7 @@ private static Command ConstructCommand()
{
Command command = new("install", "Installs the .NET SDK");
- command.Arguments.Add(ChannelArgument);
+ command.Arguments.Add(ChannelArguments);
command.Options.Add(CommonOptions.InstallPathOption);
command.Options.Add(CommonOptions.SetDefaultInstallOption);
@@ -47,6 +47,7 @@ private static Command ConstructCommand()
command.Options.Add(CommonOptions.InteractiveOption);
command.Options.Add(CommonOptions.NoProgressOption);
+ command.Options.Add(CommonOptions.VerbosityOption);
command.Options.Add(CommonOptions.RequireMuxerUpdateOption);
command.Options.Add(CommonOptions.UntrackedOption);
diff --git a/src/Installer/dotnetup/Commands/Sdk/Update/SdkUpdateCommand.cs b/src/Installer/dotnetup/Commands/Sdk/Update/SdkUpdateCommand.cs
index 39d1d2f30c93..4db3d60da153 100644
--- a/src/Installer/dotnetup/Commands/Sdk/Update/SdkUpdateCommand.cs
+++ b/src/Installer/dotnetup/Commands/Sdk/Update/SdkUpdateCommand.cs
@@ -12,6 +12,7 @@ internal class SdkUpdateCommand(ParseResult result, bool updateAllOverride = fal
private readonly bool _updateAll = updateAllOverride || result.GetValue(SdkUpdateCommandParser.UpdateAllOption);
private readonly bool _updateGlobalJson = result.GetValue(SdkUpdateCommandParser.UpdateGlobalJsonOption);
private readonly bool _noProgress = result.GetValue(CommonOptions.NoProgressOption);
+ private readonly Verbosity _verbosity = result.GetValue(CommonOptions.VerbosityOption);
private readonly string? _manifestPath = result.GetValue(CommonOptions.ManifestPathOption);
private readonly string? _installPath = result.GetValue(CommonOptions.InstallPathOption);
@@ -25,6 +26,7 @@ protected override int ExecuteCore()
_installPath,
_updateAll ? null : InstallComponent.SDK,
_noProgress,
- _updateGlobalJson);
+ _updateGlobalJson,
+ _verbosity);
}
}
diff --git a/src/Installer/dotnetup/Commands/Sdk/Update/SdkUpdateCommandParser.cs b/src/Installer/dotnetup/Commands/Sdk/Update/SdkUpdateCommandParser.cs
index cc1988a4760b..9c4b61333515 100644
--- a/src/Installer/dotnetup/Commands/Sdk/Update/SdkUpdateCommandParser.cs
+++ b/src/Installer/dotnetup/Commands/Sdk/Update/SdkUpdateCommandParser.cs
@@ -47,6 +47,7 @@ private static Command ConstructCommand()
command.Options.Add(CommonOptions.InteractiveOption);
command.Options.Add(CommonOptions.NoProgressOption);
+ command.Options.Add(CommonOptions.VerbosityOption);
command.SetAction(parseResult => new SdkUpdateCommand(parseResult).Execute());
@@ -63,6 +64,7 @@ private static Command ConstructRootCommand()
command.Options.Add(CommonOptions.InteractiveOption);
command.Options.Add(CommonOptions.NoProgressOption);
+ command.Options.Add(CommonOptions.VerbosityOption);
command.SetAction(parseResult => new SdkUpdateCommand(parseResult, updateAllOverride: true).Execute());
diff --git a/src/Installer/dotnetup/Commands/Shared/GarbageCollectionRunner.cs b/src/Installer/dotnetup/Commands/Shared/GarbageCollectionRunner.cs
index 5ec46b2993fd..93c1571d941d 100644
--- a/src/Installer/dotnetup/Commands/Shared/GarbageCollectionRunner.cs
+++ b/src/Installer/dotnetup/Commands/Shared/GarbageCollectionRunner.cs
@@ -32,12 +32,12 @@ public static List RunAndDisplay(string? manifestPath, DotnetInstallRoot
{
foreach (var d in deleted)
{
- AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $" Removed [dim]{d}[/]");
+ AnsiConsole.MarkupLine(string.Format(CultureInfo.InvariantCulture, " Removed {0}", DotnetupTheme.Dim(d)));
}
}
else if (showEmptyMessage)
{
- AnsiConsole.MarkupLine("[dim]No files were removed.[/]");
+ AnsiConsole.MarkupLine(DotnetupTheme.Dim("No files were removed."));
}
return deleted;
diff --git a/src/Installer/dotnetup/Commands/Shared/InstallCommand.cs b/src/Installer/dotnetup/Commands/Shared/InstallCommand.cs
new file mode 100644
index 000000000000..9491ea489231
--- /dev/null
+++ b/src/Installer/dotnetup/Commands/Shared/InstallCommand.cs
@@ -0,0 +1,42 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+using Microsoft.Dotnet.Installation.Internal;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared;
+
+///
+/// Abstract base for SDK and Runtime install commands.
+/// Exposes the shared command-line options so
+/// can access them without taking a dozen parameters.
+///
+internal abstract class InstallCommand : CommandBase
+{
+ public string? InstallPath { get; }
+ public string? ManifestPath { get; }
+ public bool Interactive { get; }
+ public bool NoProgress { get; }
+ public Verbosity Verbosity { get; }
+ public bool RequireMuxerUpdate { get; }
+ public bool Untracked { get; }
+ public virtual bool UpdateGlobalJson => false;
+
+ public IDotnetEnvironmentManager DotnetEnvironment { get; }
+ public ChannelVersionResolver ChannelVersionResolver { get; }
+
+ protected InstallCommand(ParseResult parseResult)
+ : base(parseResult)
+ {
+ InstallPath = parseResult.GetValue(CommonOptions.InstallPathOption);
+ ManifestPath = parseResult.GetValue(CommonOptions.ManifestPathOption);
+ Interactive = parseResult.GetValue(CommonOptions.InteractiveOption);
+ NoProgress = parseResult.GetValue(CommonOptions.NoProgressOption);
+ Verbosity = parseResult.GetValue(CommonOptions.VerbosityOption);
+ RequireMuxerUpdate = parseResult.GetValue(CommonOptions.RequireMuxerUpdateOption);
+ Untracked = parseResult.GetValue(CommonOptions.UntrackedOption);
+
+ DotnetEnvironment = new DotnetEnvironmentManager();
+ ChannelVersionResolver = new ChannelVersionResolver();
+ }
+}
diff --git a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs
index 6c3eee013b1a..98ac7d40776e 100644
--- a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs
+++ b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs
@@ -2,8 +2,9 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
-using Microsoft.Deployment.DotNet.Releases;
+using Microsoft.Dotnet.Installation;
using Microsoft.Dotnet.Installation.Internal;
+using Spectre.Console;
using SpectreAnsiConsole = Spectre.Console.AnsiConsole;
namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared;
@@ -14,274 +15,114 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared;
internal class InstallExecutor
{
///
- /// Result of an installation execution.
+ /// Executes a batch of resolved install requests concurrently via ,
+ /// then displays a unified result summary including any per-request failures.
///
- public record InstallResult(DotnetInstall Install, bool WasAlreadyInstalled = false);
-
- ///
- /// Result of creating and resolving an install request.
- ///
- public record ResolvedInstallRequest(DotnetInstallRequest Request, ReleaseVersion? ResolvedVersion);
-
- ///
- /// Creates a DotnetInstallRequest and resolves the version using the channel version resolver.
- ///
- /// The installation path.
- /// The channel or version to install.
- /// The component type (SDK, Runtime, ASPNETCore, WindowsDesktop).
- /// Optional manifest path for tracking installations.
- /// The resolver to use for version resolution.
- /// If true, fail when the muxer cannot be updated.
- /// The source of this install request.
- /// The path to the global.json that triggered this install.
- /// If true, install without recording in the manifest.
- /// The resolved install request with version information.
- public static ResolvedInstallRequest CreateAndResolveRequest(
- string installPath,
- string channel,
- InstallComponent component,
- string? manifestPath,
- ChannelVersionResolver channelVersionResolver,
- bool requireMuxerUpdate = false,
- InstallRequestSource installSource = InstallRequestSource.Explicit,
- string? globalJsonPath = null,
- bool untracked = false)
- {
- var installRoot = new DotnetInstallRoot(installPath, InstallerUtilities.GetDefaultInstallArchitecture());
-
- var request = new DotnetInstallRequest(
- installRoot,
- new UpdateChannel(channel),
- component,
- new InstallRequestOptions
- {
- ManifestPath = manifestPath,
- RequireMuxerUpdate = requireMuxerUpdate,
- InstallSource = installSource,
- GlobalJsonPath = globalJsonPath,
- Untracked = untracked
- });
-
- var resolvedVersion = channelVersionResolver.Resolve(request);
-
- return new ResolvedInstallRequest(request, resolvedVersion);
- }
-
- ///
- /// Executes the installation of a .NET component and displays appropriate status messages.
- ///
- /// The installation request to execute.
- /// The resolved version string for display purposes.
- /// Description of the component (e.g., ".NET SDK", ".NET Runtime").
+ /// The resolved install requests to execute.
/// Whether to suppress progress display.
- /// The installation result.
- public static InstallResult ExecuteInstall(
- DotnetInstallRequest installRequest,
- string? resolvedVersion,
- string componentDescription,
+ /// The batch result containing successes and failures.
+ public static InstallBatchResult ExecuteInstalls(
+ List requests,
bool noProgress)
{
-#pragma warning disable CA1305 // Spectre.Console API does not accept IFormatProvider
- SpectreAnsiConsole.MarkupLineInterpolated($"Installing {componentDescription} [blue]{resolvedVersion}[/] to [blue]{installRequest.InstallRoot.Path}[/]...");
-#pragma warning restore CA1305
-
- var orchestratorResult = InstallerOrchestratorSingleton.Instance.Install(installRequest, noProgress);
-
- if (orchestratorResult.WasAlreadyInstalled)
+ if (requests.Count == 0)
{
- SpectreAnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[green]{componentDescription} {orchestratorResult.Install.Version} is already installed at {orchestratorResult.Install.InstallRoot}[/]");
+ return new InstallBatchResult(Array.Empty(), Array.Empty());
}
- else
+
+ DotnetInstallRoot installRoot = requests[0].Request.InstallRoot;
+ string escapedPath = installRoot.Path.EscapeMarkup();
+ string accent = DotnetupTheme.Current.Accent;
+
+ // Print "Installing X, Y, Z to ..."
+ var descriptions = requests.Select(r =>
+ string.Format(CultureInfo.InvariantCulture, "{0} [{1}]{2}[/]",
+ r.Request.Component.GetDisplayName(), accent, r.ResolvedVersion)).ToList();
+ SpectreAnsiConsole.MarkupLine(string.Format(
+ CultureInfo.InvariantCulture,
+ "Installing {0} to [{1}]{2}[/]...",
+ string.Join(", ", descriptions),
+ accent,
+ escapedPath));
+
+ InstallBatchResult batchResult;
{
- SpectreAnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[green]Installed {componentDescription} {orchestratorResult.Install.Version}, available via {orchestratorResult.Install.InstallRoot}[/]");
+ IProgressTarget progressTarget = noProgress ? new NonUpdatingProgressTarget() : new SpectreProgressTarget();
+ using var sharedReporter = new LazyProgressReporter(progressTarget);
+ batchResult = InstallerOrchestratorSingleton.Instance.InstallMany(requests, sharedReporter);
}
- return new InstallResult(orchestratorResult.Install, orchestratorResult.WasAlreadyInstalled);
+ DisplayBatchResults(batchResult);
+ return batchResult;
}
///
- /// Executes the installation of additional versions of a .NET component.
+ /// Displays a summary of batch install results, grouping by newly installed, already installed, and failed.
///
- /// The list of additional versions to install.
- /// The installation root path.
- /// The component type to install.
- /// Description of the component for display.
- /// Optional manifest path.
- /// Whether to suppress progress display.
- /// If true, fail when the muxer cannot be updated.
- /// True if all installations succeeded, false if any failed.
- public static bool ExecuteAdditionalInstalls(
- IEnumerable additionalVersions,
- DotnetInstallRoot installRoot,
- InstallComponent component,
- string componentDescription,
- string? manifestPath,
- bool noProgress,
- bool requireMuxerUpdate = false)
+ private static void DisplayBatchResults(InstallBatchResult batchResult)
{
- bool allSucceeded = true;
+ var installed = new List();
+ var alreadyInstalled = new List();
+ string? sharedPath = null;
- foreach (var additionalVersion in additionalVersions)
+ foreach (var result in batchResult.Successes)
{
- var additionalRequest = new DotnetInstallRequest(
- installRoot,
- new UpdateChannel(additionalVersion),
- component,
- new InstallRequestOptions
- {
- ManifestPath = manifestPath,
- RequireMuxerUpdate = requireMuxerUpdate
- });
-
- try
+ sharedPath ??= result.Install.InstallRoot.Path;
+ string successAccent = DotnetupTheme.Current.SuccessAccent;
+ string installDetailLine = string.Format(CultureInfo.InvariantCulture, "{0} [{1}]{2}[/]", result.Install.Component.GetDisplayName(), successAccent, result.Install.Version.ToString().EscapeMarkup());
+ if (result.WasAlreadyInstalled)
{
- var additionalResult = InstallerOrchestratorSingleton.Instance.Install(additionalRequest, noProgress);
- SpectreAnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[green]Installed additional {componentDescription} {additionalResult.Install.Version}, available via {additionalResult.Install.InstallRoot}[/]");
+ alreadyInstalled.Add(installDetailLine);
}
- catch (DotnetInstallException)
+ else
{
- SpectreAnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[red]Failed to install additional {componentDescription} {additionalVersion}[/]");
- allSucceeded = false;
+ installed.Add(installDetailLine);
}
}
- return allSucceeded;
- }
-
- ///
- /// Configures the default .NET installation if requested.
- ///
- /// The install manager.
- /// Whether to set as default install.
- /// The installation path.
- public static void ConfigureDefaultInstallIfRequested(
- IDotnetInstallManager dotnetInstaller,
- bool setDefaultInstall,
- string installPath)
- {
- if (setDefaultInstall)
- {
- dotnetInstaller.ConfigureInstallType(InstallType.User, installPath);
- }
- }
-
- ///
- /// Displays completion message.
- ///
- public static void DisplayComplete()
- {
- SpectreAnsiConsole.WriteLine("Complete!");
- }
-
- ///
- /// Determines whether the given path is an admin/system-managed .NET install location.
- /// These locations are managed by system package managers or OS installers and should not
- /// be used by dotnetup for user-level installations.
- ///
- public static bool IsAdminInstallPath(string path)
- {
- var fullPath = Path.GetFullPath(path);
+ EmitBatchSummaryLines(installed, alreadyInstalled, sharedPath);
- if (OperatingSystem.IsWindows())
+ if (batchResult.Failures.Count > 0)
{
- var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
- var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
-
- if ((!string.IsNullOrEmpty(programFiles) && IsOrIsUnder(fullPath, Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase)) ||
- (!string.IsNullOrEmpty(programFilesX86) && IsOrIsUnder(fullPath, Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase)))
+ SpectreAnsiConsole.MarkupLine(string.Format(CultureInfo.InvariantCulture,
+ "[{0}]The following installs failed:[/]", DotnetupTheme.Current.Error));
+ foreach (var failure in batchResult.Failures)
{
- return true;
+ SpectreAnsiConsole.MarkupLine(string.Format(CultureInfo.InvariantCulture,
+ " [{0}]{1} {2}: {3}[/]",
+ DotnetupTheme.Current.Error,
+ failure.Request.Request.Component.GetDisplayName(),
+ failure.Request.ResolvedVersion.ToString().EscapeMarkup(),
+ failure.Exception.Message.EscapeMarkup()));
}
}
- else
- {
- // Standard admin/package-manager locations on Linux and macOS
- if (IsOrIsUnder(fullPath, "/usr/share/dotnet", StringComparison.Ordinal) ||
- IsOrIsUnder(fullPath, "/usr/lib/dotnet", StringComparison.Ordinal) ||
- IsOrIsUnder(fullPath, "/usr/local/share/dotnet", StringComparison.Ordinal))
- {
- return true;
- }
- }
-
- return false;
}
- // Checks whether fullPath equals adminPath or is a child directory of it.
- // A separate equality check prevents false matches on path prefixes
- // (e.g. "C:\Program Files\dotnet is cool" matching "C:\Program Files\dotnet").
- private static bool IsOrIsUnder(string fullPath, string adminPath, StringComparison comparison)
+ private static void EmitBatchSummaryLines(List installed, List alreadyInstalled, string? sharedPath)
{
- return string.Equals(fullPath, adminPath, comparison) ||
- fullPath.StartsWith(adminPath + Path.DirectorySeparatorChar, comparison);
- }
-
- ///
- /// Classifies the install path for telemetry (no PII - just the type of location).
- /// When pathSource is provided, global_json paths are distinguished from other path types.
- ///
- /// The install path to classify.
- /// How the path was determined (e.g., "global_json", "explicit"). Null to skip source-based classification.
- public static string ClassifyInstallPath(string path, PathSource? pathSource = null)
- {
- var fullPath = Path.GetFullPath(path);
-
- // Check for admin/system .NET paths first — these are the most important to distinguish
- if (IsAdminInstallPath(path))
- {
- return "admin";
- }
+ string successAccent = DotnetupTheme.Current.SuccessAccent;
+ string escapedPath = sharedPath?.EscapeMarkup() ?? string.Empty;
- if (OperatingSystem.IsWindows())
+ if (installed.Count > 0)
{
- var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
- var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
-
- if (!string.IsNullOrEmpty(programFiles) && fullPath.StartsWith(programFiles, StringComparison.OrdinalIgnoreCase))
- {
- return "system_programfiles";
- }
- if (!string.IsNullOrEmpty(programFilesX86) && fullPath.StartsWith(programFilesX86, StringComparison.OrdinalIgnoreCase))
- {
- return "system_programfiles_x86";
- }
-
- // Check more-specific paths before less-specific ones:
- // LocalApplicationData (e.g., C:\Users\x\AppData\Local) is under UserProfile (C:\Users\x)
- var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
- if (!string.IsNullOrEmpty(localAppData) && fullPath.StartsWith(localAppData, StringComparison.OrdinalIgnoreCase))
- {
- return "local_appdata";
- }
-
- var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
- if (!string.IsNullOrEmpty(userProfile) && fullPath.StartsWith(userProfile, StringComparison.OrdinalIgnoreCase))
+ SpectreAnsiConsole.MarkupLine(string.Format(CultureInfo.InvariantCulture,
+ "Installed at [{0}]{1}[/]:", successAccent, escapedPath));
+ foreach (var item in installed)
{
- return "user_profile";
+ SpectreAnsiConsole.MarkupLine(string.Format(CultureInfo.InvariantCulture,
+ " {0}", item));
}
}
- else
- {
- if (fullPath.StartsWith("/usr/", StringComparison.Ordinal) ||
- fullPath.StartsWith("/opt/", StringComparison.Ordinal))
- {
- return "system_path";
- }
- var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
- if (!string.IsNullOrEmpty(home) && fullPath.StartsWith(home, StringComparison.Ordinal))
+ if (alreadyInstalled.Count > 0)
+ {
+ SpectreAnsiConsole.MarkupLine(string.Format(CultureInfo.InvariantCulture,
+ "Already installed at [{0}]{1}[/]:", successAccent, escapedPath));
+ foreach (var item in alreadyInstalled)
{
- return "user_home";
+ SpectreAnsiConsole.MarkupLine(string.Format(CultureInfo.InvariantCulture,
+ " {0}", item));
}
}
-
- // If the path was specified by global.json and doesn't match a well-known location,
- // classify it as global_json rather than generic "other"
- if (pathSource == PathSource.GlobalJson)
- {
- return "global_json";
- }
-
- return "other";
}
}
diff --git a/src/Installer/dotnetup/Commands/Shared/InstallPathClassifier.cs b/src/Installer/dotnetup/Commands/Shared/InstallPathClassifier.cs
new file mode 100644
index 000000000000..3725ed2c9d53
--- /dev/null
+++ b/src/Installer/dotnetup/Commands/Shared/InstallPathClassifier.cs
@@ -0,0 +1,125 @@
+// 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.Commands.Shared;
+
+///
+/// Resolves the installation path for .NET components based on various inputs including
+/// global.json, explicit paths, current installations, and user prompts.
+///
+internal static class InstallPathClassifier
+{
+ ///
+ /// Determines whether the given path is an admin/system-managed .NET install location.
+ /// These locations are managed by system package managers or OS installers and should not
+ /// be used by dotnetup for user-level installations.
+ ///
+ public static bool IsAdminInstallPath(string path)
+ {
+ var fullPath = Path.GetFullPath(path);
+
+ if (OperatingSystem.IsWindows())
+ {
+ var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
+ var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
+
+ if ((!string.IsNullOrEmpty(programFiles) && IsOrIsUnder(fullPath, Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase)) ||
+ (!string.IsNullOrEmpty(programFilesX86) && IsOrIsUnder(fullPath, Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+ }
+ else
+ {
+ // Standard system/package-manager locations on Linux and macOS.
+ // See https://github.com/dotnet/designs/blob/main/accepted/2021/install-location-per-architecture.md
+ if (IsOrIsUnder(fullPath, "/usr/share/dotnet", StringComparison.Ordinal) ||
+ IsOrIsUnder(fullPath, "/usr/lib/dotnet", StringComparison.Ordinal) ||
+ IsOrIsUnder(fullPath, "/usr/lib64/dotnet", StringComparison.Ordinal) ||
+ IsOrIsUnder(fullPath, "/usr/local/share/dotnet", StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // Checks whether fullPath equals adminPath or is a child directory of it.
+ // A separate equality check prevents false matches on path prefixes
+ // (e.g. "C:\Program Files\dotnet is cool" matching "C:\Program Files\dotnet").
+ private static bool IsOrIsUnder(string fullPath, string adminPath, StringComparison comparison)
+ {
+ return string.Equals(fullPath, adminPath, comparison) ||
+ fullPath.StartsWith(adminPath + Path.DirectorySeparatorChar, comparison);
+ }
+
+ ///
+ /// Classifies the install path for telemetry (no PII - just the type of location).
+ /// When pathSource is provided, global_json paths are distinguished from other path types.
+ ///
+ /// The install path to classify.
+ /// How the path was determined (e.g., "global_json", "explicit"). Null to skip source-based classification.
+ public static string ClassifyInstallPath(string path, PathSource? pathSource = null)
+ {
+ var fullPath = Path.GetFullPath(path);
+
+ // Check for admin/system .NET paths first — these are the most important to distinguish
+ if (IsAdminInstallPath(path))
+ {
+ return "admin";
+ }
+
+ if (OperatingSystem.IsWindows())
+ {
+ var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
+ var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
+
+ if (!string.IsNullOrEmpty(programFiles) && fullPath.StartsWith(programFiles, StringComparison.OrdinalIgnoreCase))
+ {
+ return "system_programfiles";
+ }
+ if (!string.IsNullOrEmpty(programFilesX86) && fullPath.StartsWith(programFilesX86, StringComparison.OrdinalIgnoreCase))
+ {
+ return "system_programfiles_x86";
+ }
+
+ // Check more-specific paths before less-specific ones:
+ // LocalApplicationData (e.g., C:\Users\x\AppData\Local) is under UserProfile (C:\Users\x)
+ var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ if (!string.IsNullOrEmpty(localAppData) && fullPath.StartsWith(localAppData, StringComparison.OrdinalIgnoreCase))
+ {
+ return "local_appdata";
+ }
+
+ var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ if (!string.IsNullOrEmpty(userProfile) && fullPath.StartsWith(userProfile, StringComparison.OrdinalIgnoreCase))
+ {
+ return "user_profile";
+ }
+ }
+ else
+ {
+ if (fullPath.StartsWith("/usr/", StringComparison.Ordinal) ||
+ fullPath.StartsWith("/opt/", StringComparison.Ordinal))
+ {
+ return "system_path";
+ }
+
+ var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ if (!string.IsNullOrEmpty(home) && fullPath.StartsWith(home, StringComparison.Ordinal))
+ {
+ return "user_home";
+ }
+ }
+
+ // If the path was specified by global.json and doesn't match a well-known location,
+ // classify it as global_json rather than generic "other"
+ if (pathSource == PathSource.GlobalJson)
+ {
+ return "global_json";
+ }
+
+ return "other";
+ }
+}
diff --git a/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs b/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs
index 799d02f47a58..0b7c54798cde 100644
--- a/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs
+++ b/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs
@@ -1,9 +1,6 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-using Spectre.Console;
-using SpectreAnsiConsole = Spectre.Console.AnsiConsole;
-
namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared;
///
@@ -12,11 +9,11 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared;
///
internal class InstallPathResolver
{
- private readonly IDotnetInstallManager _dotnetInstaller;
+ private readonly IDotnetEnvironmentManager _dotnetEnvironment;
- public InstallPathResolver(IDotnetInstallManager dotnetInstaller)
+ public InstallPathResolver(IDotnetEnvironmentManager dotnetEnvironment)
{
- _dotnetInstaller = dotnetInstaller;
+ _dotnetEnvironment = dotnetEnvironment;
}
///
@@ -36,25 +33,18 @@ public record InstallPathResolutionResult(
/// 1. Path from global.json (if available)
/// 2. Explicitly provided install path
/// 3. Current user installation path (if exists)
- /// 4. Interactive prompt (if interactive mode)
- /// 5. Default install path
+ /// 4. Default install path
///
/// The install path explicitly provided by the user (e.g., --install-path option).
/// Information from global.json, if available.
/// Current .NET installation configuration, if any.
- /// Whether to prompt the user for input.
- /// Description of the component being installed (e.g., ".NET SDK", ".NET Runtime").
- /// Output parameter for any error message.
- /// The resolution result, or null if an error occurred.
- public InstallPathResolutionResult? Resolve(
+ /// The resolution result.
+ /// Thrown when the install path cannot be resolved.
+ public InstallPathResolutionResult Resolve(
string? explicitInstallPath,
GlobalJsonInfo? globalJsonInfo,
- DotnetInstallRootConfiguration? currentDotnetInstallRoot,
- bool interactive,
- string componentDescription,
- out string? error)
+ DotnetInstallRootConfiguration? currentDotnetInstallRoot)
{
- error = null;
string? installPathFromGlobalJson = globalJsonInfo?.GlobalJsonPath is not null
? globalJsonInfo.SdkPath
: null;
@@ -63,8 +53,7 @@ public record InstallPathResolutionResult(
// 1. Explicit --install-path always wins
// 2. global.json sdk-path
// 3. Existing user installation
- // 4. Interactive prompt
- // 5. Default install path
+ // 4. Default install path
if (explicitInstallPath is not null)
{
@@ -78,16 +67,9 @@ public record InstallPathResolutionResult(
{
return new InstallPathResolutionResult(currentDotnetInstallRoot.Path, installPathFromGlobalJson, PathSource.ExistingUserInstall);
}
- else if (interactive)
- {
- var prompted = SpectreAnsiConsole.Prompt(
- new TextPrompt($"Where should we install the {componentDescription} to?")
- .DefaultValue(_dotnetInstaller.GetDefaultDotnetInstallPath()));
- return new InstallPathResolutionResult(prompted, installPathFromGlobalJson, PathSource.InteractivePrompt);
- }
else
{
- return new InstallPathResolutionResult(_dotnetInstaller.GetDefaultDotnetInstallPath(), installPathFromGlobalJson, PathSource.Default);
+ return new InstallPathResolutionResult(_dotnetEnvironment.GetDefaultDotnetInstallPath(), installPathFromGlobalJson, PathSource.Default);
}
}
}
diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs b/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs
deleted file mode 100644
index fcfaef1f7123..000000000000
--- a/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs
+++ /dev/null
@@ -1,243 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System.Diagnostics;
-using Microsoft.Deployment.DotNet.Releases;
-using Microsoft.Dotnet.Installation.Internal;
-using Microsoft.DotNet.Tools.Bootstrapper.Telemetry;
-using Spectre.Console;
-using SpectreAnsiConsole = Spectre.Console.AnsiConsole;
-
-namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared;
-
-///
-/// Handles interactive prompts and decision-making during .NET component installation.
-/// This includes resolving channel/version, set-default-install preferences, and global.json updates.
-///
-/// Note: Install path prompting is handled by to keep path resolution
-/// logic self-contained. This class focuses on post-path-resolution decisions.
-///
-internal class InstallWalkthrough
-{
- private readonly IDotnetInstallManager _dotnetInstaller;
- private readonly ChannelVersionResolver _channelVersionResolver;
- private readonly InstallWorkflow.InstallWorkflowOptions _options;
-#pragma warning disable IDE0032 // Lazy-init via ??=; not convertible to auto-property
- private InstallRootManager? _installRootManager;
-#pragma warning restore IDE0032
-
- public InstallWalkthrough(
- IDotnetInstallManager dotnetInstaller,
- ChannelVersionResolver channelVersionResolver,
- InstallWorkflow.InstallWorkflowOptions options)
- {
- _dotnetInstaller = dotnetInstaller;
- _channelVersionResolver = channelVersionResolver;
- _options = options;
- }
-
- private InstallRootManager InstallRootManager => _installRootManager ??= new InstallRootManager(_dotnetInstaller);
-
- ///
- /// Prompts the user to install a higher admin version when switching to user install.
- /// This is relevant when the user is setting up a user install and has a higher version in admin install.
- ///
- /// The version being installed.
- /// Whether the install will be set as default.
- /// Current installation configuration.
- /// List of additional versions to install, empty if none.
- public List GetAdditionalAdminVersionsToMigrate(
- ReleaseVersion? resolvedVersion,
- bool setDefaultInstall,
- DotnetInstallRootConfiguration? currentInstallRoot)
- {
- var additionalVersions = new List();
-
- if (setDefaultInstall && currentInstallRoot?.InstallType == InstallType.Admin)
- {
- // Track admin-to-user migration scenario
- Activity.Current?.SetTag(TelemetryTagNames.InstallMigratingFromAdmin, true);
-
- if (_options.Interactive)
- {
- var latestAdminVersion = _dotnetInstaller.GetLatestInstalledAdminVersion();
- if (latestAdminVersion is not null && resolvedVersion < new ReleaseVersion(latestAdminVersion))
- {
- SpectreAnsiConsole.WriteLine($"Since the admin installs of the {_options.ComponentDescription} 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 {_options.ComponentDescription} continues to be used for projects that don't specify a .NET SDK version in global.json.");
-
- if (SpectreAnsiConsole.Confirm($"Also install {_options.ComponentDescription} {latestAdminVersion}?",
- defaultValue: true))
- {
- additionalVersions.Add(latestAdminVersion);
- Activity.Current?.SetTag(TelemetryTagNames.InstallAdminVersionCopied, true);
- }
- }
- }
- // TODO: Add command-line option for installing admin versions locally
- }
-
- return additionalVersions;
- }
-
- ///
- /// Resolves the channel or version to install, considering global.json and user input.
- ///
- /// The channel resolved from global.json, if any.
- /// Path to the global.json file, for display purposes.
- /// The default channel to use if none specified (typically "latest").
- /// The resolved channel or version string.
- public string ResolveChannel(
- string? channelFromGlobalJson,
- string? globalJsonPath,
- string defaultChannel = "latest")
- {
- // Explicit version/channel from the user always takes priority
- if (_options.VersionOrChannel is not null)
- {
- return _options.VersionOrChannel;
- }
-
- if (channelFromGlobalJson is not null)
- {
- SpectreAnsiConsole.WriteLine($"{_options.ComponentDescription} {channelFromGlobalJson} will be installed since {globalJsonPath} specifies that version.");
- return channelFromGlobalJson;
- }
-
- if (_options.Interactive)
- {
- // Feature bands (like 9.0.1xx) are SDK-specific, don't show them for runtimes
- bool includeFeatureBands = _options.Component == InstallComponent.SDK;
- SpectreAnsiConsole.WriteLine("Available supported channels: " + string.Join(' ', _channelVersionResolver.GetSupportedChannels(includeFeatureBands)));
-
- // Use appropriate version example for SDK vs Runtime
- string versionExample = _options.Component == InstallComponent.SDK ? "9.0.304" : "9.0.12";
- SpectreAnsiConsole.WriteLine($"You can also specify a specific version (for example {versionExample}).");
-
- return SpectreAnsiConsole.Prompt(
- new TextPrompt($"Which channel of the {_options.ComponentDescription} do you want to install?")
- .DefaultValue(defaultChannel));
- }
-
- return defaultChannel;
- }
-
- ///
- /// Determines whether global.json should be updated based on channel mismatch.
- ///
- /// The channel from global.json.
- /// True if global.json should be updated, false otherwise, or null if not determined.
- public bool? ResolveUpdateGlobalJson(string? channelFromGlobalJson)
- {
- if (channelFromGlobalJson is not null && _options.VersionOrChannel is not null &&
- // TODO: Should channel comparison be case-sensitive?
- !channelFromGlobalJson.Equals(_options.VersionOrChannel, StringComparison.OrdinalIgnoreCase))
- {
- if (_options.Interactive && _options.UpdateGlobalJson == null)
- {
- return SpectreAnsiConsole.Confirm(
- $"The channel specified in global.json ({channelFromGlobalJson}) does not match the channel specified ({_options.VersionOrChannel}). Do you want to update global.json to match the specified channel?",
- defaultValue: true);
- }
- }
-
- return null;
- }
-
- ///
- /// Resolves whether the installation should be set as the default .NET install.
- ///
- /// The current .NET installation configuration.
- /// The resolved installation path.
- /// True if the install path came from global.json, which typically means it's repo-local.
- /// True if the installation should be set as default, false otherwise.
- public bool ResolveSetDefaultInstall(
- DotnetInstallRootConfiguration? currentDotnetInstallRoot,
- string resolvedInstallPath,
- bool installPathCameFromGlobalJson)
- {
- bool? resolvedSetDefaultInstall = _options.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 (_options.Interactive && !installPathCameFromGlobalJson)
- {
- 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 (DotnetupUtilities.PathsEqual(resolvedInstallPath, currentDotnetInstallRoot.Path))
- {
- // If the current install is fully configured and matches the resolved path, skip the prompt
- if (currentDotnetInstallRoot.IsFullyConfigured)
- {
- // Default install is already set up correctly, no need to prompt
- resolvedSetDefaultInstall = false;
- }
- else
- {
- // Not fully configured - display what needs to be configured and prompt
- if (OperatingSystem.IsWindows())
- {
- UserInstallRootChanges userInstallRootChanges = InstallRootManager.GetUserInstallRootChanges();
-
- SpectreAnsiConsole.WriteLine($"The .NET installation at {resolvedInstallPath} is not fully configured.");
- SpectreAnsiConsole.WriteLine("The following changes are needed:");
-
- if (userInstallRootChanges.NeedsRemoveAdminPath)
- {
- SpectreAnsiConsole.WriteLine(" - Remove admin .NET paths from system PATH");
- }
- if (userInstallRootChanges.NeedsAddToUserPath)
- {
- SpectreAnsiConsole.WriteLine($" - Add {userInstallRootChanges.UserDotnetPath} to user PATH");
- }
- if (userInstallRootChanges.NeedsSetDotnetRoot)
- {
- SpectreAnsiConsole.WriteLine($" - Set DOTNET_ROOT to {userInstallRootChanges.UserDotnetPath}");
- }
-
- resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm(
- "Do you want to apply these configuration changes?",
- defaultValue: true);
- }
- else
- {
- // On non-Windows, we don't have detailed configuration info
- // 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 \"dotnetup 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
- }
- }
-
- return resolvedSetDefaultInstall ?? false;
- }
-}
diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs
index 25ecb7b99a96..c466f31d4bb2 100644
--- a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs
+++ b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs
@@ -2,228 +2,285 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
+using System.Globalization;
+using Microsoft.Deployment.DotNet.Releases;
using Microsoft.Dotnet.Installation;
using Microsoft.Dotnet.Installation.Internal;
+using Microsoft.DotNet.Tools.Bootstrapper.Commands.Walkthrough;
using Microsoft.DotNet.Tools.Bootstrapper.Telemetry;
+using Spectre.Console;
+using SpectreAnsiConsole = Spectre.Console.AnsiConsole;
namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared;
///
/// Shared installation workflow that handles the common installation logic for SDK and Runtime commands.
+/// Generates install requests, validates paths, and either executes directly or delegates to the
+/// walkthrough for environment configuration.
///
internal class InstallWorkflow
{
- private readonly IDotnetInstallManager _dotnetInstaller;
- private readonly ChannelVersionResolver _channelVersionResolver;
+ private readonly InstallCommand _command;
private readonly InstallPathResolver _installPathResolver;
- public InstallWorkflow(IDotnetInstallManager dotnetInstaller, ChannelVersionResolver channelVersionResolver)
+ public InstallWorkflow(InstallCommand command)
{
- _dotnetInstaller = dotnetInstaller;
- _channelVersionResolver = channelVersionResolver;
- _installPathResolver = new InstallPathResolver(dotnetInstaller);
+ _command = command;
+ _installPathResolver = new InstallPathResolver(command.DotnetEnvironment);
}
- public record InstallWorkflowOptions(
- string? VersionOrChannel,
- string? InstallPath,
- bool? SetDefaultInstall,
- string? ManifestPath,
- bool Interactive,
- bool NoProgress,
- InstallComponent Component,
- string ComponentDescription,
- bool? UpdateGlobalJson = null,
- Func? ResolveChannelFromGlobalJson = null,
- bool RequireMuxerUpdate = false,
- bool Untracked = false);
-
///
- /// Holds all resolved state during workflow execution, eliminating repeated parameter passing.
+ /// Executes the install workflow for the given component specifications.
+ /// Each spec is a (component, channel) pair where channel may be null (defaults to global.json or "latest").
+ /// When an explicit install path is provided, installs directly.
+ /// When interactive and no explicit path, wraps execution in the walkthrough
+ /// for environment configuration (path preference, admin migration, etc.).
+ /// Otherwise, installs to the default/resolved path without prompting.
///
- private record WorkflowContext(
- InstallWorkflowOptions Options,
- InstallWalkthrough Walkthrough,
- GlobalJsonInfo? GlobalJson,
- DotnetInstallRootConfiguration? CurrentInstallRoot,
- string InstallPath,
- string? InstallPathFromGlobalJson,
- string Channel,
- bool SetDefaultInstall,
- bool? UpdateGlobalJson,
- string RequestSource,
- PathSource PathSource);
-
- public void Execute(InstallWorkflowOptions options)
+ public void Execute(MinimalInstallSpec[] componentSpecs)
{
- // Record telemetry for the install request
- Activity.Current?.SetTag(TelemetryTagNames.InstallComponent, options.Component.ToString());
- Activity.Current?.SetTag(TelemetryTagNames.InstallRequestedVersion, VersionSanitizer.Sanitize(options.VersionOrChannel));
- Activity.Current?.SetTag(TelemetryTagNames.InstallPathExplicit, options.InstallPath is not null);
+ var requests = GenerateInstallRequests(componentSpecs);
- var context = ResolveWorkflowContext(options, out string? error);
- if (context is null)
+ if (_command.InstallPath is not null || !_command.Interactive)
{
- throw new DotnetInstallException(DotnetInstallErrorCode.ContextResolutionFailed, error ?? "Failed to resolve workflow context.");
+ // Explicit path or non-interactive — skip walkthrough entirely
+ ExecuteInstallRequests(requests);
}
-
- // Block install paths that point to existing files (not directories)
- if (File.Exists(context.InstallPath))
+ else
{
- throw new DotnetInstallException(
- DotnetInstallErrorCode.InstallPathIsFile,
- $"The install path '{context.InstallPath}' is an existing file, not a directory. " +
- "Please specify a directory path for the installation.");
+ // Interactive with no explicit path — walkthrough for path preference, admin migration, etc.
+ var workflows = new WalkthroughWorkflows(_command.DotnetEnvironment, _command.ChannelVersionResolver);
+ workflows.BaseConfigurationWalkthrough(
+ requests,
+ () => ExecuteInstallRequests(requests),
+ _command.NoProgress,
+ _command.Interactive,
+ true,
+ false);
}
- // Block admin/system-managed install paths — dotnetup should not install there
- if (InstallExecutor.IsAdminInstallPath(context.InstallPath))
+ // Global.json update runs after install in all code paths, but only when
+ // the command opted in (e.g. `sdk install --update-global-json`).
+ // The walkthrough intentionally does NOT own this — only command-level
+ // flags control whether the global.json file is mutated.
+ if (_command.UpdateGlobalJson)
{
- Activity.Current?.SetTag(TelemetryTagNames.InstallPathType, "admin");
- Activity.Current?.SetTag(TelemetryTagNames.InstallPathSource, context.PathSource.ToString().ToLowerInvariant());
- throw new DotnetInstallException(
- DotnetInstallErrorCode.AdminPathBlocked,
- $"The install path '{context.InstallPath}' is a system-managed .NET location. " +
- "dotnetup cannot install to the default system .NET directory (Program Files\\dotnet on Windows, /usr/share/dotnet on Linux/macOS). " +
- "Use your system package manager or the official installer for system-wide installations, or choose a different path.");
+ _command.DotnetEnvironment.ApplyGlobalJsonModifications(requests);
}
-
- // Record resolved context telemetry
- Activity.Current?.SetTag(TelemetryTagNames.InstallHasGlobalJson, context.GlobalJson?.GlobalJsonPath is not null);
- Activity.Current?.SetTag(TelemetryTagNames.InstallExistingInstallType, context.CurrentInstallRoot?.InstallType.ToString() ?? "none");
- Activity.Current?.SetTag(TelemetryTagNames.InstallSetDefault, context.SetDefaultInstall);
- Activity.Current?.SetTag(TelemetryTagNames.InstallPathType, InstallExecutor.ClassifyInstallPath(context.InstallPath, context.PathSource));
- Activity.Current?.SetTag(TelemetryTagNames.InstallPathSource, context.PathSource.ToString().ToLowerInvariant());
-
- // Record request source (how the version/channel was determined)
- Activity.Current?.SetTag(TelemetryTagNames.DotnetRequestSource, context.RequestSource);
- Activity.Current?.SetTag(TelemetryTagNames.DotnetRequested, VersionSanitizer.Sanitize(context.Channel));
-
- var resolved = CreateInstallRequest(context);
-
- // Record resolved version
- Activity.Current?.SetTag(TelemetryTagNames.InstallResolvedVersion, resolved.ResolvedVersion?.ToString());
-
- var installResult = ExecuteInstallations(context, resolved);
-
- ApplyPostInstallConfiguration(context, resolved);
-
- Activity.Current?.SetTag(TelemetryTagNames.InstallResult, installResult.WasAlreadyInstalled ? "already_installed" : "installed");
- InstallExecutor.DisplayComplete();
}
- private WorkflowContext? ResolveWorkflowContext(InstallWorkflowOptions options, out string? error)
+ ///
+ /// Generates resolved install requests for the given component specifications.
+ /// Handles path resolution, global.json channel inference (for SDK when no channel specified),
+ /// install path validation, and version resolution via the channel version resolver.
+ ///
+ public List GenerateInstallRequests(
+ MinimalInstallSpec[] componentSpecs)
{
- var walkthrough = new InstallWalkthrough(_dotnetInstaller, _channelVersionResolver, options);
- var globalJson = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory);
- var currentInstallRoot = _dotnetInstaller.GetConfiguredInstallType();
+ var globalJson = GlobalJsonModifier.GetGlobalJsonInfo(Environment.CurrentDirectory);
+ var currentInstallRoot = _command.DotnetEnvironment.GetCurrentPathConfiguration();
var pathResolution = _installPathResolver.Resolve(
- options.InstallPath,
+ _command.InstallPath,
globalJson,
- currentInstallRoot,
- options.Interactive,
- options.ComponentDescription,
- out error);
+ currentInstallRoot);
- if (pathResolution is null)
+ ValidateInstallPath(pathResolution.ResolvedInstallPath, pathResolution.PathSource, _command.ManifestPath);
+
+ var installRoot = new DotnetInstallRoot(
+ pathResolution.ResolvedInstallPath,
+ InstallerUtilities.GetDefaultInstallArchitecture());
+
+ var requests = new List();
+ foreach (var spec in componentSpecs)
{
- return null;
+ var resolved = ResolveSpec(spec, installRoot, globalJson, currentInstallRoot, pathResolution);
+ requests.Add(resolved);
}
- string? channelFromGlobalJson = null;
- bool? updateGlobalJson = null;
+ return requests;
+ }
- if (options.ResolveChannelFromGlobalJson is not null && globalJson?.GlobalJsonPath is not null)
+ ///
+ /// Resolves a single to a
+ /// by inferring the channel (from global.json or "latest"), resolving the version, and recording telemetry.
+ ///
+ private ResolvedInstallRequest ResolveSpec(
+ MinimalInstallSpec spec,
+ DotnetInstallRoot installRoot,
+ GlobalJsonInfo? globalJson,
+ DotnetInstallRootConfiguration? currentInstallRoot,
+ InstallPathResolver.InstallPathResolutionResult pathResolution)
+ {
+ var component = spec.Component;
+ var explicitChannel = spec.VersionOrChannel;
+
+ var (channel, isFromGlobalJson) = ResolveChannel(component, explicitChannel, globalJson);
+
+ var request = new DotnetInstallRequest(
+ installRoot,
+ new UpdateChannel(channel),
+ component,
+ new InstallRequestOptions
+ {
+ ManifestPath = _command.ManifestPath,
+ RequireMuxerUpdate = _command.RequireMuxerUpdate,
+ InstallSource = isFromGlobalJson ? InstallRequestSource.GlobalJson : InstallRequestSource.Explicit,
+ GlobalJsonPath = (isFromGlobalJson || _command.UpdateGlobalJson) ? globalJson?.GlobalJsonPath : null,
+ Untracked = _command.Untracked,
+ Verbosity = _command.Verbosity
+ });
+
+ var resolvedVersion = _command.ChannelVersionResolver.Resolve(request);
+
+ if (resolvedVersion is null)
{
- channelFromGlobalJson = options.ResolveChannelFromGlobalJson(globalJson.GlobalJsonPath);
- updateGlobalJson = walkthrough.ResolveUpdateGlobalJson(channelFromGlobalJson);
+ throw new DotnetInstallException(
+ DotnetInstallErrorCode.VersionNotFound,
+ $"Could not resolve channel '{channel}' to a .NET version for {component.GetDisplayName()}.");
}
- string channel = walkthrough.ResolveChannel(channelFromGlobalJson, globalJson?.GlobalJsonPath);
- bool setDefaultInstall = walkthrough.ResolveSetDefaultInstall(
- currentInstallRoot,
- pathResolution.ResolvedInstallPath,
- installPathCameFromGlobalJson: pathResolution.InstallPathFromGlobalJson is not null);
+ var resolved = new ResolvedInstallRequest(request, resolvedVersion);
- // Classify how the version/channel was determined for telemetry
- string requestSource = options.VersionOrChannel is not null
- ? "explicit"
- : channelFromGlobalJson is not null
- ? "default-globaljson"
- : "default-latest";
+ RecordInstallTelemetry(
+ component, explicitChannel,
+ _command.InstallPath, globalJson, currentInstallRoot,
+ pathResolution, channel, resolved);
- return new WorkflowContext(
- options,
- walkthrough,
- globalJson,
- currentInstallRoot,
- pathResolution.ResolvedInstallPath,
- pathResolution.InstallPathFromGlobalJson,
- channel,
- setDefaultInstall,
- updateGlobalJson,
- requestSource,
- pathResolution.PathSource);
+ return resolved;
}
- private InstallExecutor.ResolvedInstallRequest CreateInstallRequest(WorkflowContext context)
+ ///
+ /// Determines the channel for an install spec. If the user provided an explicit channel, uses that.
+ /// For SDK installs with no explicit channel, tries to infer from global.json.
+ /// Falls back to "latest" if nothing else applies.
+ ///
+ private static (string Channel, bool IsFromGlobalJson) ResolveChannel(
+ InstallComponent component,
+ string? explicitChannel,
+ GlobalJsonInfo? globalJson)
{
- // Only tag as GlobalJson source if the channel/version actually came from global.json,
- // not just because a global.json file exists in the directory.
- var installSource = context.RequestSource == "default-globaljson"
- ? InstallRequestSource.GlobalJson
- : InstallRequestSource.Explicit;
-
- return InstallExecutor.CreateAndResolveRequest(
- context.InstallPath,
- context.Channel,
- context.Options.Component,
- context.Options.ManifestPath,
- _channelVersionResolver,
- context.Options.RequireMuxerUpdate,
- installSource,
- installSource == InstallRequestSource.GlobalJson ? context.GlobalJson?.GlobalJsonPath : null,
- context.Options.Untracked);
+ if (explicitChannel is not null)
+ {
+ return (explicitChannel, false);
+ }
+
+ if (component == InstallComponent.SDK && globalJson?.GlobalJsonPath is not null)
+ {
+ string? channelFromGlobalJson = GlobalJsonChannelResolver.ResolveChannel(globalJson.GlobalJsonPath);
+ if (channelFromGlobalJson is not null)
+ {
+ SpectreAnsiConsole.MarkupLine(string.Format(
+ CultureInfo.InvariantCulture,
+ "[{0}]{1} {2} will be installed since {3} specifies that version.[/]",
+ DotnetupTheme.Current.Dim,
+ component.GetDisplayName(),
+ channelFromGlobalJson,
+ globalJson.GlobalJsonPath));
+ return (channelFromGlobalJson, true);
+ }
+ }
+
+ return (ChannelVersionResolver.LatestChannel, false);
}
- private static InstallExecutor.InstallResult ExecuteInstallations(WorkflowContext context, InstallExecutor.ResolvedInstallRequest resolved)
+ ///
+ /// Executes resolved install requests via the install executor as a concurrent batch.
+ ///
+ private void ExecuteInstallRequests(List requests)
{
- // Gather all user prompts before starting any downloads.
- // Users may walk away after seeing download progress begin, expecting no more prompts.
- var additionalVersions = context.Walkthrough.GetAdditionalAdminVersionsToMigrate(
- resolved.ResolvedVersion,
- context.SetDefaultInstall,
- context.CurrentInstallRoot);
-
- var installResult = InstallExecutor.ExecuteInstall(
- resolved.Request,
- resolved.ResolvedVersion?.ToString(),
- context.Options.ComponentDescription,
- context.Options.NoProgress);
-
- InstallExecutor.ExecuteAdditionalInstalls(
- additionalVersions,
- resolved.Request.InstallRoot,
- context.Options.Component,
- context.Options.ComponentDescription,
- context.Options.ManifestPath,
- context.Options.NoProgress,
- context.Options.RequireMuxerUpdate);
-
- return installResult;
+ var batchResult = InstallExecutor.ExecuteInstalls(requests, _command.NoProgress);
+
+ foreach (var result in batchResult.Successes)
+ {
+ Activity.Current?.SetTag(TelemetryTagNames.InstallResult, result.WasAlreadyInstalled ? "already_installed" : "installed");
+ }
+
+ if (batchResult.Failures.Count > 0)
+ {
+ throw batchResult.Failures[0].Exception;
+ }
}
- private void ApplyPostInstallConfiguration(WorkflowContext context, InstallExecutor.ResolvedInstallRequest resolved)
+ private void ValidateInstallPath(string installPath, PathSource pathSource, string? manifestPath)
{
- InstallExecutor.ConfigureDefaultInstallIfRequested(_dotnetInstaller, context.SetDefaultInstall, context.InstallPath);
+ // Block install paths that point to existing files (not directories)
+ if (File.Exists(installPath))
+ {
+ throw new DotnetInstallException(
+ DotnetInstallErrorCode.InstallPathIsFile,
+ $"The install path '{installPath}' is an existing file, not a directory. " +
+ "Please specify a directory path for the installation.");
+ }
- if (context.UpdateGlobalJson == true && context.GlobalJson?.GlobalJsonPath is not null)
+ // Block admin/system-managed install paths — dotnetup should not install there
+ if (InstallPathClassifier.IsAdminInstallPath(installPath))
{
- _dotnetInstaller.UpdateGlobalJson(
- context.GlobalJson.GlobalJsonPath,
- resolved.ResolvedVersion!.ToString());
+ Activity.Current?.SetTag(TelemetryTagNames.InstallPathType, "admin");
+ Activity.Current?.SetTag(TelemetryTagNames.InstallPathSource, pathSource.ToString().ToLowerInvariant());
+ throw new DotnetInstallException(
+ DotnetInstallErrorCode.AdminPathBlocked,
+ $"The install path '{installPath}' is a system-managed .NET location. " +
+ "dotnetup cannot install to the default system .NET directory (Program Files\\dotnet on Windows, /usr/share/dotnet on Linux/macOS). " +
+ "Use your system package manager or the official installer for system-wide installations, or choose a different path.");
+ }
+
+ if(!_command.Untracked)
+ {
+ ValidateNoUntrackedArtifacts(installPath, manifestPath);
+ }
+ }
+
+ ///
+ /// Throws if the install path contains .NET artifacts that are not tracked in the manifest.
+ /// This is intentionally skipped when --untracked is specified, since untracked installs
+ /// are expected to coexist with artifacts not in the manifest.
+ ///
+ internal static void ValidateNoUntrackedArtifacts(string installPath, string? manifestPath)
+ {
+ var installRoot = new DotnetInstallRoot(installPath, InstallerUtilities.GetDefaultInstallArchitecture());
+ using var mutex = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates);
+ var manifest = new DotnetupSharedManifest(manifestPath);
+ if (!manifest.IsRootTracked(installRoot)
+ && DotnetupSharedManifest.HasDotnetArtifacts(installRoot.Path))
+ {
+ throw new DotnetInstallException(
+ DotnetInstallErrorCode.Unknown,
+ $"The install path '{installRoot.Path}' already contains a .NET installation that is not tracked by dotnetup. " +
+ "To avoid conflicts, use a different install path or remove the existing installation first.");
}
}
+ private static void RecordInstallTelemetry(
+ InstallComponent component,
+ string? requestedVersionOrChannel,
+ string? explicitInstallPath,
+ GlobalJsonInfo? globalJson,
+ DotnetInstallRootConfiguration? currentInstallRoot,
+ InstallPathResolver.InstallPathResolutionResult pathResolution,
+ string resolvedChannel,
+ ResolvedInstallRequest resolved)
+ {
+ // Request-level tags
+ Activity.Current?.SetTag(TelemetryTagNames.InstallComponent, component.ToString());
+ Activity.Current?.SetTag(TelemetryTagNames.InstallRequestedVersion, VersionSanitizer.Sanitize(requestedVersionOrChannel));
+ Activity.Current?.SetTag(TelemetryTagNames.InstallPathExplicit, explicitInstallPath is not null);
+
+ // Resolved context tags
+ Activity.Current?.SetTag(TelemetryTagNames.InstallHasGlobalJson, globalJson?.GlobalJsonPath is not null);
+ Activity.Current?.SetTag(TelemetryTagNames.InstallExistingInstallType, currentInstallRoot?.InstallType.ToString() ?? "none");
+ Activity.Current?.SetTag(TelemetryTagNames.InstallPathType, InstallPathClassifier.ClassifyInstallPath(pathResolution.ResolvedInstallPath, pathResolution.PathSource));
+ Activity.Current?.SetTag(TelemetryTagNames.InstallPathSource, pathResolution.PathSource.ToString().ToLowerInvariant());
+
+ // Resolved version tags
+ Activity.Current?.SetTag(TelemetryTagNames.InstallResolvedVersion, resolved.ResolvedVersion.ToString());
+
+ string requestSource = requestedVersionOrChannel is not null
+ ? "explicit"
+ : globalJson?.GlobalJsonPath is not null
+ ? "default-globaljson"
+ : "default-latest";
+ Activity.Current?.SetTag(TelemetryTagNames.DotnetRequestSource, requestSource);
+ Activity.Current?.SetTag(TelemetryTagNames.DotnetRequested, VersionSanitizer.Sanitize(resolvedChannel));
+ }
}
diff --git a/src/Installer/dotnetup/Commands/Shared/SpectreDisplayHelpers.cs b/src/Installer/dotnetup/Commands/Shared/SpectreDisplayHelpers.cs
new file mode 100644
index 000000000000..d59033f9e83f
--- /dev/null
+++ b/src/Installer/dotnetup/Commands/Shared/SpectreDisplayHelpers.cs
@@ -0,0 +1,276 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Globalization;
+using Spectre.Console;
+using Spectre.Console.Rendering;
+using SpectreAnsiConsole = Spectre.Console.AnsiConsole;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared;
+
+///
+/// Reusable Spectre.Console display helpers for interactive scrollable lists and confirmations.
+/// Extracted to keep UI rendering separate from decision logic.
+///
+///
+/// Result of a confirm prompt that supports Y/N/P (never ask again).
+///
+internal enum ConfirmResult
+{
+ Yes,
+ No,
+ NeverAskAgain,
+}
+
+internal static class SpectreDisplayHelpers
+{
+ ///
+ /// Renders a list of items with only shown initially.
+ /// When running interactively, the user can scroll with arrow keys to see more.
+ /// Falls back to a static truncated list when input is redirected.
+ ///
+ internal static void RenderScrollableList(List items, int visibleCount)
+ {
+ if (items.Count == 0)
+ {
+ return;
+ }
+
+ string dim = DotnetupTheme.Current.Dim;
+ string accent = DotnetupTheme.Current.Accent;
+
+ if (items.Count <= visibleCount || Console.IsInputRedirected)
+ {
+ // All items fit or non-interactive — just print them all
+ foreach (var item in items)
+ {
+ SpectreAnsiConsole.MarkupLine(string.Format(CultureInfo.InvariantCulture, " [{0}]• [{1}]{2}[/][/]", dim, accent, item.EscapeMarkup()));
+ }
+
+ return;
+ }
+
+ // Interactive scrollable list
+ RunInteractiveScrollLoop(items, visibleCount, confirmPrompt: null);
+ }
+
+ ///
+ /// Renders a scrollable list with an inline confirmation prompt.
+ /// The prompt is shown below the list and Enter accepts the default (yes).
+ ///
+ internal static ConfirmResult RenderScrollableListWithConfirm(List items, int visibleCount, string confirmPrompt, bool allowNeverAsk = false)
+ {
+ if (items.Count == 0)
+ {
+ return ConfirmResult.Yes;
+ }
+
+ string dim = DotnetupTheme.Current.Dim;
+ string accent = DotnetupTheme.Current.Accent;
+ string brand = DotnetupTheme.Current.Brand;
+
+ if (items.Count <= visibleCount || Console.IsInputRedirected)
+ {
+ foreach (var item in items)
+ {
+ SpectreAnsiConsole.MarkupLine(string.Format(CultureInfo.InvariantCulture, " [{0}]• [{1}]{2}[/][/]", dim, accent, item.EscapeMarkup()));
+ }
+
+ string promptSuffix = allowNeverAsk
+ ? string.Format(CultureInfo.InvariantCulture, "{0} [{1}]([bold underline]Y[/]/n/[bold]p[/] = never ask again)[/] ", confirmPrompt, brand)
+ : string.Format(CultureInfo.InvariantCulture, "{0} [{1}]([bold underline]Y[/]/n)[/] ", confirmPrompt, brand);
+ SpectreAnsiConsole.Markup(promptSuffix);
+ var result = ReadConfirm(defaultValue: ConfirmResult.Yes, allowNeverAsk: allowNeverAsk);
+ SpectreAnsiConsole.WriteLine();
+ return result;
+ }
+
+ return RunInteractiveScrollLoop(items, visibleCount, confirmPrompt, allowNeverAsk);
+ }
+
+ ///
+ /// Returns true (accept) or false (decline) when is set;
+ /// always returns true when is null (plain scroll).
+ /// Uses Spectre.Console's LiveDisplay for reliable rendering without manual ANSI cursor management.
+ ///
+ private static ConfirmResult RunInteractiveScrollLoop(List items, int visibleCount, string? confirmPrompt, bool allowNeverAsk = false)
+ {
+ int offset = 0;
+ int maxOffset = items.Count - visibleCount;
+ bool done = false;
+ ConfirmResult result = ConfirmResult.Yes;
+
+ SpectreAnsiConsole.Live(BuildScrollRenderable(items, offset, visibleCount, confirmPrompt, allowNeverAsk))
+ .AutoClear(true)
+ .Start(ctx =>
+ {
+ ctx.Refresh();
+
+ while (!done)
+ {
+ if (!Console.KeyAvailable)
+ {
+ Thread.Sleep(50);
+ continue;
+ }
+
+ var key = Console.ReadKey(intercept: true);
+ switch (key.Key)
+ {
+ case ConsoleKey.UpArrow:
+ if (offset > 0)
+ {
+ offset--;
+ ctx.UpdateTarget(BuildScrollRenderable(items, offset, visibleCount, confirmPrompt, allowNeverAsk));
+ }
+
+ break;
+ case ConsoleKey.DownArrow:
+ if (offset < maxOffset)
+ {
+ offset++;
+ ctx.UpdateTarget(BuildScrollRenderable(items, offset, visibleCount, confirmPrompt, allowNeverAsk));
+ }
+
+ break;
+ case ConsoleKey.Enter:
+ result = ConfirmResult.Yes;
+ done = true;
+ break;
+ case ConsoleKey.N:
+ if (confirmPrompt is not null)
+ {
+ result = ConfirmResult.No;
+ done = true;
+ }
+
+ break;
+ case ConsoleKey.P:
+ if (confirmPrompt is not null && allowNeverAsk)
+ {
+ result = ConfirmResult.NeverAskAgain;
+ done = true;
+ }
+
+ break;
+ }
+ }
+ });
+
+ // Render final collapsed view after LiveDisplay clears its region
+ RenderFinalScrollView(items, confirmPrompt, result);
+
+ return result;
+ }
+
+ ///
+ /// Builds a Spectre renderable for the current scroll window.
+ ///
+ private static Rows BuildScrollRenderable(List items, int offset, int visibleCount, string? confirmPrompt, bool allowNeverAsk)
+ {
+ string dim = DotnetupTheme.Current.Dim;
+ string accent = DotnetupTheme.Current.Accent;
+ var rows = new List();
+
+ if (offset > 0)
+ {
+ rows.Add(new Markup(string.Format(CultureInfo.InvariantCulture, " [{0}]{1} {2} more above[/]", dim, Constants.Symbols.UpTriangle, offset)));
+ }
+ else
+ {
+ rows.Add(Text.Empty);
+ }
+
+ for (int i = offset; i < offset + visibleCount && i < items.Count; i++)
+ {
+ rows.Add(new Markup(string.Format(CultureInfo.InvariantCulture, " [{0}]• [{1}]{2}[/][/]", dim, accent, items[i].EscapeMarkup())));
+ }
+
+ int remaining = items.Count - offset - visibleCount;
+ if (remaining > 0)
+ {
+ rows.Add(new Markup(string.Format(CultureInfo.InvariantCulture, " [{0}]{1} {2} more below (use {3}{4} arrows)[/]", dim, Constants.Symbols.DownTriangle, remaining, Constants.Symbols.UpArrow, Constants.Symbols.DownArrow)));
+ }
+ else
+ {
+ rows.Add(Text.Empty);
+ }
+
+ if (confirmPrompt is not null)
+ {
+ string promptHint = allowNeverAsk
+ ? string.Format(CultureInfo.InvariantCulture, "{0} [{1}]([bold underline]Y[/]/n/[bold]p[/] = never ask again)[/]", confirmPrompt, DotnetupTheme.Current.Brand)
+ : string.Format(CultureInfo.InvariantCulture, "{0} [{1}]([bold underline]Y[/]/n)[/]", confirmPrompt, DotnetupTheme.Current.Brand);
+ rows.Add(new Markup(promptHint));
+ }
+ else if (remaining <= 0)
+ {
+ rows.Add(new Markup(string.Format(CultureInfo.InvariantCulture, " [{0}](Press Enter to continue)[/]", dim)));
+ }
+
+ return new Rows(rows);
+ }
+
+ ///
+ /// Renders the final collapsed view after the user makes a choice.
+ ///
+ private static void RenderFinalScrollView(List items, string? confirmPrompt, ConfirmResult result)
+ {
+ string dim = DotnetupTheme.Current.Dim;
+ string accent = DotnetupTheme.Current.Accent;
+ string brand = DotnetupTheme.Current.Brand;
+
+ if (result == ConfirmResult.Yes)
+ {
+ foreach (var item in items)
+ {
+ SpectreAnsiConsole.MarkupLine(string.Format(CultureInfo.InvariantCulture, " [{0}]• [{1}]{2}[/][/]", dim, accent, item.EscapeMarkup()));
+ }
+ }
+
+ if (confirmPrompt is not null)
+ {
+ string answer = result switch
+ {
+ ConfirmResult.Yes => "Yes",
+ ConfirmResult.NeverAskAgain => "No (won't ask again)",
+ _ => "No",
+ };
+ SpectreAnsiConsole.MarkupLine(string.Format(CultureInfo.InvariantCulture, "{0} [{1}]{2}[/]", confirmPrompt, brand, answer));
+ SpectreAnsiConsole.WriteLine();
+ }
+ }
+
+ ///
+ /// Reads a single y/n keypress. Returns on Enter.
+ ///
+ private static ConfirmResult ReadConfirm(ConfirmResult defaultValue, bool allowNeverAsk)
+ {
+ string brand = DotnetupTheme.Current.Brand;
+ while (true)
+ {
+ var key = Console.ReadKey(intercept: true);
+ switch (key.Key)
+ {
+ case ConsoleKey.Enter:
+ string defaultLabel = defaultValue == ConfirmResult.Yes ? "Yes" : "No";
+ SpectreAnsiConsole.MarkupLine(string.Format(CultureInfo.InvariantCulture, "[{0}]{1}[/]", brand, defaultLabel));
+ return defaultValue;
+ case ConsoleKey.Y:
+ SpectreAnsiConsole.MarkupLine(string.Format(CultureInfo.InvariantCulture, "[{0}]Yes[/]", brand));
+ return ConfirmResult.Yes;
+ case ConsoleKey.N:
+ SpectreAnsiConsole.MarkupLine(string.Format(CultureInfo.InvariantCulture, "[{0}]No[/]", brand));
+ return ConfirmResult.No;
+ case ConsoleKey.P:
+ if (allowNeverAsk)
+ {
+ SpectreAnsiConsole.MarkupLine(string.Format(CultureInfo.InvariantCulture, "[{0}]No (won't ask again)[/]", brand));
+ return ConfirmResult.NeverAskAgain;
+ }
+
+ break;
+ }
+ }
+ }
+}
diff --git a/src/Installer/dotnetup/Commands/Shared/UninstallWorkflow.cs b/src/Installer/dotnetup/Commands/Shared/UninstallWorkflow.cs
index a45c0e63cb5d..495450c98fd2 100644
--- a/src/Installer/dotnetup/Commands/Shared/UninstallWorkflow.cs
+++ b/src/Installer/dotnetup/Commands/Shared/UninstallWorkflow.cs
@@ -28,11 +28,8 @@ public static int Execute(string? manifestPath, string? installPath, string vers
var manifest = new DotnetupSharedManifest(manifestPath);
var manifestData = manifest.ReadManifest();
- // Resolve install path
- var dotnetInstaller = new DotnetInstallManager();
- string resolvedInstallPath = installPath
- ?? dotnetInstaller.GetConfiguredInstallType()?.Path
- ?? dotnetInstaller.GetDefaultDotnetInstallPath();
+ var dotnetEnvironment = new DotnetEnvironmentManager();
+ string resolvedInstallPath = ResolveInstallPath(installPath, dotnetEnvironment);
var root = manifestData.DotnetRoots.FirstOrDefault(r =>
DotnetupUtilities.PathsEqual(Path.GetFullPath(r.Path), Path.GetFullPath(resolvedInstallPath)));
@@ -59,36 +56,7 @@ public static int Execute(string? manifestPath, string? installPath, string vers
if (matchingSpecs.Count == 0)
{
- // Check if there are matches with other sources
- var otherSourceSpecs = allMatchingSpecs.Except(matchingSpecs).ToList();
- if (otherSourceSpecs.Count > 0)
- {
- if (sourceFilter != InstallSource.All)
- {
- AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture,
- $"[yellow]No [bold]{sourceFilter}[/] {componentFilter.GetDisplayName()} install spec found for '{versionOrChannel}', but matching specs exist with other sources:[/]");
- }
- else
- {
- AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture,
- $"[yellow]No {componentFilter.GetDisplayName()} install spec found for '{versionOrChannel}', but matching specs exist with other sources:[/]");
- }
-
- foreach (var spec in otherSourceSpecs)
- {
- AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $" [dim]{spec.Component.GetDisplayName()} {spec.VersionOrChannel} (source: {spec.InstallSource})[/]");
- }
-
- if (sourceFilter != InstallSource.All)
- {
- AnsiConsole.MarkupLine("[dim]Use --source all to target these specs.[/]");
- }
- }
- else
- {
- AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[yellow]No {componentFilter.GetDisplayName()} install spec found for '{versionOrChannel}' at {resolvedInstallPath}.[/]");
- }
-
+ ReportNoMatchingSpecs(allMatchingSpecs, matchingSpecs, sourceFilter, componentFilter, versionOrChannel, resolvedInstallPath);
return 1;
}
@@ -100,17 +68,76 @@ public static int Execute(string? manifestPath, string? installPath, string vers
.Select(i => (i.Component, i.Version))
.ToHashSet();
+ RemoveSpecsAndRunGc(manifest, installRoot, matchingSpecs, manifestPath);
+
+ // Check if the targeted installations are still present (referenced by another spec)
+ CheckAndReportStillPresent(manifestPath, installRoot, targetedInstallations);
+
+ AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[{DotnetupTheme.Current.Brand}]Done.[/]");
+ return 0;
+ }
+
+ private static void ReportNoMatchingSpecs(
+ List allMatchingSpecs,
+ List matchingSpecs,
+ InstallSource sourceFilter,
+ InstallComponent componentFilter,
+ string versionOrChannel,
+ string resolvedInstallPath)
+ {
+ // Check if there are matches with other sources
+ var otherSourceSpecs = allMatchingSpecs.Except(matchingSpecs).ToList();
+ if (otherSourceSpecs.Count > 0)
+ {
+ if (sourceFilter != InstallSource.All)
+ {
+ AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture,
+ $"[{DotnetupTheme.Current.Warning}]No [bold]{sourceFilter}[/] {componentFilter.GetDisplayName()} install spec found for '{versionOrChannel}', but matching specs exist with other sources:[/]");
+ }
+ else
+ {
+ AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture,
+ $"[{DotnetupTheme.Current.Warning}]No {componentFilter.GetDisplayName()} install spec found for '{versionOrChannel}', but matching specs exist with other sources:[/]");
+ }
+
+ foreach (var spec in otherSourceSpecs)
+ {
+ AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $" [{DotnetupTheme.Current.Dim}]{spec.Component.GetDisplayName()} {spec.VersionOrChannel} (source: {spec.InstallSource})[/]");
+ }
+
+ if (sourceFilter != InstallSource.All)
+ {
+ AnsiConsole.MarkupLine(DotnetupTheme.Dim("Use --source all to target these specs."));
+ }
+ }
+ else
+ {
+ AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[{DotnetupTheme.Current.Warning}]No {componentFilter.GetDisplayName()} install spec found for '{versionOrChannel}' at {resolvedInstallPath}.[/]");
+ }
+ }
+
+ private static void RemoveSpecsAndRunGc(
+ DotnetupSharedManifest manifest,
+ DotnetInstallRoot installRoot,
+ List matchingSpecs,
+ string? manifestPath)
+ {
// Remove the install spec(s)
foreach (var spec in matchingSpecs)
{
manifest.RemoveInstallSpec(installRoot, spec);
- AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"Removed install spec: {spec.Component.GetDisplayName()} [blue]{spec.VersionOrChannel}[/] (source: {spec.InstallSource})");
+ AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"Dereferenced {spec.Component.GetDisplayName()} [{DotnetupTheme.Current.Accent}]{spec.VersionOrChannel}[/] [{DotnetupTheme.Current.Dim}](source: {spec.InstallSource})[/]");
}
// Run garbage collection
GarbageCollectionRunner.RunAndDisplay(manifestPath, installRoot, showEmptyMessage: true);
+ }
- // Check if the targeted installations are still present (referenced by another spec)
+ private static void CheckAndReportStillPresent(
+ string? manifestPath,
+ DotnetInstallRoot installRoot,
+ HashSet<(InstallComponent Component, string Version)> targetedInstallations)
+ {
if (targetedInstallations.Count > 0)
{
var updatedManifest = new DotnetupSharedManifest(manifestPath);
@@ -120,11 +147,28 @@ public static int Execute(string? manifestPath, string? installPath, string vers
if (stillPresent.Count > 0)
{
- AnsiConsole.MarkupLine("[dim]Some installations were not removed because they are still referenced by other install specs.[/]");
+ AnsiConsole.MarkupLine(DotnetupTheme.Dim("Some installations were not removed because they are still referenced by other install specs."));
}
}
+ }
- AnsiConsole.MarkupLine("[green]Done.[/]");
- return 0;
+ ///
+ /// Resolves the install path for uninstall using the same logic as the install command:
+ /// only use the configured path if it's a user install, otherwise fall back to default.
+ ///
+ internal static string ResolveInstallPath(string? explicitInstallPath, IDotnetEnvironmentManager dotnetEnvironment)
+ {
+ if (explicitInstallPath is not null)
+ {
+ return explicitInstallPath;
+ }
+
+ var configuredInstall = dotnetEnvironment.GetCurrentPathConfiguration();
+ if (configuredInstall is { InstallType: InstallType.User })
+ {
+ return configuredInstall.Path;
+ }
+
+ return dotnetEnvironment.GetDefaultDotnetInstallPath();
}
}
diff --git a/src/Installer/dotnetup/Commands/Shared/UpdateWorkflow.cs b/src/Installer/dotnetup/Commands/Shared/UpdateWorkflow.cs
index 06bc24b86954..df819341d964 100644
--- a/src/Installer/dotnetup/Commands/Shared/UpdateWorkflow.cs
+++ b/src/Installer/dotnetup/Commands/Shared/UpdateWorkflow.cs
@@ -1,7 +1,6 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-using System.Globalization;
using Microsoft.Deployment.DotNet.Releases;
using Microsoft.Dotnet.Installation.Internal;
using Spectre.Console;
@@ -28,8 +27,9 @@ public UpdateWorkflow(ChannelVersionResolver channelVersionResolver)
/// Which component(s) to update. Null means update all.
/// Whether to suppress progress display.
/// Whether to update global.json files after updating global.json-sourced SDK specs.
+ /// The verbosity level for diagnostic messages during installation.
/// Exit code (0 for success).
- public int Execute(string? manifestPath, string? installPath, InstallComponent? componentFilter, bool noProgress, bool updateGlobalJson = false)
+ public int Execute(string? manifestPath, string? installPath, InstallComponent? componentFilter, bool noProgress, bool updateGlobalJson = false, Verbosity verbosity = Verbosity.Normal)
{
using var mutex = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates);
@@ -47,7 +47,7 @@ public int Execute(string? manifestPath, string? installPath, InstallComponent?
var rootsList = rootsToUpdate.ToList();
if (rootsList.Count == 0)
{
- AnsiConsole.MarkupLine("[yellow]No tracked dotnet installations found to update.[/]");
+ AnsiConsole.MarkupLine(DotnetupTheme.Warning("No tracked dotnet installations found to update."));
return 0;
}
@@ -66,7 +66,7 @@ public int Execute(string? manifestPath, string? installPath, InstallComponent?
continue;
}
- var (updated, failed) = UpdateSpec(spec, root, installRoot, manifestPath, noProgress, updateGlobalJson);
+ var (updated, failed) = UpdateSpec(spec, root, installRoot, manifestPath, noProgress, updateGlobalJson, verbosity);
if (updated) { anyUpdated = true; rootUpdated = true; }
if (failed) { anyFailed = true; }
}
@@ -97,7 +97,8 @@ public int Execute(string? manifestPath, string? installPath, InstallComponent?
DotnetInstallRoot installRoot,
string? manifestPath,
bool noProgress,
- bool updateGlobalJson)
+ bool updateGlobalJson,
+ Verbosity verbosity)
{
var channel = new UpdateChannel(spec.VersionOrChannel);
@@ -111,45 +112,39 @@ public int Execute(string? manifestPath, string? installPath, InstallComponent?
string displayName = spec.Component.GetDisplayName();
if (latestVersion is null)
{
- AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[yellow]Could not resolve latest version for {displayName} '{spec.VersionOrChannel}'.[/]");
+ AnsiConsole.MarkupLine(DotnetupTheme.Warning($"Could not resolve latest version for {displayName} '{spec.VersionOrChannel}'."));
return (false, false);
}
- // Check if this version is already installed
+ // Check if this version is already installed (in the manifest)
var alreadyInstalled = root.Installations.Any(i =>
i.Component == spec.Component && i.Version == latestVersion.ToString());
- bool updated = false;
+ // If the manifest says it's installed, validate on disk. If missing, remove the stale record.
if (alreadyInstalled)
{
- AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[yellow]{displayName} {spec.VersionOrChannel} is already up to date ({latestVersion}).[/]");
- }
- else
- {
- AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"Updating {displayName} {spec.VersionOrChannel} to [blue]{latestVersion}[/]...");
-
- var installRequest = new DotnetInstallRequest(
- installRoot,
- channel,
- spec.Component,
- new InstallRequestOptions { ManifestPath = manifestPath, SkipInstallSpecRecording = true })
- {
- ResolvedVersion = latestVersion
- };
-
- try
+ var install = new DotnetInstall(installRoot, latestVersion, spec.Component);
+ ArchiveInstallationValidator validator = new();
+ if (!validator.Validate(install))
{
- var result = InstallerOrchestratorSingleton.Instance.Install(installRequest, noProgress);
- AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[green]Updated {displayName} {spec.VersionOrChannel} to {latestVersion}.[/]");
- updated = true;
- }
- catch (DotnetInstallException)
- {
- AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[red]Failed to update {displayName} {spec.VersionOrChannel} to {latestVersion}.[/]");
- return (false, true);
+ var staleInstallation = root.Installations.First(i =>
+ i.Component == spec.Component && i.Version == latestVersion.ToString());
+ var manifest = new DotnetupSharedManifest(manifestPath);
+ manifest.RemoveInstallation(installRoot, new Installation
+ {
+ Component = staleInstallation.Component,
+ Version = staleInstallation.Version
+ });
+ alreadyInstalled = false;
}
}
+ var (updated, failed) = TryInstallVersion(channel, spec, installRoot, latestVersion, alreadyInstalled, noProgress, manifestPath, verbosity);
+ if (failed)
+ {
+ return (false, true);
+ }
+
// Update global.json if requested and this spec came from a global.json,
// but only if the latest version is newer than what's already specified.
if (updateGlobalJson
@@ -170,7 +165,56 @@ private static void UpdateGlobalJsonFile(string globalJsonPath, ReleaseVersion l
{
if (GlobalJsonModifier.UpdateGlobalJsonIfNewer(globalJsonPath, latestVersion))
{
- AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $" Updated [dim]{globalJsonPath}[/] to {latestVersion}.");
+ AnsiConsole.MarkupLine($" Updated {DotnetupTheme.Dim(globalJsonPath.EscapeMarkup())} to {latestVersion}.");
}
}
+
+ ///
+ /// Installs the given version or logs a message if it is already installed.
+ ///
+ /// A tuple of (wasUpdated, hadFailure).
+ private static (bool Updated, bool Failed) TryInstallVersion(
+ UpdateChannel channel,
+ InstallSpec spec,
+ DotnetInstallRoot installRoot,
+ ReleaseVersion latestVersion,
+ bool alreadyInstalled,
+ bool noProgress,
+ string? manifestPath,
+ Verbosity verbosity)
+ {
+ string displayName = spec.Component.GetDisplayName();
+ bool updated = false;
+
+ if (alreadyInstalled)
+ {
+ AnsiConsole.MarkupLine(DotnetupTheme.Warning($"{displayName} {spec.VersionOrChannel} is already up to date ({latestVersion})."));
+ }
+ else
+ {
+ AnsiConsole.MarkupLine($"Updating {displayName} {spec.VersionOrChannel} to {DotnetupTheme.Accent(latestVersion.ToString())}...");
+
+ var installRequest = new DotnetInstallRequest(
+ installRoot,
+ channel,
+ spec.Component,
+ new InstallRequestOptions { ManifestPath = manifestPath, SkipInstallSpecRecording = true, Verbosity = verbosity });
+
+ var resolvedRequest = new ResolvedInstallRequest(installRequest, latestVersion);
+
+ try
+ {
+ InstallerOrchestratorSingleton.Instance.Install(resolvedRequest, noProgress);
+ AnsiConsole.MarkupLine(DotnetupTheme.Success($"Updated {displayName} {spec.VersionOrChannel} to {latestVersion}."));
+ updated = true;
+ }
+ catch (DotnetInstallException)
+ {
+ AnsiConsole.MarkupLine(DotnetupTheme.Error($"Failed to update {displayName} {spec.VersionOrChannel} to {latestVersion}."));
+ return (false, true);
+ }
+ }
+
+ return (updated, false);
+ }
}
diff --git a/src/Installer/dotnetup/Commands/Walkthrough/DotnetBotBanner.cs b/src/Installer/dotnetup/Commands/Walkthrough/DotnetBotBanner.cs
new file mode 100644
index 000000000000..b283e807fd24
--- /dev/null
+++ b/src/Installer/dotnetup/Commands/Walkthrough/DotnetBotBanner.cs
@@ -0,0 +1,40 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Spectre.Console;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Walkthrough;
+
+///
+/// Builds the banner panel for the dotnetup first-run screen.
+///
+internal static class DotnetBotBanner
+{
+ ///
+ /// Builds the banner panel.
+ ///
+ internal static Panel BuildPanel()
+ {
+ // Trim the commit hash from the informational version (e.g. "0.1.1-preview+abc123" -> "0.1.1-preview")
+ string version = Parser.Version;
+ int plusIndex = version.IndexOf('+');
+ if (plusIndex >= 0)
+ {
+ version = version[..plusIndex];
+ }
+
+ string brand = DotnetupTheme.Current.Brand;
+ string description = ".NET installation manager for developers.";
+
+ var content = new Rows(
+ new Markup($"[{brand} bold]dotnetup[/] v{version.EscapeMarkup()}"),
+ new Markup($"[dim]{description.EscapeMarkup()}[/]"));
+
+ return new Panel(content)
+ {
+ Border = BoxBorder.Rounded,
+ BorderStyle = Style.Parse(brand),
+ Padding = new Padding(1, 0),
+ };
+ }
+}
diff --git a/src/Installer/dotnetup/Commands/Walkthrough/InteractiveOptionSelector.cs b/src/Installer/dotnetup/Commands/Walkthrough/InteractiveOptionSelector.cs
new file mode 100644
index 000000000000..6a36ea02bd84
--- /dev/null
+++ b/src/Installer/dotnetup/Commands/Walkthrough/InteractiveOptionSelector.cs
@@ -0,0 +1,172 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Globalization;
+using Spectre.Console;
+using Spectre.Console.Rendering;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Walkthrough;
+
+///
+/// Represents an option in the interactive selector with a title, description, and hover tooltip.
+///
+internal record SelectableOption(string Key, string Title, string Description, string Tooltip);
+
+///
+/// A custom interactive option selector that uses Spectre.Console's LiveDisplay
+/// for flicker-free rendering. Shows all options with a slowly flashing arrow
+/// indicator on the selected item. Supports up/down arrow navigation.
+///
+/// Why not use Spectre's SelectionPrompt?
+/// SelectionPrompt supports only single-line items and a static ">" indicator.
+/// This selector provides multi-line items (title + description), per-option
+/// tooltips for the selected item, theme-aware styling, and the flashing arrow.
+///
+internal static class InteractiveOptionSelector
+{
+ // Arrow flash interval in milliseconds.
+ private const int FlashIntervalMs = 600;
+
+ ///
+ /// Displays the interactive selector and returns the index of the chosen option.
+ ///
+ public static int Show(string title, IReadOnlyList options, int defaultIndex = 0)
+ {
+ if (options.Count == 0)
+ {
+ throw new ArgumentException("At least one option is required.", nameof(options));
+ }
+
+ if (Console.IsInputRedirected)
+ {
+ // Fallback for non-interactive/redirected input: render once and return default
+ AnsiConsole.Write(BuildRenderable(title, options, defaultIndex, showArrow: true));
+ return defaultIndex;
+ }
+
+ return RunInteractive(title, options, defaultIndex);
+ }
+
+ private static int RunInteractive(string title, IReadOnlyList options, int defaultIndex)
+ {
+ int selectedIndex = defaultIndex;
+ bool showArrow = true;
+ long lastToggle = Environment.TickCount64;
+ bool done = false;
+
+ AnsiConsole.Live(BuildRenderable(title, options, selectedIndex, showArrow))
+ .AutoClear(true)
+ .Start(ctx =>
+ {
+ while (!done)
+ {
+ if (Console.KeyAvailable)
+ {
+ var keyInfo = Console.ReadKey(intercept: true);
+ switch (keyInfo.Key)
+ {
+ case ConsoleKey.UpArrow:
+ selectedIndex = (selectedIndex - 1 + options.Count) % options.Count;
+ showArrow = true;
+ lastToggle = Environment.TickCount64;
+ break;
+
+ case ConsoleKey.DownArrow:
+ selectedIndex = (selectedIndex + 1) % options.Count;
+ showArrow = true;
+ lastToggle = Environment.TickCount64;
+ break;
+
+ case ConsoleKey.Enter:
+ done = true;
+ return;
+ }
+
+ ctx.UpdateTarget(BuildRenderable(title, options, selectedIndex, showArrow));
+ continue;
+ }
+
+ long now = Environment.TickCount64;
+ if (now - lastToggle >= FlashIntervalMs)
+ {
+ lastToggle = now;
+ showArrow = !showArrow;
+ ctx.UpdateTarget(BuildRenderable(title, options, selectedIndex, showArrow));
+ }
+
+ Thread.Sleep(50);
+ }
+ });
+
+ // Render final compact result after LiveDisplay clears its region
+ RenderFinal(title, options, selectedIndex);
+
+ return selectedIndex;
+ }
+
+ private static Rows BuildRenderable(string title, IReadOnlyList options,
+ int selectedIndex, bool showArrow)
+ {
+ var theme = DotnetupTheme.Current;
+
+ var rows = new List
+ {
+ new Markup($"[bold {theme.Brand}]{title.EscapeMarkup()}[/]"),
+ Text.Empty,
+ };
+
+ for (int i = 0; i < options.Count; i++)
+ {
+ bool isSelected = i == selectedIndex;
+
+ if (isSelected)
+ {
+ string prefix = showArrow ? "> " : " ";
+ rows.Add(new Markup(string.Format(
+ CultureInfo.InvariantCulture,
+ "[{0}]{1}[bold]{2}[/][/]",
+ theme.Brand,
+ prefix,
+ options[i].Title.EscapeMarkup())));
+ rows.Add(new Markup(string.Format(
+ CultureInfo.InvariantCulture,
+ " {0}",
+ options[i].Description.EscapeMarkup())));
+ }
+ else
+ {
+ rows.Add(new Markup(string.Format(
+ CultureInfo.InvariantCulture,
+ "[{0}] {1}[/]",
+ theme.Dim,
+ options[i].Title.EscapeMarkup())));
+ rows.Add(new Markup(string.Format(
+ CultureInfo.InvariantCulture,
+ "[{0}] {1}[/]",
+ theme.Dim,
+ options[i].Description.EscapeMarkup())));
+ }
+
+ rows.Add(Text.Empty);
+ }
+
+ // Tooltip for selected option
+ rows.Add(new Markup(string.Format(
+ CultureInfo.InvariantCulture,
+ " {0}",
+ options[selectedIndex].Tooltip.EscapeMarkup())));
+
+ return new Rows(rows);
+ }
+
+ private static void RenderFinal(string title, IReadOnlyList options, int selectedIndex)
+ {
+ AnsiConsole.MarkupLine($"[bold {DotnetupTheme.Current.Brand}]{title.EscapeMarkup()}[/]");
+ AnsiConsole.MarkupLine(string.Format(
+ CultureInfo.InvariantCulture,
+ "[{0}]{1}[/]",
+ DotnetupTheme.Current.Dim,
+ options[selectedIndex].Title.EscapeMarkup()));
+ }
+}
+
diff --git a/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughCommand.cs b/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughCommand.cs
new file mode 100644
index 000000000000..e07451a30cb4
--- /dev/null
+++ b/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughCommand.cs
@@ -0,0 +1,23 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Walkthrough;
+
+///
+/// Runs the interactive walkthrough that installs the .NET SDK with defaults
+/// and records the user's path replacement preference to dotnetup.config.json.
+///
+internal class WalkthroughCommand(ParseResult result) : InstallCommand(result)
+{
+ protected override string GetCommandName() => "walkthrough";
+
+ protected override int ExecuteCore()
+ {
+ var workflows = new WalkthroughWorkflows(DotnetEnvironment, ChannelVersionResolver);
+ workflows.FullIntroductionWalkthrough(this);
+ return 0;
+ }
+}
diff --git a/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughCommandParser.cs b/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughCommandParser.cs
new file mode 100644
index 000000000000..0a3b9c729d6c
--- /dev/null
+++ b/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughCommandParser.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.CommandLine;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Walkthrough;
+
+internal static class WalkthroughCommandParser
+{
+ private static readonly Command s_command = ConstructCommand();
+
+ public static Command GetCommand() => s_command;
+
+ private static Command ConstructCommand()
+ {
+ Command command = new("walkthrough", Strings.WalkthroughCommandDescription);
+
+ command.Options.Add(CommonOptions.InstallPathOption);
+ command.Options.Add(CommonOptions.ManifestPathOption);
+ command.Options.Add(CommonOptions.NoProgressOption);
+ command.Options.Add(CommonOptions.VerbosityOption);
+ command.Options.Add(CommonOptions.RequireMuxerUpdateOption);
+
+ command.SetAction(parseResult => new WalkthroughCommand(parseResult).Execute());
+
+ return command;
+ }
+}
diff --git a/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs b/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs
new file mode 100644
index 000000000000..c404cb7aaefc
--- /dev/null
+++ b/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs
@@ -0,0 +1,613 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Globalization;
+using Microsoft.Dotnet.Installation.Internal;
+using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared;
+using Spectre.Console;
+using SpectreAnsiConsole = Spectre.Console.AnsiConsole;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Walkthrough;
+
+///
+/// Orchestrates the interactive walkthrough that configures the user's environment
+/// and records the path replacement preference to dotnetup.config.json.
+/// Has two modes:
+///
+/// — full first-run experience with channel prompt
+/// — minimal setup, wraps an install action
+///
+///
+internal class WalkthroughWorkflows
+{
+ private readonly IDotnetEnvironmentManager _dotnetEnvironment;
+ private readonly ChannelVersionResolver _channelVersionResolver;
+
+ /// Sentinel channel value indicating the user wants to skip the initial install.
+ internal const string NoneChannel = "none";
+
+ private sealed record ChannelExample(string Channel, string Description, string? ResolvedVersion);
+
+ public WalkthroughWorkflows(IDotnetEnvironmentManager dotnetEnvironment, ChannelVersionResolver channelVersionResolver)
+ {
+ _dotnetEnvironment = dotnetEnvironment;
+ _channelVersionResolver = channelVersionResolver;
+ }
+
+ ///
+ /// Returns true when the given implies we should
+ /// replace the default dotnet installation (i.e. update PATH / DOTNET_ROOT).
+ ///
+ public static bool ShouldReplaceSystemConfiguration(PathPreference preference) =>
+ preference == PathPreference.FullPathReplacement;
+
+ ///
+ /// Returns true when the user chose to convert existing system-level .NET installs
+ /// into dotnetup-managed installs. This applies to any mode that shadows the system PATH.
+ /// Also returns false if the user previously opted out via .
+ ///
+ public static bool ShouldPromptToConvertSystemInstalls(PathPreference preference, bool ignoreConfig = false)
+ {
+ if (preference == PathPreference.DotnetupDotnet)
+ {
+ return false;
+ }
+
+ if (!ignoreConfig)
+ {
+ var existingConfig = DotnetupConfig.Read();
+ if (existingConfig?.DisableInstallConversion == true)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Returns true when the user chose full PATH replacement (Windows-only),
+ /// meaning the system PATH entry for dotnet is replaced with the dotnetup path.
+ ///
+ public static bool ShouldReplaceSystemPath(PathPreference preference) =>
+ preference == PathPreference.FullPathReplacement;
+
+ // ── Walkthrough Orchestrators ──
+
+ ///
+ /// Full first-run walkthrough: shows banner, prompts for channel, generates
+ /// install request, then delegates to
+ /// for environment setup and installation.
+ ///
+ public void FullIntroductionWalkthrough(InstallCommand command)
+ {
+ ShowBanner();
+
+ // Step 0: Explain channels and let the user pick one
+ string selectedChannel = PromptChannel();
+
+ if (selectedChannel == NoneChannel)
+ {
+ // User chose to skip installation — just configure the environment.
+ BaseConfigurationWalkthrough(
+ [],
+ () => { },
+ command.NoProgress);
+ return;
+ }
+
+ // Generate the install request via the workflow (handles path resolution, global.json, validation)
+ var workflow = new InstallWorkflow(command);
+ var requests = workflow.GenerateInstallRequests(
+ [new MinimalInstallSpec(InstallComponent.SDK, selectedChannel)]);
+
+ BaseConfigurationWalkthrough(
+ requests,
+ () => InstallExecutor.ExecuteInstalls(requests, command.NoProgress),
+ command.NoProgress);
+ }
+
+ ///
+ /// Minimal walkthrough: prompts for path preference and admin migration (if needed),
+ /// then runs the provided action, saves config, applies system configuration, and
+ /// batch-installs any migrated system installs.
+ /// Called by and by
+ /// when no explicit install path is provided.
+ ///
+ /// The resolved install requests (used for predownload and install root context).
+ /// The action to execute after environment configuration (typically the install).
+ /// Whether to suppress progress display.
+ /// Whether to prompt the user. When false, uses existing config or defaults — no prompts are shown.
+ /// When true, defers the admin migration prompt until the end of the walkthrough.
+ /// When true, prompts the user even if a preference was previously saved.
+ public void BaseConfigurationWalkthrough(
+ List requests,
+ Action primaryActionAfterConfigured,
+ bool noProgress,
+ bool interactive = true,
+ bool deferAdminMigrationUntilEnd = false,
+ bool askEvenIfConfigured = true)
+ {
+ // Determine the install root for environment configuration and migration.
+ // Use the first request's root if available, otherwise fall back to the default path.
+ DotnetInstallRoot installRoot = requests.Count > 0
+ ? requests[0].Request.InstallRoot
+ : new DotnetInstallRoot(
+ _dotnetEnvironment.GetDefaultDotnetInstallPath(),
+ InstallerUtilities.GetDefaultInstallArchitecture());
+
+ // Fire off background predownload while the user answers prompts
+ if (requests.Count > 0)
+ {
+ _ = InstallerOrchestratorSingleton.PredownloadToCacheAsync(requests[0]);
+ }
+
+ // User chooses how to access .NET
+ PathPreference? previousPreference = DotnetupConfig.ReadPathPreference();
+ var pathPreference = GetPathPreference(interactive, askEvenIfConfigured);
+ string? manifestPath = requests.Count > 0 ? requests[0].Request.Options.ManifestPath : null;
+
+ // (Can Defer) Step 2: Prompt about admin installs before setting up the environment
+ // In non-interactive mode, skip the migration prompt entirely.
+ List toMigrate = deferAdminMigrationUntilEnd
+ ? []
+ : PromptInstallsToMigrateIfDesired(_dotnetEnvironment, pathPreference, installRoot, manifestPath, askEvenIfConfigured);
+
+ SpectreAnsiConsole.MarkupLine("Setting up your environment.");
+ if (requests.Count > 0)
+ {
+ DisplayInstallLocation(requests[0]);
+ }
+
+ // Run the primary action (typically installing the base SDK from global.json/latest)
+ primaryActionAfterConfigured();
+
+ // Save config and apply configuration(s) - NOTE: Terminal Profile not yet implemented
+ SaveConfigAndDisplayResult(pathPreference, previousPreference);
+
+ // NOTE: Global.json modification is intentionally NOT done here.
+ // The walkthrough does not own global.json updates — that responsibility
+ // belongs to InstallWorkflow, gated on the --update-global-json flag
+ // which only the SDK install command exposes.
+
+ if (ShouldReplaceSystemConfiguration(pathPreference))
+ {
+ _dotnetEnvironment.ApplyEnvironmentModifications(InstallType.User, installRoot.Path);
+ }
+
+ // Step 4: Prompt (or use old prompt) migrating admin installs now that the environment is configured.
+ toMigrate ??= PromptInstallsToMigrateIfDesired(_dotnetEnvironment, pathPreference, installRoot, manifestPath, askEvenIfConfigured);
+ if (toMigrate.Count > 0)
+ {
+ SpectreAnsiConsole.MarkupLine(DotnetupTheme.Dim(
+ "You may now use dotnetup. In the meantime, we'll install your remaining components."));
+
+ ExecuteMigrationBatch(toMigrate, installRoot, noProgress);
+ }
+ }
+
+ private static PathPreference GetPathPreference(bool interactive, bool askEvenIfConfigured)
+ {
+ // If the user already configured their preference (e.g. prior walkthrough), reuse it.
+ // In non-interactive mode, use the existing config or default to ShellProfile.
+ PathPreference? existingPreference = DotnetupConfig.ReadPathPreference();
+ if (existingPreference is not null && !askEvenIfConfigured)
+ {
+ return existingPreference.Value;
+ }
+ else if (!interactive)
+ {
+ return PathPreference.ShellProfile;
+ }
+
+ var preference = PromptPathPreference();
+ if (preference == PathPreference.FullPathReplacement && !OperatingSystem.IsWindows())
+ {
+ throw new DotnetInstallException(
+ DotnetInstallErrorCode.PlatformNotSupported,
+ Strings.PathReplacementModeUnixError);
+ }
+
+ return preference;
+ }
+
+ // ── Prompt Functions ──
+
+ ///
+ /// Explains how dotnetup channels work and lets the user pick a channel.
+ /// Builds example channels dynamically from the release manifest and shows
+ /// what each one currently resolves to.
+ ///
+ private string PromptChannel()
+ {
+ string brand = DotnetupTheme.Current.Brand;
+ string dim = DotnetupTheme.Current.Dim;
+
+ SpectreAnsiConsole.MarkupLine($"Welcome to [{brand} bold]dotnetup[/]!");
+ SpectreAnsiConsole.WriteLine();
+
+ SpectreAnsiConsole.MarkupLine(string.Format(
+ CultureInfo.InvariantCulture,
+ "dotnetup updates and groups installations using [{0} bold]dotnetup channels[/].",
+ brand));
+
+ var globalJsonInfo = GlobalJsonModifier.GetGlobalJsonInfo(Environment.CurrentDirectory);
+ if (globalJsonInfo.GlobalJsonPath is not null)
+ {
+ SpectreAnsiConsole.MarkupLine(string.Format(
+ CultureInfo.InvariantCulture,
+ "[{0}]Channels may be implied from your global.json at [{1}]{2}[/].[/]",
+ dim,
+ brand,
+ globalJsonInfo.GlobalJsonPath.EscapeMarkup()));
+ }
+
+ var examples = BuildChannelExamples();
+
+ var prompt = new SelectionPrompt()
+ .Title("[bold]Select an example channel to get started:[/]")
+ .PageSize(5)
+ .HighlightStyle(Style.Parse(brand))
+ .MoreChoicesText(string.Format(CultureInfo.InvariantCulture, "[{0}](use {1}{2} arrows)[/]", dim, Constants.Symbols.UpArrow, Constants.Symbols.DownArrow))
+ .UseConverter(ex => FormatChannelExample(ex, brand, dim));
+
+ prompt.AddChoices(examples);
+
+ if (Console.IsInputRedirected)
+ {
+ SpectreAnsiConsole.MarkupLine(string.Format(
+ CultureInfo.InvariantCulture,
+ "[{0}]Using default channel: [{1}]latest[/][/]",
+ dim, brand));
+ return ChannelVersionResolver.LatestChannel;
+ }
+
+ var selected = SpectreAnsiConsole.Prompt(prompt);
+ SpectreAnsiConsole.WriteLine();
+ return selected.Channel;
+ }
+
+ ///
+ /// Prompts the user to choose how they want to access the dotnetup-managed dotnet
+ /// using an interactive selector that shows all options with descriptions and tooltips.
+ ///
+ internal static PathPreference PromptPathPreference()
+ {
+ bool isWindows = OperatingSystem.IsWindows();
+
+ string isolationTooltip = string.Format(
+ CultureInfo.InvariantCulture,
+ Strings.PathTooltipDotnetupDotnet,
+ isWindows ? "Program Files" : "/usr/local");
+
+ string terminalTooltip = isWindows
+ ? Strings.PathTooltipShellProfile + " " + Strings.PathTooltipShellProfileWindowsNote
+ : Strings.PathTooltipShellProfile;
+
+ var options = new List
+ {
+ new("i", Strings.PathPreferenceDotnetupDotnet, Strings.PathDescriptionDotnetupDotnet, isolationTooltip),
+ new("t", Strings.PathPreferenceShellProfile, isWindows ? Strings.PathDescriptionShellProfile : Strings.PathDescriptionShellProfileBase, terminalTooltip),
+ };
+
+ if (isWindows)
+ {
+ options.Add(new("r", Strings.PathPreferenceFullReplacement, Strings.PathDescriptionFullReplacement, Strings.PathTooltipFullReplacement));
+ }
+
+ int selected = InteractiveOptionSelector.Show("How would you like to use dotnetup?", options, defaultIndex: 1);
+
+ return selected switch
+ {
+ 0 => PathPreference.DotnetupDotnet,
+ 1 => PathPreference.ShellProfile,
+ _ => PathPreference.FullPathReplacement,
+ };
+ }
+
+ ///
+ /// Prompts the user about copying admin-managed installs into the dotnetup-managed directory.
+ /// Installs already tracked in the dotnetup manifest for are excluded.
+ ///
+ /// A list of installs to migrate if the user agrees, or an empty list if they decline or no unconverted system installs exist.
+ internal static List PromptInstallsToMigrateIfDesired(IDotnetEnvironmentManager dotnetEnvironment, PathPreference pathPreference, DotnetInstallRoot installRoot, string? manifestPath = null, bool askEvenIfConfigured = false)
+ {
+ if (!ShouldPromptToConvertSystemInstalls(pathPreference, ignoreConfig: askEvenIfConfigured))
+ {
+ return [];
+ }
+
+ var systemInstalls = dotnetEnvironment.GetExistingSystemInstalls();
+ if (systemInstalls.Count == 0)
+ {
+ return [];
+ }
+
+ // Filter out installs already tracked in the dotnetup manifest
+ List trackedInstalls;
+ using (new ScopedMutex(Constants.MutexNames.ModifyInstallationStates))
+ {
+ trackedInstalls = [.. new DotnetupSharedManifest(manifestPath).GetInstallations(installRoot)];
+ }
+
+ systemInstalls = [.. systemInstalls
+ .Where(unfilteredSystemInstall => !trackedInstalls.Exists(tracked =>
+ tracked.Component == unfilteredSystemInstall.Component && tracked.Version == unfilteredSystemInstall.Version.ToString())),];
+
+ if (systemInstalls.Count == 0)
+ {
+ return [];
+ }
+
+ // Find the system install path for display purposes
+ var currentInstall = dotnetEnvironment.GetCurrentPathConfiguration();
+ string systemPath = currentInstall?.InstallType == InstallType.Admin
+ ? currentInstall.Path
+ : DotnetEnvironmentManager.GetSystemDotnetPaths().FirstOrDefault() ?? "the system .NET location";
+
+ SpectreAnsiConsole.MarkupLine($"You have existing system install(s) of .NET in [{DotnetupTheme.Current.Accent}]{systemPath.EscapeMarkup()}[/].");
+
+ var displayItems = systemInstalls
+ .OrderBy(i => i.Component)
+ .ThenByDescending(i => i.Version)
+ .Select(i => string.Format(CultureInfo.InvariantCulture, "{0} {1}", i.Component.GetDisplayName(), i.Version))
+ .ToList();
+
+ var confirmResult = SpectreDisplayHelpers.RenderScrollableListWithConfirm(
+ displayItems,
+ visibleCount: 3,
+ "Do you want to copy the following installs into the dotnetup managed directory?",
+ allowNeverAsk: true);
+
+ HandleMigrationConfirmResult(confirmResult, askEvenIfConfigured);
+ return confirmResult == ConfirmResult.Yes ? systemInstalls : [];
+ }
+
+ ///
+ /// Persists the user's migration-prompt decision: clears a prior opt-out on accept,
+ /// sets on "never ask again",
+ /// or shows a hint on decline.
+ ///
+ private static void HandleMigrationConfirmResult(ConfirmResult confirmResult, bool askEvenIfConfigured)
+ {
+ if (confirmResult == ConfirmResult.Yes)
+ {
+ if (askEvenIfConfigured)
+ {
+ var config = DotnetupConfig.Read() ?? new DotnetupConfigData();
+ if (config.DisableInstallConversion)
+ {
+ config.DisableInstallConversion = false;
+ DotnetupConfig.Write(config);
+ }
+ }
+
+ SpectreAnsiConsole.MarkupLine($"[{DotnetupTheme.Current.Dim}]These will be installed after your setup completes. You can change this later with \"dotnetup defaultinstall\".[/]");
+ }
+ else if (confirmResult == ConfirmResult.NeverAskAgain)
+ {
+ var config = DotnetupConfig.Read() ?? new DotnetupConfigData();
+ config.DisableInstallConversion = true;
+ DotnetupConfig.Write(config);
+ }
+ else
+ {
+ SpectreAnsiConsole.MarkupLine($"[{DotnetupTheme.Current.Dim}]You can change this later with \"dotnetup defaultinstall\".[/]");
+ }
+ }
+
+ // ── Migration Batch ──
+
+ ///
+ /// Installs migrated system installs in two phases: SDKs first (their archives
+ /// typically bundle runtimes), then runtimes that aren't already on disk.
+ /// Failures are collected from both phases and reported at the end.
+ ///
+ private static void ExecuteMigrationBatch(List toMigrate, DotnetInstallRoot installRoot, bool noProgress)
+ {
+ var sdks = toMigrate.Where(i => i.Component == InstallComponent.SDK).ToList();
+ var runtimes = toMigrate.Where(i => i.Component != InstallComponent.SDK).ToList();
+ var allFailures = new List();
+
+ // Phase 1: Install SDKs first — their archives typically bundle runtime binaries.
+ if (sdks.Count > 0)
+ {
+ var sdkRequests = sdks.Select(i => new ResolvedInstallRequest(
+ new DotnetInstallRequest(
+ installRoot,
+ new UpdateChannel(i.Version.ToString()),
+ i.Component,
+ new InstallRequestOptions()),
+ i.Version)).ToList();
+
+ var sdkResult = InstallExecutor.ExecuteInstalls(sdkRequests, noProgress);
+ allFailures.AddRange(sdkResult.Failures);
+ }
+
+ SpectreAnsiConsole.WriteLine();
+
+ // Phase 2: Skip runtimes whose folders already landed on disk via SDK archives.
+ var remainingRuntimes = runtimes.Where(r => !RuntimeFolderExistsOnDisk(installRoot, r)).ToList();
+
+ if (remainingRuntimes.Count > 0)
+ {
+ var runtimeRequests = remainingRuntimes.Select(i => new ResolvedInstallRequest(
+ new DotnetInstallRequest(
+ installRoot,
+ new UpdateChannel(i.Version.ToString()),
+ i.Component,
+ new InstallRequestOptions()),
+ i.Version)).ToList();
+
+ var runtimeResult = InstallExecutor.ExecuteInstalls(runtimeRequests, noProgress);
+ allFailures.AddRange(runtimeResult.Failures);
+ }
+
+ if (allFailures.Count > 0)
+ {
+ SpectreAnsiConsole.MarkupLine(string.Format(
+ CultureInfo.InvariantCulture,
+ "\n[{0}]{1} of {2} migration install(s) failed. " +
+ "You can retry them later with \"dotnetup install\".[/]",
+ DotnetupTheme.Current.Warning,
+ allFailures.Count,
+ toMigrate.Count));
+ }
+ }
+
+ ///
+ /// Checks whether a runtime's framework folder already exists on disk,
+ /// typically because it was bundled inside an SDK archive.
+ ///
+ private static bool RuntimeFolderExistsOnDisk(DotnetInstallRoot installRoot, DotnetInstall runtime)
+ {
+ string frameworkDir = Path.Combine(
+ installRoot.Path,
+ "shared",
+ runtime.Component.GetFrameworkName(),
+ runtime.Version.ToString());
+ return Directory.Exists(frameworkDir);
+ }
+
+ // ── Display Functions ──
+
+ ///
+ /// Shows the user where .NET will be installed, noting if the path
+ /// was determined by a global.json file.
+ ///
+ private static void DisplayInstallLocation(ResolvedInstallRequest request)
+ {
+ string? globalJsonPath = request.Request.Options.GlobalJsonPath;
+ string installPath = request.Request.InstallRoot.Path;
+
+ if (globalJsonPath is not null)
+ {
+ SpectreAnsiConsole.MarkupLine(string.Format(
+ CultureInfo.InvariantCulture,
+ "[{0}]Installing to [{1}]{2}[/] as controlled by global.json file [{1}]{3}[/].[/]",
+ DotnetupTheme.Current.Dim,
+ DotnetupTheme.Current.Accent,
+ installPath.EscapeMarkup(),
+ globalJsonPath.EscapeMarkup()));
+ }
+ else
+ {
+ SpectreAnsiConsole.MarkupLine(string.Format(
+ CultureInfo.InvariantCulture,
+ "[{0}]You can find dotnetup managed installs at [{1}]{2}[/].[/]",
+ DotnetupTheme.Current.Dim,
+ DotnetupTheme.Current.Accent,
+ installPath.EscapeMarkup()));
+ }
+ }
+
+ private static void ShowBanner()
+ {
+ SpectreAnsiConsole.Write(DotnetBotBanner.BuildPanel());
+ SpectreAnsiConsole.WriteLine();
+ }
+
+ ///
+ /// Builds a list of example channels with descriptions and resolved versions.
+ /// Uses the release manifest to find the latest major version dynamically.
+ ///
+ private List BuildChannelExamples()
+ {
+ var resolver = _channelVersionResolver;
+
+ var latestResolved = resolver.GetLatestVersionForChannel(
+ new UpdateChannel(ChannelVersionResolver.LatestChannel), InstallComponent.SDK);
+ string? ltsVersion = resolver.GetLatestVersionForChannel(
+ new UpdateChannel(ChannelVersionResolver.LtsChannel), InstallComponent.SDK)?.ToString();
+ string? previewVersion = resolver.GetLatestVersionForChannel(
+ new UpdateChannel(ChannelVersionResolver.PreviewChannel), InstallComponent.SDK)?.ToString();
+
+ var examples = new List
+ {
+ new(ChannelVersionResolver.LatestChannel, "Latest stable release", latestResolved?.ToString()),
+ new(NoneChannel, "I'll tell you what to install later.", null),
+ new(ChannelVersionResolver.LtsChannel, "Long Term Support", ltsVersion),
+ new(ChannelVersionResolver.PreviewChannel, "Latest preview", previewVersion),
+ };
+
+ if (latestResolved is not null)
+ {
+ string latestVersion = latestResolved.ToString();
+ string majorMinor = FormattableString.Invariant($"{latestResolved.Major}.{latestResolved.Minor}");
+ string featureBand = FormattableString.Invariant($"{latestResolved.Major}.{latestResolved.Minor}.{latestResolved.SdkFeatureBand / 100}xx");
+
+ examples.Add(new(majorMinor, "Major.Minor channel", latestVersion));
+ examples.Add(new(featureBand, "SDK feature band", latestVersion));
+ examples.Add(new(latestVersion, "Explicit version", latestVersion));
+ }
+
+ return examples;
+ }
+
+ private static string FormatChannelExample(ChannelExample ex, string brand, string dim)
+ {
+ if (ex.Channel == NoneChannel)
+ {
+ return string.Format(CultureInfo.InvariantCulture, "[bold {0}]{1}[/] [{2}]{3}[/]",
+ brand,
+ ex.Channel.EscapeMarkup().PadRight(12),
+ dim,
+ ex.Description.EscapeMarkup());
+ }
+
+ string resolved = ex.ResolvedVersion is not null
+ ? string.Format(CultureInfo.InvariantCulture, "[{0}] {1} {2}[/]", dim, Constants.Symbols.RightArrow, ex.ResolvedVersion)
+ : string.Format(CultureInfo.InvariantCulture, "[{0}] (no version available)[/]", dim);
+ string suggested = ex.Channel == ChannelVersionResolver.LatestChannel
+ ? " [white](suggested)[/]"
+ : "";
+ return string.Format(CultureInfo.InvariantCulture, "[bold {0}]{1}[/]{2} [{3}]{4}[/] {5}",
+ brand,
+ ex.Channel.EscapeMarkup().PadRight(12),
+ suggested,
+ dim,
+ ex.Description.EscapeMarkup(),
+ resolved);
+ }
+
+ private static void SaveConfigAndDisplayResult(PathPreference pathPreference, PathPreference? previousPreference)
+ {
+ var config = new DotnetupConfigData
+ {
+ PathPreference = pathPreference,
+ };
+
+ DotnetupConfig.Write(config);
+
+ // Only show guidance when the preference actually changed (or first-time setup).
+ if (previousPreference != pathPreference)
+ {
+ DisplayPathGuidance(pathPreference);
+ }
+
+ SpectreAnsiConsole.MarkupLine(DotnetupTheme.Brand("Setup complete!"));
+ }
+
+ ///
+ /// Shows guidance based on the chosen path preference.
+ ///
+ private static void DisplayPathGuidance(PathPreference preference)
+ {
+ string? guidance = preference switch
+ {
+ PathPreference.DotnetupDotnet => Strings.PathGuidanceDotnetupDotnet,
+ PathPreference.ShellProfile => Strings.PathGuidanceShellProfile,
+ PathPreference.FullPathReplacement => Strings.PathGuidanceFullReplacement,
+ _ => null,
+ };
+
+ if (guidance is not null)
+ {
+ SpectreAnsiConsole.MarkupLine(string.Format(
+ CultureInfo.InvariantCulture,
+ "[{0}]{1}[/]",
+ DotnetupTheme.Current.Dim,
+ guidance.EscapeMarkup()));
+ }
+ }
+}
diff --git a/src/Installer/dotnetup/CommonOptions.cs b/src/Installer/dotnetup/CommonOptions.cs
index 23bb9116de37..764b71dd7090 100644
--- a/src/Installer/dotnetup/CommonOptions.cs
+++ b/src/Installer/dotnetup/CommonOptions.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.CommandLine;
+using Microsoft.Dotnet.Installation.Internal;
using Microsoft.DotNet.Tools.Bootstrapper.Commands.Runtime.Install;
namespace Microsoft.DotNet.Tools.Bootstrapper;
@@ -32,6 +33,13 @@ internal class CommonOptions
Arity = ArgumentArity.ZeroOrOne
};
+ public static readonly Option VerbosityOption = new("--verbosity", "-v")
+ {
+ Description = "Set the output verbosity level (normal, detailed)",
+ Arity = ArgumentArity.ExactlyOne,
+ DefaultValueFactory = _ => Verbosity.Normal
+ };
+
///
/// Output format option for commands that support structured output.
/// Consistent with dotnet CLI's --format option.
@@ -101,7 +109,23 @@ internal class CommonOptions
}
///
- /// Creates a component-spec argument for runtime commands.
+ /// Creates a channel argument for SDK commands that accepts multiple values.
+ /// Allows commands like: dotnetup sdk install 9.0 10.0
+ ///
+ /// Verb for the description (e.g., "install").
+ public static Argument CreateSdkChannelArguments(string actionVerb)
+ {
+ return new Argument("channel")
+ {
+ HelpName = "CHANNEL",
+ Description = $"One or more channels or versions of the .NET SDK to {actionVerb} (e.g., latest, 10, 9.0.3xx, or 9.0.304). "
+ + "Multiple channels can be provided to install concurrently.",
+ Arity = ArgumentArity.ZeroOrMore,
+ };
+ }
+
+ ///
+ /// Creates a component-spec argument for runtime commands (single value).
/// Each command needs its own Argument instance (System.CommandLine requirement),
/// but the shape and valid types are shared.
///
@@ -122,6 +146,24 @@ internal class CommonOptions
};
}
+ ///
+ /// Creates a component-spec argument for runtime commands that accepts multiple values.
+ /// Allows commands like: dotnetup runtime install aspnet@9.0 runtime@10.0.2
+ ///
+ /// Verb for the description (e.g., "install").
+ public static Argument CreateRuntimeComponentSpecsArgument(string actionVerb)
+ {
+ return new Argument("component-spec")
+ {
+ HelpName = "COMPONENT_SPEC",
+ Description = $"One or more version/channel (e.g., 10.0) or component@version (e.g., aspnetcore@10.0) to {actionVerb}. "
+ + "When only a version is provided, the core .NET runtime is targeted. "
+ + "Multiple specs can be provided to install concurrently. "
+ + "Valid component types: " + string.Join(", ", RuntimeInstallCommand.GetValidRuntimeTypes()),
+ Arity = ArgumentArity.ZeroOrMore,
+ };
+ }
+
private static bool IsCIEnvironmentOrRedirected() =>
new Cli.Telemetry.CIEnvironmentDetectorForTelemetry().IsCIEnvironment() || Console.IsOutputRedirected;
}
diff --git a/src/Installer/dotnetup/Constants.cs b/src/Installer/dotnetup/Constants.cs
index b0317f7899f0..e8924feba4de 100644
--- a/src/Installer/dotnetup/Constants.cs
+++ b/src/Installer/dotnetup/Constants.cs
@@ -19,4 +19,27 @@ public static class MutexNames
///
public const string ModifyInstallationStates = "Global\\DotnetupManifest";
}
+
+ ///
+ /// Unicode symbols used in console output.
+ ///
+ public static class Symbols
+ {
+ public const string RightArrow = "\u2192"; // →
+ public const string UpArrow = "\u2191"; // ↑
+ public const string DownArrow = "\u2193"; // ↓
+ public const string UpTriangle = "\u25B2"; // ▲
+ public const string DownTriangle = "\u25BC"; // ▼
+ public const string Bullet = "\u2022"; // •
+ }
+
+ ///
+ /// ANSI escape sequences for terminal control.
+ ///
+ public static class Ansi
+ {
+ public const string HideCursor = "\x1b[?25l";
+ public const string ShowCursor = "\x1b[?25h";
+ public const string ClearToEnd = "\x1b[J";
+ }
}
diff --git a/src/Installer/dotnetup/DotnetEnvironmentManager.cs b/src/Installer/dotnetup/DotnetEnvironmentManager.cs
new file mode 100644
index 000000000000..aa0e81ce6bd7
--- /dev/null
+++ b/src/Installer/dotnetup/DotnetEnvironmentManager.cs
@@ -0,0 +1,339 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Dotnet.Installation.Internal;
+using Microsoft.DotNet.Cli.Utils;
+using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared;
+using Spectre.Console;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper;
+
+///
+/// Central manager for discovering and configuring the environment.
+/// Responsibilities:
+/// - Detecting the current install type (user vs. system/admin) via PATH and registry.
+/// - Resolving the default dotnetup-managed install path.
+/// - Enumerating existing system-level .NET installs across platforms (Windows registry,
+/// macOS /etc/dotnet/install_location files, Linux well-known paths).
+/// - Delegating SDK installation and global.json management.
+///
+internal class DotnetEnvironmentManager : IDotnetEnvironmentManager
+{
+
+ public DotnetEnvironmentManager()
+ {
+ }
+
+ public DotnetInstallRootConfiguration? GetCurrentPathConfiguration()
+ {
+ var environmentProvider = new EnvironmentProvider();
+ string? foundDotnet = environmentProvider.GetCommandPath("dotnet");
+ if (string.IsNullOrEmpty(foundDotnet))
+ {
+ return null;
+ }
+
+ var currentInstallRoot = new DotnetInstallRoot(Path.GetDirectoryName(foundDotnet)!, InstallerUtilities.GetDefaultInstallArchitecture());
+
+ // Use InstallRootManager to determine if the install is fully configured
+ if (OperatingSystem.IsWindows())
+ {
+ var installRootManager = new InstallRootManager(this);
+
+ // Check if user install root is fully configured
+ var userChanges = installRootManager.GetUserInstallRootChanges();
+ if (!userChanges.NeedsChange() && DotnetupUtilities.PathsEqual(currentInstallRoot.Path, userChanges.UserDotnetPath))
+ {
+ return new(currentInstallRoot, InstallType.User, IsFullyConfigured: true);
+ }
+
+ // Check if admin install root is fully configured
+ var adminChanges = installRootManager.GetAdminInstallRootChanges();
+ if (!adminChanges.NeedsChange())
+ {
+ return new(currentInstallRoot, InstallType.Admin, IsFullyConfigured: true);
+ }
+
+ // Not fully configured, but PATH resolves to dotnet
+ // Determine type based on location using registry-based detection
+ var programFilesDotnetPaths = WindowsPathHelper.GetProgramFilesDotnetPaths();
+ bool isAdminPath = programFilesDotnetPaths.Any(path =>
+ currentInstallRoot.Path.StartsWith(path, StringComparison.OrdinalIgnoreCase));
+
+ return new(currentInstallRoot, isAdminPath ? InstallType.Admin : InstallType.User, IsFullyConfigured: false);
+ }
+ else
+ {
+ // For non-Windows platforms, determine based on path location
+ bool isAdminInstall = InstallPathClassifier.IsAdminInstallPath(currentInstallRoot.Path);
+
+ // For now, we consider it fully configured if it's on PATH
+ return new(currentInstallRoot, isAdminInstall ? InstallType.Admin : InstallType.User, IsFullyConfigured: true);
+ }
+ }
+
+ public string GetDefaultDotnetInstallPath()
+ {
+ return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet");
+ }
+
+ public string? GetLatestInstalledSystemVersion()
+ {
+ var sdkInstalls = GetExistingSystemInstalls()
+ .Where(i => i.Component == InstallComponent.SDK)
+ .ToList();
+ return sdkInstalls.Count > 0 ? sdkInstalls[0].Version.ToString() : null;
+ }
+
+ public List GetInstalledSystemSdkVersions()
+ {
+ return [.. GetExistingSystemInstalls()
+ .Where(i => i.Component == InstallComponent.SDK)
+ .Select(i => i.Version.ToString())];
+ }
+
+ public List GetExistingSystemInstalls()
+ {
+ var installs = new List();
+
+ foreach (var systemPath in GetSystemDotnetPaths())
+ {
+ try
+ {
+ installs.AddRange(HostFxrWrapper.getInstalls(systemPath));
+ }
+ catch
+ {
+ // If we can't enumerate installs (e.g., hostfxr not found), skip this path
+ }
+ }
+
+ // Sort descending so newest versions appear first
+ installs.Sort((a, b) => string.Compare(b.Version.ToString(), a.Version.ToString(), StringComparison.OrdinalIgnoreCase));
+ return installs;
+ }
+
+ ///
+ /// Returns the system-level .NET install directories for the current platform.
+ /// Windows: reads registry (sharedhost\Path, then InstallLocation) per architecture,
+ /// falls back to %ProgramFiles%\dotnet.
+ /// macOS: checks /etc/dotnet/install_location_{arch}, /etc/dotnet/install_location,
+ /// defaults to /usr/local/share/dotnet (plus /usr/local/share/dotnet/x64 under Rosetta).
+ /// Linux: checks /usr/lib/dotnet, /usr/share/dotnet, /usr/lib64/dotnet.
+ ///
+ /// See https://github.com/dotnet/designs/blob/main/accepted/2021/install-location-per-architecture.md
+ /// See https://github.com/dotnet/runtime/issues/109974
+ ///
+ internal static List GetSystemDotnetPaths()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return WindowsPathHelper.GetProgramFilesDotnetPaths();
+ }
+
+ var paths = new List();
+
+ if (OperatingSystem.IsMacOS())
+ {
+ AddMacOSPaths(paths);
+ }
+ else
+ {
+ AddLinuxPaths(paths);
+ }
+
+ return paths;
+ }
+
+ ///
+ /// Adds macOS system dotnet paths in priority order:
+ /// 1. Per-architecture install_location file: /etc/dotnet/install_location_{arch}
+ /// 2. Default install_location file: /etc/dotnet/install_location
+ /// 3. Default system location: /usr/local/share/dotnet
+ /// 4. x64 emulation sublocation: /usr/local/share/dotnet/x64 (when running x64 on arm64 via Rosetta)
+ ///
+ private static void AddMacOSPaths(List paths)
+ {
+ var arch = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant();
+
+ // Per-architecture install_location file takes highest priority.
+ // See: https://github.com/dotnet/designs/blob/main/accepted/2021/install-location-per-architecture.md
+ TryReadInstallLocationFile(paths, $"/etc/dotnet/install_location_{arch}");
+
+ // Fall back to the non-architecture-specific file.
+ TryReadInstallLocationFile(paths, "/etc/dotnet/install_location");
+
+ // Default macOS system location (the .NET macOS installer places files here).
+ TryAddPath(paths, "/usr/local/share/dotnet");
+
+ // When running x64 under Rosetta on an arm64 Mac, the x64 dotnet root
+ // is a subdirectory rather than a sibling directory.
+ if (RuntimeInformation.ProcessArchitecture == Architecture.X64 &&
+ RuntimeInformation.OSArchitecture == Architecture.Arm64)
+ {
+ TryAddPath(paths, "/usr/local/share/dotnet/x64");
+ }
+ }
+
+ ///
+ /// Adds Linux system dotnet paths in priority order:
+ /// 1. /usr/lib/dotnet — preferred by newer distros and the .NET install docs.
+ /// 2. /usr/share/dotnet — used by some distributions (e.g., Ubuntu packages).
+ /// 3. /usr/lib64/dotnet — used by some RPM-based distributions (Fedora, RHEL).
+ ///
+ /// Note: /etc/ld.so.conf and /etc/ld.so.conf.d/* configure the dynamic linker
+ /// and may reference dotnet library paths on some distributions. The well-known
+ /// directories above cover the standard package-manager install locations.
+ ///
+ private static void AddLinuxPaths(List paths)
+ {
+ TryAddPath(paths, "/usr/lib/dotnet");
+ TryAddPath(paths, "/usr/share/dotnet");
+ TryAddPath(paths, "/usr/lib64/dotnet");
+ }
+
+ ///
+ /// Reads a dotnet install_location file and adds the path it contains if valid.
+ /// These files are written by the .NET installer on macOS and contain a single
+ /// line with the dotnet root directory.
+ ///
+ private static void TryReadInstallLocationFile(List paths, string filePath)
+ {
+ try
+ {
+ if (!File.Exists(filePath))
+ {
+ return;
+ }
+
+ string? location = File.ReadAllText(filePath).Trim();
+ if (!string.IsNullOrEmpty(location))
+ {
+ var normalized = location.TrimEnd(Path.DirectorySeparatorChar);
+ if (Directory.Exists(normalized) && !paths.Contains(normalized, StringComparer.Ordinal))
+ {
+ paths.Add(normalized);
+ }
+ }
+ }
+ catch
+ {
+ // Best-effort; file may be unreadable due to permissions
+ }
+ }
+
+ private static void TryAddPath(List paths, string path)
+ {
+ if (Directory.Exists(path) && !paths.Contains(path, StringComparer.Ordinal))
+ {
+ paths.Add(path);
+ }
+ }
+ internal static string? ReplaceGlobalJsonSdkVersion(string jsonText, string newVersion)
+ {
+ return GlobalJsonModifier.ReplaceGlobalJsonSdkVersion(jsonText, newVersion);
+ }
+
+ public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null)
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ // On Windows, use InstallRootManager for proper configuration
+ var installRootManager = new InstallRootManager(this);
+
+ switch (installType)
+ {
+ case InstallType.User:
+ if (string.IsNullOrEmpty(dotnetRoot))
+ {
+ throw new ArgumentNullException(nameof(dotnetRoot));
+ }
+
+ var userChanges = installRootManager.GetUserInstallRootChanges();
+ bool succeeded = InstallRootManager.ApplyUserInstallRoot(
+ userChanges,
+ AnsiConsole.WriteLine,
+ msg => AnsiConsole.MarkupLine(DotnetupTheme.Error(msg)));
+
+ if (!succeeded)
+ {
+ throw new InvalidOperationException("Failed to configure user install root.");
+ }
+ break;
+
+ case InstallType.Admin:
+ var adminChanges = installRootManager.GetAdminInstallRootChanges();
+ bool adminSucceeded = InstallRootManager.ApplyAdminInstallRoot(
+ adminChanges,
+ AnsiConsole.WriteLine,
+ msg => AnsiConsole.MarkupLine(DotnetupTheme.Error(msg)));
+
+ if (!adminSucceeded)
+ {
+ throw new InvalidOperationException("Failed to configure admin install root.");
+ }
+ break;
+
+ default:
+ throw new ArgumentException($"Unknown install type: {installType}", nameof(installType));
+ }
+ }
+ else
+ {
+ ConfigureInstallTypeUnix(installType, dotnetRoot);
+ }
+ }
+
+ private static void ConfigureInstallTypeUnix(InstallType installType, string? dotnetRoot)
+ {
+ // Non-Windows platforms: use the simpler PATH-based approach
+ // Get current PATH
+ var path = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty;
+ var pathEntries = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList();
+ string exeName = "dotnet";
+ // Remove only actual dotnet installation folders from PATH
+ pathEntries = [.. pathEntries.Where(p => !File.Exists(Path.Combine(p, exeName)))];
+
+ 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);
+ }
+
+ ///
+ public void ApplyGlobalJsonModifications(IReadOnlyList requests)
+ {
+ foreach (var request in requests)
+ {
+ string? globalJsonPath = request.Request.Options.GlobalJsonPath;
+ if (globalJsonPath is not null && request.Request.Component == InstallComponent.SDK)
+ {
+ GlobalJsonModifier.UpdateGlobalJson(globalJsonPath, request.ResolvedVersion.ToString());
+ }
+ }
+ }
+}
diff --git a/src/Installer/dotnetup/DotnetInstallManager.cs b/src/Installer/dotnetup/DotnetInstallManager.cs
deleted file mode 100644
index 81927efa3b50..000000000000
--- a/src/Installer/dotnetup/DotnetInstallManager.cs
+++ /dev/null
@@ -1,199 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System.Globalization;
-using Microsoft.Dotnet.Installation.Internal;
-using Microsoft.DotNet.Cli.Utils;
-using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared;
-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;
- }
-
- var currentInstallRoot = new DotnetInstallRoot(Path.GetDirectoryName(foundDotnet)!, InstallerUtilities.GetDefaultInstallArchitecture());
-
- // Use InstallRootManager to determine if the install is fully configured
- if (OperatingSystem.IsWindows())
- {
- var installRootManager = new InstallRootManager(this);
-
- // Check if user install root is fully configured
- var userChanges = installRootManager.GetUserInstallRootChanges();
- if (!userChanges.NeedsChange() && DotnetupUtilities.PathsEqual(currentInstallRoot.Path, userChanges.UserDotnetPath))
- {
- return new(currentInstallRoot, InstallType.User, IsFullyConfigured: true);
- }
-
- // Check if admin install root is fully configured
- var adminChanges = installRootManager.GetAdminInstallRootChanges();
- if (!adminChanges.NeedsChange())
- {
- return new(currentInstallRoot, InstallType.Admin, IsFullyConfigured: true);
- }
-
- // Not fully configured, but PATH resolves to dotnet
- // Determine type based on location using registry-based detection
- var programFilesDotnetPaths = WindowsPathHelper.GetProgramFilesDotnetPaths();
- bool isAdminPath = programFilesDotnetPaths.Any(path =>
- currentInstallRoot.Path.StartsWith(path, StringComparison.OrdinalIgnoreCase));
-
- return new(currentInstallRoot, isAdminPath ? InstallType.Admin : InstallType.User, IsFullyConfigured: false);
- }
- else
- {
- // For non-Windows platforms, determine based on path location
- bool isAdminInstall = InstallExecutor.IsAdminInstallPath(currentInstallRoot.Path);
-
- // For now, we consider it fully configured if it's on PATH
- return new(currentInstallRoot, isAdminInstall ? InstallType.Admin : InstallType.User, IsFullyConfigured: true);
- }
- }
-
- public string GetDefaultDotnetInstallPath()
- {
- return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet");
- }
-
- public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory)
- {
- return GlobalJsonModifier.GetGlobalJsonInfo(initialDirectory);
- }
-
- public string? GetLatestInstalledAdminVersion()
- {
- // TODO: Implement this
- return null;
- }
-
- public void InstallSdks(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions)
- {
- foreach (var channelVersion in sdkVersions)
- {
- InstallSDK(dotnetRoot, new UpdateChannel(channelVersion));
- }
- }
-
- private static void InstallSDK(DotnetInstallRoot dotnetRoot, UpdateChannel channel)
- {
- DotnetInstallRequest request = new DotnetInstallRequest(
- dotnetRoot,
- channel,
- InstallComponent.SDK,
- new InstallRequestOptions()
- );
-
- InstallResult installResult = InstallerOrchestratorSingleton.Instance.Install(request);
- Spectre.Console.AnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[green]Installed .NET SDK {installResult.Install.Version}, available via {installResult.Install.InstallRoot}[/]");
- }
-
- public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null)
- {
- GlobalJsonModifier.UpdateGlobalJson(globalJsonPath, sdkVersion);
- }
-
- internal static string? ReplaceGlobalJsonSdkVersion(string jsonText, string newVersion)
- {
- return GlobalJsonModifier.ReplaceGlobalJsonSdkVersion(jsonText, newVersion);
- }
-
- public void ConfigureInstallType(InstallType installType, string? dotnetRoot = null)
- {
- if (OperatingSystem.IsWindows())
- {
- // On Windows, use InstallRootManager for proper configuration
- var installRootManager = new InstallRootManager(this);
-
- switch (installType)
- {
- case InstallType.User:
- if (string.IsNullOrEmpty(dotnetRoot))
- {
- throw new ArgumentNullException(nameof(dotnetRoot));
- }
-
- var userChanges = installRootManager.GetUserInstallRootChanges();
- bool succeeded = InstallRootManager.ApplyUserInstallRoot(
- userChanges,
- AnsiConsole.WriteLine,
- msg => AnsiConsole.MarkupLine($"[red]{msg}[/]"));
-
- if (!succeeded)
- {
- throw new InvalidOperationException("Failed to configure user install root.");
- }
- break;
-
- case InstallType.Admin:
- var adminChanges = installRootManager.GetAdminInstallRootChanges();
- bool adminSucceeded = InstallRootManager.ApplyAdminInstallRoot(
- adminChanges,
- AnsiConsole.WriteLine,
- msg => AnsiConsole.MarkupLine($"[red]{msg}[/]"));
-
- if (!adminSucceeded)
- {
- throw new InvalidOperationException("Failed to configure admin install root.");
- }
- break;
-
- default:
- throw new ArgumentException($"Unknown install type: {installType}", nameof(installType));
- }
- }
- else
- {
- // Non-Windows platforms: use the simpler PATH-based approach
- // Get current PATH
- var path = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty;
- var pathEntries = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList();
- string exeName = "dotnet";
- // Remove only actual dotnet installation folders from PATH
- pathEntries = [.. pathEntries.Where(p => !File.Exists(Path.Combine(p, exeName)))];
-
- 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/dotnetup/DotnetupConfig.cs b/src/Installer/dotnetup/DotnetupConfig.cs
new file mode 100644
index 000000000000..e917cc0a9b4f
--- /dev/null
+++ b/src/Installer/dotnetup/DotnetupConfig.cs
@@ -0,0 +1,93 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.DotNet.Tools.Bootstrapper;
+
+///
+/// Represents the user's path replacement preference chosen during the walkthrough.
+///
+internal enum PathPreference
+{
+ /// No PATH replacement. User runs commands via dotnetup dotnet.
+ DotnetupDotnet = 1,
+
+ /// Add dotnetup-managed dotnet to a shell profile file.
+ ShellProfile = 2,
+
+ /// Full PATH and DOTNET_ROOT replacement (the existing set-default-install behavior).
+ FullPathReplacement = 3,
+}
+
+///
+/// Persisted user configuration for dotnetup, stored alongside the manifest.
+/// Records decisions made during the interactive walkthrough.
+///
+internal class DotnetupConfigData
+{
+ public string SchemaVersion { get; set; } = "1";
+ public PathPreference PathPreference { get; set; } = PathPreference.FullPathReplacement;
+ public bool DisableInstallConversion { get; set; }
+}
+
+[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ UseStringEnumConverter = true)]
+[JsonSerializable(typeof(DotnetupConfigData))]
+[JsonSerializable(typeof(PathPreference))]
+internal partial class DotnetupConfigJsonContext : JsonSerializerContext { }
+
+///
+/// Reads and writes the dotnetup configuration file.
+///
+internal static class DotnetupConfig
+{
+ ///
+ /// Reads the config file if it exists, otherwise returns null.
+ /// Uses GlobalJsonFileHelper for encoding-aware reading (handles BOM variants).
+ ///
+ public static DotnetupConfigData? Read()
+ {
+ var path = DotnetupPaths.ConfigPath;
+ if (!File.Exists(path))
+ {
+ return null;
+ }
+
+ try
+ {
+ using var stream = GlobalJsonFileHelper.OpenAsUtf8Stream(path);
+ return JsonSerializer.Deserialize(stream, DotnetupConfigJsonContext.Default.DotnetupConfigData);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Writes the config file, creating the directory if necessary.
+ ///
+ public static void Write(DotnetupConfigData config)
+ {
+ DotnetupPaths.EnsureDataDirectoryExists();
+ var json = JsonSerializer.Serialize(config, DotnetupConfigJsonContext.Default.DotnetupConfigData);
+ File.WriteAllText(DotnetupPaths.ConfigPath, json);
+ }
+
+ ///
+ /// Returns the user's from the config file if it exists,
+ /// otherwise returns null.
+ ///
+ public static PathPreference? ReadPathPreference()
+ {
+ var config = Read();
+ return config?.PathPreference;
+ }
+
+ ///
+ /// Returns true if a config file exists, indicating the walkthrough has been completed.
+ ///
+ public static bool Exists() => File.Exists(DotnetupPaths.ConfigPath);
+}
diff --git a/src/Installer/dotnetup/DotnetupPaths.cs b/src/Installer/dotnetup/DotnetupPaths.cs
index 93c30cc06bd9..67e1224a72d1 100644
--- a/src/Installer/dotnetup/DotnetupPaths.cs
+++ b/src/Installer/dotnetup/DotnetupPaths.cs
@@ -11,6 +11,7 @@ internal static class DotnetupPaths
{
private const string DotnetupFolderName = "dotnetup";
private const string ManifestFileName = "dotnetup_manifest.json";
+ private const string ConfigFileName = "dotnetup.config.json";
private const string TelemetrySentinelFileName = ".dotnetup-telemetry-notice";
#pragma warning disable IDE0032 // Lazy-init cache; not convertible to auto-property
@@ -75,6 +76,16 @@ public static string ManifestPath
}
}
+ ///
+ /// Gets the path to the dotnetup configuration file.
+ ///
+ public static string ConfigPath => Path.Combine(DataDirectory, ConfigFileName);
+
+ ///
+ /// Gets the path to the download cache directory.
+ ///
+ public static string DownloadCacheDirectory => Path.Combine(DataDirectory, "downloadcache");
+
///
/// Gets the path to the telemetry first-run sentinel file.
///
diff --git a/src/Installer/dotnetup/DotnetupSharedManifest.cs b/src/Installer/dotnetup/DotnetupSharedManifest.cs
index 6330c9c2afa8..81eae26bc3d8 100644
--- a/src/Installer/dotnetup/DotnetupSharedManifest.cs
+++ b/src/Installer/dotnetup/DotnetupSharedManifest.cs
@@ -83,6 +83,13 @@ internal DotnetupManifestData ReadManifest()
ex);
}
+ var manifest = ParseManifestJson(json);
+ PruneStaleInstallations(manifest);
+ return manifest;
+ }
+
+ private DotnetupManifestData ParseManifestJson(string json)
+ {
try
{
// Try new format first
@@ -372,4 +379,150 @@ public void RemoveInstallation(DotnetInstallRoot installRoot, Installation insta
WriteManifest(manifest);
}
+ ///
+ /// Returns true if the manifest already records an installation matching the given install.
+ /// Must be called while holding the install-state mutex.
+ ///
+ internal bool InstallAlreadyExists(DotnetInstall install)
+ {
+ var manifestData = ReadManifest();
+ var root = manifestData.DotnetRoots.FirstOrDefault(r =>
+ DotnetupUtilities.PathsEqual(r.Path, install.InstallRoot.Path!));
+ return root?.Installations.Any(existing =>
+ existing.Version == install.Version.ToString() &&
+ existing.Component == install.Component) ?? false;
+ }
+
+ ///
+ /// Returns true if the manifest tracks the given install root.
+ /// Must be called while holding the install-state mutex.
+ ///
+ internal bool IsRootTracked(DotnetInstallRoot installRoot)
+ {
+ var manifestData = ReadManifest();
+ return manifestData.DotnetRoots.Any(root =>
+ DotnetupUtilities.PathsEqual(root.Path, installRoot.Path));
+ }
+
+ ///
+ /// Checks whether a directory contains common .NET installation markers
+ /// (dotnet executable, sdk/ or shared/ subdirectories).
+ ///
+ internal static bool HasDotnetArtifacts(string? path)
+ {
+ if (path is null || !Directory.Exists(path))
+ {
+ return false;
+ }
+
+ return File.Exists(Path.Combine(path, DotnetupUtilities.GetDotnetExeName()))
+ || Directory.Exists(Path.Combine(path, "sdk"))
+ || Directory.Exists(Path.Combine(path, "shared"));
+ }
+
+ ///
+ /// Records the install spec in the manifest, respecting Untracked and SkipInstallSpecRecording flags.
+ /// Must be called while holding the install-state mutex.
+ ///
+ internal void RecordInstallSpec(DotnetInstallRequest installRequest)
+ {
+ if (installRequest.Options.Untracked || installRequest.Options.SkipInstallSpecRecording)
+ {
+ return;
+ }
+
+ AddInstallSpec(installRequest.InstallRoot, new InstallSpec
+ {
+ Component = installRequest.Component,
+ VersionOrChannel = installRequest.Channel.Name,
+ InstallSource = installRequest.Options.InstallSource switch
+ {
+ InstallRequestSource.GlobalJson => InstallSource.GlobalJson,
+ _ => InstallSource.Explicit,
+ },
+ GlobalJsonPath = installRequest.Options.GlobalJsonPath
+ });
+ }
+
+ ///
+ /// Removes a stale manifest entry for an install whose on-disk files no longer exist.
+ /// Must be called while holding the install-state mutex.
+ ///
+ internal void RemoveStaleInstallation(DotnetInstall install)
+ {
+ RemoveInstallation(install.InstallRoot, new Installation
+ {
+ Component = install.Component,
+ Version = install.Version.ToString()
+ });
+ }
+
+ ///
+ /// Checks each installation across all roots and removes entries whose
+ /// on-disk component directory no longer exists. Roots whose directory
+ /// no longer exists are removed entirely. Writes the manifest if any
+ /// entries were pruned.
+ ///
+ private void PruneStaleInstallations(DotnetupManifestData manifest)
+ {
+ bool anyPruned = false;
+
+ // Remove entire roots whose directory no longer exists.
+ int removedRoots = manifest.DotnetRoots.RemoveAll(root =>
+ !string.IsNullOrEmpty(root.Path) && !Directory.Exists(root.Path));
+ if (removedRoots > 0)
+ {
+ anyPruned = true;
+ }
+
+ // For surviving roots, prune individual installations that are missing on disk.
+ foreach (var root in manifest.DotnetRoots)
+ {
+ if (string.IsNullOrEmpty(root.Path))
+ {
+ continue;
+ }
+
+ int removed = root.Installations.RemoveAll(installation =>
+ !InstallationExistsOnDisk(root.Path, installation));
+
+ if (removed > 0)
+ {
+ anyPruned = true;
+ }
+ }
+
+ if (anyPruned)
+ {
+ WriteManifest(manifest);
+ }
+ }
+
+ ///
+ /// Returns true if the primary on-disk directory for the given installation
+ /// still exists under .
+ ///
+ private static bool InstallationExistsOnDisk(string rootPath, Installation installation)
+ {
+ string? componentDir = GetComponentDirectory(rootPath, installation);
+ return componentDir is not null && Directory.Exists(componentDir);
+ }
+
+ private static string? GetComponentDirectory(string rootPath, Installation installation)
+ {
+ if (string.IsNullOrEmpty(installation.Version))
+ {
+ return null;
+ }
+
+ return installation.Component switch
+ {
+ InstallComponent.SDK => Path.Combine(rootPath, "sdk", installation.Version),
+ InstallComponent.Runtime => Path.Combine(rootPath, "shared", InstallComponentExtensions.RuntimeFrameworkName, installation.Version),
+ InstallComponent.ASPNETCore => Path.Combine(rootPath, "shared", InstallComponentExtensions.AspNetCoreFrameworkName, installation.Version),
+ InstallComponent.WindowsDesktop => Path.Combine(rootPath, "shared", InstallComponentExtensions.WindowsDesktopFrameworkName, installation.Version),
+ _ => null,
+ };
+ }
+
}
diff --git a/src/Installer/dotnetup/DotnetupTheme.cs b/src/Installer/dotnetup/DotnetupTheme.cs
new file mode 100644
index 000000000000..6af9ceaad629
--- /dev/null
+++ b/src/Installer/dotnetup/DotnetupTheme.cs
@@ -0,0 +1,75 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.DotNet.Tools.Bootstrapper;
+
+///
+/// Semantic color names used throughout dotnetup output.
+/// All values are standard ANSI color names supported by Spectre.Console
+/// (e.g. "green", "red", "blue", "magenta", "yellow", "grey").
+///
+internal sealed class ThemeColors
+{
+ /// Color for success messages (installs, completions).
+ public string Success { get; set; } = "green";
+
+ /// Color for error messages.
+ public string Error { get; set; } = "red";
+
+ /// Color for warning messages.
+ public string Warning { get; set; } = "yellow";
+
+ /// Color for emphasis on versions, paths, and key values.
+ public string Accent { get; set; } = "magenta";
+
+ /// Color for the dotnet bot banner and branding elements.
+ public string Brand { get; set; } = "magenta";
+
+ /// Color for completion/finished highlights (e.g. progress bar at 100%).
+ public string SuccessAlt { get; set; } = "green";
+
+ /// Color for secondary/de-emphasized text.
+ public string Dim { get; set; } = "grey";
+
+ /// Color for success emphasis (installed versions/paths in success messages).
+ public string SuccessAccent { get; set; } = "green";
+}
+
+///
+/// Provides access to the current theme and helpers for building Spectre markup strings.
+///
+internal static class DotnetupTheme
+{
+ ///
+ /// Gets the current theme colors (default theme).
+ ///
+ public static ThemeColors Current { get; private set; } = new ThemeColors();
+
+ ///
+ /// Resets to the default theme.
+ ///
+ public static void Reload() => Current = new ThemeColors();
+
+ // ── Markup helpers ──────────────────────────────────────────────
+
+ /// Wraps text in the theme's success color markup.
+ public static string Success(string text) => $"[{Current.Success}]{text}[/]";
+
+ /// Wraps text in the theme's error color markup.
+ public static string Error(string text) => $"[{Current.Error}]{text}[/]";
+
+ /// Wraps text in the theme's warning color markup.
+ public static string Warning(string text) => $"[{Current.Warning}]{text}[/]";
+
+ /// Wraps text in the theme's accent color markup (versions, paths).
+ public static string Accent(string text) => $"[{Current.Accent}]{text}[/]";
+
+ /// Wraps text in the theme's brand color markup.
+ public static string Brand(string text) => $"[{Current.Brand}]{text}[/]";
+
+ /// Wraps text in the theme's dim/secondary color markup.
+ public static string Dim(string text) => $"[{Current.Dim}]{text}[/]";
+
+ /// Wraps text in the theme's success-accent color markup (versions/paths in success messages).
+ public static string SuccessAccent(string text) => $"[{Current.SuccessAccent}]{text}[/]";
+}
diff --git a/src/Installer/dotnetup/GlobalJsonModifier.cs b/src/Installer/dotnetup/GlobalJsonModifier.cs
index de7c514b0c2b..d18be31c1ad8 100644
--- a/src/Installer/dotnetup/GlobalJsonModifier.cs
+++ b/src/Installer/dotnetup/GlobalJsonModifier.cs
@@ -123,52 +123,27 @@ public static bool UpdateGlobalJsonIfNewer(string globalJsonPath, ReleaseVersion
switch (reader.TokenType)
{
case JsonTokenType.StartObject:
- if (nextValueIsSdk)
- {
- inSdkObject = true;
- nextValueIsSdk = false;
- }
+ if (nextValueIsSdk) { inSdkObject = true; nextValueIsSdk = false; }
foundVersionProperty = false;
depth++;
break;
case JsonTokenType.EndObject:
- if (inSdkObject && depth == 2)
- {
- inSdkObject = false;
- }
+ if (inSdkObject && depth == 2) { inSdkObject = false; }
foundVersionProperty = false;
depth--;
break;
case JsonTokenType.PropertyName:
- if (depth == 1 && reader.ValueTextEquals("sdk"u8))
- {
- nextValueIsSdk = true;
- }
- else if (inSdkObject && depth == 2 && reader.ValueTextEquals("version"u8))
- {
- foundVersionProperty = true;
- }
+ if (depth == 1 && reader.ValueTextEquals("sdk"u8)) { nextValueIsSdk = true; }
+ else if (inSdkObject && depth == 2 && reader.ValueTextEquals("version"u8)) { foundVersionProperty = true; }
break;
case JsonTokenType.String:
nextValueIsSdk = false;
if (foundVersionProperty)
{
- // Found the version value token — splice in the new version at its byte position.
- // TokenStartIndex is a UTF-8 byte offset, so splice on the byte array
- // rather than on the C# string to handle non-ASCII characters correctly.
- int tokenStart = (int)reader.TokenStartIndex;
- int tokenLength = (int)reader.BytesConsumed - tokenStart;
- byte[] replacementBytes = System.Text.Encoding.UTF8.GetBytes($"\"{newVersion}\"");
-
- byte[] result = new byte[bytes.Length - tokenLength + replacementBytes.Length];
- bytes.AsSpan(0, tokenStart).CopyTo(result);
- replacementBytes.CopyTo(result.AsSpan(tokenStart));
- bytes.AsSpan(tokenStart + tokenLength).CopyTo(result.AsSpan(tokenStart + replacementBytes.Length));
-
- return System.Text.Encoding.UTF8.GetString(result);
+ return SpliceUtf8Token(bytes, (int)reader.TokenStartIndex, (int)reader.BytesConsumed, newVersion);
}
break;
@@ -181,4 +156,19 @@ public static bool UpdateGlobalJsonIfNewer(string globalJsonPath, ReleaseVersion
return null;
}
+
+ private static string SpliceUtf8Token(byte[] bytes, int tokenStart, int bytesConsumed, string newVersion)
+ {
+ // TokenStartIndex is a UTF-8 byte offset, so splice on the byte array
+ // rather than on the C# string to handle non-ASCII characters correctly.
+ int tokenLength = bytesConsumed - tokenStart;
+ byte[] replacementBytes = System.Text.Encoding.UTF8.GetBytes($"\"{newVersion}\"");
+
+ byte[] result = new byte[bytes.Length - tokenLength + replacementBytes.Length];
+ bytes.AsSpan(0, tokenStart).CopyTo(result);
+ replacementBytes.CopyTo(result.AsSpan(tokenStart));
+ bytes.AsSpan(tokenStart + tokenLength).CopyTo(result.AsSpan(tokenStart + replacementBytes.Length));
+
+ return System.Text.Encoding.UTF8.GetString(result);
+ }
}
diff --git a/src/Installer/dotnetup/IDotnetInstallManager.cs b/src/Installer/dotnetup/IDotnetEnvironmentManager.cs
similarity index 67%
rename from src/Installer/dotnetup/IDotnetInstallManager.cs
rename to src/Installer/dotnetup/IDotnetEnvironmentManager.cs
index 5786a2864265..357b4d9556f4 100644
--- a/src/Installer/dotnetup/IDotnetInstallManager.cs
+++ b/src/Installer/dotnetup/IDotnetEnvironmentManager.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using Microsoft.Dotnet.Installation.Internal;
using Spectre.Console;
namespace Microsoft.DotNet.Tools.Bootstrapper;
@@ -11,22 +12,25 @@ namespace Microsoft.DotNet.Tools.Bootstrapper;
// - Orchestrate installation so that only one install happens at a time
// - Call into installer implementation
-public interface IDotnetInstallManager
+internal interface IDotnetEnvironmentManager
{
- GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory);
-
string GetDefaultDotnetInstallPath();
- DotnetInstallRootConfiguration? GetConfiguredInstallType();
+ DotnetInstallRootConfiguration? GetCurrentPathConfiguration();
- string? GetLatestInstalledAdminVersion();
+ string? GetLatestInstalledSystemVersion();
- void InstallSdks(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions);
+ List GetInstalledSystemSdkVersions();
- void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null);
+ List GetExistingSystemInstalls();
- void ConfigureInstallType(InstallType installType, string? dotnetRoot = null);
+ void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null);
+ ///
+ /// Updates the global.json file to reflect the installed SDK version,
+ /// if a global.json exists and the install was global.json-sourced.
+ ///
+ void ApplyGlobalJsonModifications(IReadOnlyList requests);
}
public class GlobalJsonInfo
diff --git a/src/Installer/dotnetup/InstallResult.cs b/src/Installer/dotnetup/InstallResult.cs
new file mode 100644
index 000000000000..9b0e87bf0d3b
--- /dev/null
+++ b/src/Installer/dotnetup/InstallResult.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 Microsoft.Dotnet.Installation.Internal;
+
+namespace Microsoft.Dotnet.Installation;
+
+///
+/// Result of an installation operation.
+///
+/// The DotnetInstall for the completed installation.
+/// True if the SDK was already installed and no work was done.
+public record InstallResult(DotnetInstall Install, bool WasAlreadyInstalled);
+
+///
+/// Records a failed installation within a batch so that other installs can continue.
+///
+/// The request that failed.
+/// The exception describing the failure.
+internal record InstallFailure(ResolvedInstallRequest Request, DotnetInstallException Exception);
+
+///
+/// Aggregated outcome of a batch install. Contains both successful and failed results
+/// so callers can display partial-success summaries.
+///
+/// Installs that completed or were already present.
+/// Installs that failed with a recoverable error.
+internal record InstallBatchResult(IReadOnlyList Successes, IReadOnlyList Failures)
+{
+ /// True when every install in the batch succeeded.
+ public bool AllSucceeded => Failures.Count == 0;
+}
diff --git a/src/Installer/dotnetup/InstallRootManager.cs b/src/Installer/dotnetup/InstallRootManager.cs
index 700c4de85611..89445de8290b 100644
--- a/src/Installer/dotnetup/InstallRootManager.cs
+++ b/src/Installer/dotnetup/InstallRootManager.cs
@@ -10,11 +10,11 @@ namespace Microsoft.DotNet.Tools.Bootstrapper;
///
internal class InstallRootManager
{
- private readonly IDotnetInstallManager _dotnetInstaller;
+ private readonly IDotnetEnvironmentManager _dotnetEnvironment;
- public InstallRootManager(IDotnetInstallManager? dotnetInstaller = null)
+ public InstallRootManager(IDotnetEnvironmentManager? dotnetEnvironment = null)
{
- _dotnetInstaller = dotnetInstaller ?? new DotnetInstallManager();
+ _dotnetEnvironment = dotnetEnvironment ?? new DotnetEnvironmentManager();
}
///
@@ -27,7 +27,7 @@ public UserInstallRootChanges GetUserInstallRootChanges()
throw new PlatformNotSupportedException("User install root configuration is only supported on Windows.");
}
- string userDotnetPath = _dotnetInstaller.GetDefaultDotnetInstallPath();
+ string userDotnetPath = _dotnetEnvironment.GetDefaultDotnetInstallPath();
bool needToRemoveAdminPath = WindowsPathHelper.AdminPathContainsProgramFilesDotnet(out var foundDotnetPaths);
// Read both expanded and unexpanded user PATH from registry
@@ -65,7 +65,7 @@ public AdminInstallRootChanges GetAdminInstallRootChanges()
.Contains(programFilesDotnetPaths.First(), StringComparer.OrdinalIgnoreCase);
// Get the user dotnet installation path
- string userDotnetPath = _dotnetInstaller.GetDefaultDotnetInstallPath();
+ string userDotnetPath = _dotnetEnvironment.GetDefaultDotnetInstallPath();
// Read both expanded and unexpanded user PATH from registry to preserve environment variables
string unexpandedUserPath = WindowsPathHelper.ReadUserPath(expand: false);
diff --git a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs
index bc3986924e74..56208d3541c1 100644
--- a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs
+++ b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs
@@ -6,13 +6,6 @@
namespace Microsoft.DotNet.Tools.Bootstrapper;
-///
-/// Result of an installation operation.
-///
-/// The DotnetInstall for the completed installation.
-/// True if the SDK was already installed and no work was done.
-internal sealed record InstallResult(DotnetInstall Install, bool WasAlreadyInstalled);
-
internal class InstallerOrchestratorSingleton
{
public static InstallerOrchestratorSingleton Instance { get; } = new();
@@ -23,66 +16,261 @@ private InstallerOrchestratorSingleton()
private static ScopedMutex ModifyInstallStateMutex() => new(Constants.MutexNames.ModifyInstallationStates);
+ ///
+ /// Downloads the archive for an already-resolved install request into the download cache
+ /// without installing. This allows a background task to warm the cache while the user
+ /// interacts with walkthrough prompts, so the subsequent call
+ /// finds the archive already cached and skips the download.
+ ///
+ ///
+ /// This method is safe to call concurrently with . The download
+ /// cache handles deduplication, and no install-state mutex is acquired.
+ /// Exceptions are intentionally swallowed — a failed predownload simply means the
+ /// real install will download normally.
+ ///
+ public static async Task PredownloadToCacheAsync(ResolvedInstallRequest resolvedRequest)
+ {
+ try
+ {
+ // Download to a temp file, which populates the download cache as a side effect.
+ // The temp file is cleaned up afterwards — only the cache entry matters.
+ var tempDir = Directory.CreateTempSubdirectory("dotnetup-predownload").FullName;
+ try
+ {
+ var archivePath = Path.Combine(tempDir, $"predownload{DotnetupUtilities.GetArchiveFileExtensionForPlatform()}");
+ ReleaseManifest releaseManifest = new();
+ using var downloader = new DotnetArchiveDownloader(releaseManifest, cacheDirectory: DotnetupPaths.DownloadCacheDirectory);
+ await Task.Run(() => downloader.DownloadArchiveWithVerification(resolvedRequest.Request, resolvedRequest.ResolvedVersion, archivePath)).ConfigureAwait(false);
+ }
+ finally
+ {
+ try { Directory.Delete(tempDir, recursive: true); } catch { /* best-effort cleanup */ }
+ }
+ }
+ catch
+ {
+ // Predownload is best-effort — failures are silently ignored.
+ // The real install will download normally.
+ }
+ }
+
// Throws DotnetInstallException on failure, returns InstallResult on success
#pragma warning disable CA1822 // Intentionally an instance method on a singleton
- public InstallResult Install(DotnetInstallRequest installRequest, bool noProgress = false)
+ public InstallResult Install(ResolvedInstallRequest resolvedRequest, bool noProgress = false)
{
- // Validate channel format before attempting resolution
- if (!ChannelVersionResolver.IsValidChannelFormat(installRequest.Channel.Name))
+ IProgressTarget progressTarget = noProgress ? new NonUpdatingProgressTarget() : new SpectreProgressTarget();
+ using var reporter = new LazyProgressReporter(progressTarget);
+ using var prepared = PrepareInstall(resolvedRequest, reporter, out var alreadyInstalledResult);
+
+ if (alreadyInstalledResult is not null)
{
- throw new DotnetInstallException(
- DotnetInstallErrorCode.InvalidChannel,
- $"'{installRequest.Channel.Name}' is not a valid .NET version or channel. " +
- $"Use a version like '9.0', '9.0.100', or a channel keyword: {string.Join(", ", ChannelVersionResolver.KnownChannelKeywords)}.",
- version: null, // Don't include user input in telemetry
- component: installRequest.Component.ToString());
+ return alreadyInstalledResult;
}
- // Map InstallRequest to DotnetInstallObject by converting channel to fully specified version
- ReleaseManifest releaseManifest = new();
- ReleaseVersion? versionToInstall = installRequest.ResolvedVersion
- ?? new ChannelVersionResolver(releaseManifest).Resolve(installRequest);
+ return CommitPreparedInstall(prepared!);
+ }
+#pragma warning restore CA1822
+
+ ///
+ /// Prepares and commits all install requests concurrently where possible: downloads run in parallel,
+ /// and commits serialize through the install-state mutex.
+ /// Individual failures are captured and returned
+ /// so that other installs in the batch can continue.
+ ///
+ /// A batch result containing both successes and per-request failures.
+ public InstallBatchResult InstallMany(IReadOnlyList requests, IProgressReporter sharedReporter)
+ {
+
+ var results = new List();
+ var failures = new List();
+ var fatalExceptions = new List();
+ var lockObj = new object();
- if (versionToInstall == null)
+ // Use a BlockingCollection so commits can start as soon as downloads finish,
+ // overlapping extraction/commit with still-running downloads.
+ using var readyQueue = new System.Collections.Concurrent.BlockingCollection();
+
+ // Suppress the "another process is waiting" message during multi-install
+ // because in-process mutex contention between download and commit threads
+ // is expected and the message would be misleading.
+ ScopedMutex.SuppressWaitingCallback = true;
+ try
+ {
+ var downloadTask = Task.Run(() => DownloadAll(requests, sharedReporter, readyQueue, results, failures, fatalExceptions, lockObj));
+ ConsumeAndCommit(readyQueue, results, failures, fatalExceptions, lockObj);
+ downloadTask.Wait();
+ }
+ finally
{
- // Channel format was valid, but the version doesn't exist
- throw new DotnetInstallException(
- DotnetInstallErrorCode.VersionNotFound,
- $"Could not find .NET version '{installRequest.Channel.Name}'. The version may not exist or may not be supported.",
- version: null, // Don't include user input in telemetry
- component: installRequest.Component.ToString());
+ ScopedMutex.SuppressWaitingCallback = false;
}
- DotnetInstall install = new(
- installRequest.InstallRoot,
- versionToInstall,
- installRequest.Component);
+ // Fatal (non-DotnetInstallException) errors still abort the batch entirely.
+ if (fatalExceptions.Count > 0)
+ {
+ throw new AggregateException(fatalExceptions);
+ }
- string? customManifestPath = installRequest.Options.ManifestPath;
+ return new InstallBatchResult(results, failures);
+ }
- // Check if the install already exists and we don't need to do anything
- using (var finalizeLock = ModifyInstallStateMutex())
+ ///
+ /// Downloads archives concurrently (max 3) and enqueues PreparedInstalls for commit.
+ /// failures are captured per-request; other exceptions are fatal.
+ ///
+ private void DownloadAll(
+ IReadOnlyList requests,
+ IProgressReporter sharedReporter,
+ System.Collections.Concurrent.BlockingCollection readyQueue,
+ List results,
+ List failures,
+ List fatalExceptions,
+ object lockObj)
+ {
+ const int maxConcurrentDownloads = 3;
+
+ Parallel.ForEach(requests, new ParallelOptions { MaxDegreeOfParallelism = maxConcurrentDownloads }, request =>
+ {
+ try
+ {
+ var prepared = PrepareInstall(request, sharedReporter, out var existingResult);
+ lock (lockObj)
+ {
+ if (prepared is not null)
+ {
+ readyQueue.Add(prepared);
+ }
+ else if (existingResult is not null)
+ {
+ results.Add(existingResult);
+ }
+ }
+ }
+ catch (DotnetInstallException ex)
+ {
+ lock (lockObj) { failures.Add(new InstallFailure(request, ex)); }
+ }
+ catch (Exception ex)
+ {
+ lock (lockObj) { fatalExceptions.Add(ex); }
+ }
+ });
+
+ readyQueue.CompleteAdding();
+ }
+
+ ///
+ /// Consumes the ready queue and commits (extracts + records) each install as it arrives.
+ /// CommitPreparedInstall acquires the install-state mutex, so commits are serialized.
+ /// failures are captured per-request; other exceptions are fatal.
+ ///
+ private void ConsumeAndCommit(
+ System.Collections.Concurrent.BlockingCollection readyQueue,
+ List results,
+ List failures,
+ List fatalExceptions,
+ object lockObj)
+ {
+ var committedInstalls = new List();
+ try
+ {
+ foreach (var prepared in readyQueue.GetConsumingEnumerable())
+ {
+ committedInstalls.Add(prepared);
+ if (fatalExceptions.Count > 0)
+ {
+ continue;
+ }
+
+ try
+ {
+ results.Add(CommitPreparedInstall(prepared));
+ }
+ catch (DotnetInstallException ex)
+ {
+ lock (lockObj) { failures.Add(new InstallFailure(prepared.ResolvedRequest, ex)); }
+ }
+ catch (Exception ex)
+ {
+ lock (lockObj) { fatalExceptions.Add(ex); }
+ }
+ }
+ }
+ finally
+ {
+ foreach (var p in committedInstalls) { p.Dispose(); }
+ }
+ }
+
+ ///
+ /// Represents a prepared (downloaded but not yet committed) installation.
+ ///
+ internal sealed class PreparedInstall : IDisposable
+ {
+ public ResolvedInstallRequest ResolvedRequest { get; }
+ public DotnetInstallRequest Request => ResolvedRequest.Request;
+ public ReleaseVersion Version => ResolvedRequest.ResolvedVersion;
+ public DotnetInstall Install { get; }
+ public DotnetArchiveExtractor Extractor { get; }
+ public ReleaseManifest ReleaseManifest { get; }
+ public bool WasAlreadyInstalled { get; init; }
+
+ public PreparedInstall(ResolvedInstallRequest resolvedRequest, DotnetInstall install,
+ DotnetArchiveExtractor extractor, ReleaseManifest releaseManifest)
{
- // Untracked installs don't interact with the manifest, so skip reading it
- // entirely. This avoids errors when the manifest uses a legacy format.
- var manifestData = installRequest.Options.Untracked
- ? new DotnetupManifestData()
- : new DotnetupSharedManifest(customManifestPath).ReadManifest();
+ ResolvedRequest = resolvedRequest;
+ Install = install;
+ Extractor = extractor;
+ ReleaseManifest = releaseManifest;
+ }
+
+ public void Dispose() => Extractor.Dispose();
+ }
- if (InstallAlreadyExists(manifestData, install))
+ ///
+ /// Validates and resolves version for an install request, checks if already installed,
+ /// and downloads the archive — but does not commit (extract) it.
+ /// Returns null if the install was already present (the result is returned via the out parameter).
+ /// Used for concurrent multi-install scenarios where downloads happen in parallel.
+ ///
+ /// The resolved installation request with a concrete version.
+ /// A shared progress reporter for displaying download progress.
+ /// Set when the install is already present; null otherwise.
+ /// A PreparedInstall that can be committed later, or null if already installed.
+#pragma warning disable CA1822
+ public PreparedInstall? PrepareInstall(
+ ResolvedInstallRequest resolvedRequest,
+ IProgressReporter sharedReporter,
+ out InstallResult? alreadyInstalledResult)
+ {
+ alreadyInstalledResult = null;
+ var installRequest = resolvedRequest.Request;
+ var versionToInstall = resolvedRequest.ResolvedVersion;
+ var install = new DotnetInstall(installRequest.InstallRoot, versionToInstall, installRequest.Component);
+ ReleaseManifest releaseManifest = new();
+ var manifest = installRequest.Options.Untracked ? null : new DotnetupSharedManifest(installRequest.Options.ManifestPath);
+
+ using (var finalizeLock = ModifyInstallStateMutex())
+ {
+ if (manifest?.InstallAlreadyExists(install) == true)
{
- // Still record the install spec so the user's requested channel is tracked,
- // even though the version is already installed (possibly via a different channel).
- RecordInstallSpec(installRequest, customManifestPath);
- return new InstallResult(install, WasAlreadyInstalled: true);
+ // Validate that the installation actually exists on disk.
+ // If the files were deleted but the manifest still records it,
+ // silently remove the stale record and proceed with re-installation.
+ ArchiveInstallationValidator validator = new();
+ if (validator.Validate(install))
+ {
+ manifest.RecordInstallSpec(installRequest);
+ alreadyInstalledResult = new InstallResult(install, WasAlreadyInstalled: true);
+ return null;
+ }
+
+ manifest.RemoveStaleInstallation(install);
}
- // Guard: error if the target directory contains .NET artifacts but isn't tracked in the manifest.
- // This prevents silently mixing managed and unmanaged installations.
- // Skip this guard for untracked installs.
if (!installRequest.Options.Untracked
- && !IsRootInManifest(manifestData, installRequest.InstallRoot)
- && HasDotnetArtifacts(installRequest.InstallRoot.Path))
+ && !manifest!.IsRootTracked(installRequest.InstallRoot)
+ && DotnetupSharedManifest.HasDotnetArtifacts(installRequest.InstallRoot.Path))
{
throw new DotnetInstallException(
DotnetInstallErrorCode.Unknown,
@@ -92,115 +280,61 @@ public InstallResult Install(DotnetInstallRequest installRequest, bool noProgres
component: installRequest.Component.ToString());
}
- // Fail fast: if the muxer must be updated and it is currently locked,
- // throw before the expensive download. The check is inside the mutex
- // so it does not race with other dotnetup processes.
if (installRequest.Options.RequireMuxerUpdate && installRequest.InstallRoot.Path is not null)
{
MuxerHandler.EnsureMuxerIsWritable(installRequest.InstallRoot.Path);
}
}
- IProgressTarget progressTarget = noProgress ? new NonUpdatingProgressTarget() : new SpectreProgressTarget();
+ DotnetArchiveExtractor extractor = new(installRequest, versionToInstall, releaseManifest, sharedReporter, cacheDirectory: DotnetupPaths.DownloadCacheDirectory);
+ extractor.Prepare();
- using DotnetArchiveExtractor installer = new(installRequest, versionToInstall, releaseManifest, progressTarget);
- installer.Prepare();
+ return new PreparedInstall(resolvedRequest, install, extractor, releaseManifest);
+ }
+#pragma warning restore CA1822
+
+ ///
+ /// Commits a previously prepared installation: extracts the archive to the target directory
+ /// and records it in the manifest. Must be called sequentially (not concurrently).
+ ///
+ /// The installation result.
+#pragma warning disable CA1822
+ public InstallResult CommitPreparedInstall(PreparedInstall prepared)
+ {
+ var manifest = prepared.Request.Options.Untracked ? null : new DotnetupSharedManifest(prepared.Request.Options.ManifestPath);
- // Extract and commit the install to the directory
using (var finalizeLock = ModifyInstallStateMutex())
{
- // Untracked installs skip manifest entirely to avoid legacy format errors.
- var manifestData = installRequest.Options.Untracked
- ? new DotnetupManifestData()
- : new DotnetupSharedManifest(customManifestPath).ReadManifest();
-
- if (InstallAlreadyExists(manifestData, install))
+ if (manifest?.InstallAlreadyExists(prepared.Install) == true)
{
- return new InstallResult(install, WasAlreadyInstalled: true);
+ return new InstallResult(prepared.Install, WasAlreadyInstalled: true);
}
- installer.Commit();
+ prepared.Extractor.Commit();
ArchiveInstallationValidator validator = new();
- if (validator.Validate(install, out string? validationFailure))
+ if (validator.Validate(prepared.Install, out string? validationFailure))
{
- RecordInstallSpec(installRequest, customManifestPath);
+ manifest?.RecordInstallSpec(prepared.Request);
- // Record the installation with its resolved version
- if (!installRequest.Options.Untracked)
+ manifest?.AddInstallation(prepared.Request.InstallRoot, new Installation
{
- var manifestManager = new DotnetupSharedManifest(customManifestPath);
- manifestManager.AddInstallation(installRequest.InstallRoot, new Installation
- {
- Component = installRequest.Component,
- Version = versionToInstall.ToString(),
- Subcomponents = [.. installer.ExtractedSubcomponents]
- });
- }
+ Component = prepared.Request.Component,
+ Version = prepared.Version.ToString(),
+ Subcomponents = [.. prepared.Extractor.ExtractedSubcomponents]
+ });
}
else
{
throw new DotnetInstallException(
DotnetInstallErrorCode.InstallFailed,
$"Installation validation failed: {validationFailure}",
- version: versionToInstall.ToString(),
- component: installRequest.Component.ToString());
+ version: prepared.Version.ToString(),
+ component: prepared.Request.Component.ToString());
}
}
- return new InstallResult(install, WasAlreadyInstalled: false);
+ return new InstallResult(prepared.Install, WasAlreadyInstalled: false);
}
#pragma warning restore CA1822
-
- ///
- /// Records the install spec in the manifest, respecting Untracked and SkipInstallSpecRecording flags.
- ///
- private static void RecordInstallSpec(DotnetInstallRequest installRequest, string? customManifestPath)
- {
- if (installRequest.Options.Untracked || installRequest.Options.SkipInstallSpecRecording)
- {
- return;
- }
-
- var manifestManager = new DotnetupSharedManifest(customManifestPath);
- manifestManager.AddInstallSpec(installRequest.InstallRoot, new InstallSpec
- {
- Component = installRequest.Component,
- VersionOrChannel = installRequest.Channel.Name,
- InstallSource = installRequest.Options.InstallSource switch
- {
- InstallRequestSource.GlobalJson => InstallSource.GlobalJson,
- _ => InstallSource.Explicit,
- },
- GlobalJsonPath = installRequest.Options.GlobalJsonPath
- });
- }
-
- internal static bool InstallAlreadyExists(DotnetupManifestData manifestData, DotnetInstall install)
- {
- var root = manifestData.DotnetRoots.FirstOrDefault(r =>
- DotnetupUtilities.PathsEqual(r.Path, install.InstallRoot.Path!));
- return root?.Installations.Any(existing =>
- existing.Version == install.Version.ToString() &&
- existing.Component == install.Component) ?? false;
- }
-
- internal static bool IsRootInManifest(DotnetupManifestData manifestData, DotnetInstallRoot installRoot)
- {
- return manifestData.DotnetRoots.Any(root =>
- DotnetupUtilities.PathsEqual(root.Path, installRoot.Path));
- }
-
- internal static bool HasDotnetArtifacts(string? path)
- {
- if (path is null || !Directory.Exists(path))
- {
- return false;
- }
-
- // Check for common .NET installation markers
- return File.Exists(Path.Combine(path, DotnetupUtilities.GetDotnetExeName()))
- || Directory.Exists(Path.Combine(path, "sdk"))
- || Directory.Exists(Path.Combine(path, "shared"));
- }
}
diff --git a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs
index 18c11051bc07..2dac2414fb6f 100644
--- a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs
+++ b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs
@@ -44,7 +44,7 @@ public double Value
if (_value >= MaxValue && !_completed)
{
_completed = true;
- AnsiConsole.MarkupLine($"[green]Completed:[/] {Description}");
+ AnsiConsole.MarkupLine($"{DotnetupTheme.Brand("Completed:")} {Description}");
}
}
}
diff --git a/src/Installer/dotnetup/Parser.cs b/src/Installer/dotnetup/Parser.cs
index 184eed1c6670..18a34f6a4281 100644
--- a/src/Installer/dotnetup/Parser.cs
+++ b/src/Installer/dotnetup/Parser.cs
@@ -3,8 +3,11 @@
using System.CommandLine;
using System.CommandLine.Completions;
+using System.CommandLine.Help;
+using System.CommandLine.Invocation;
using System.Reflection;
using Microsoft.DotNet.Tools.Bootstrapper.Commands.DefaultInstall;
+using Microsoft.DotNet.Tools.Bootstrapper.Commands.Dotnet;
using Microsoft.DotNet.Tools.Bootstrapper.Commands.ElevatedAdminPath;
using Microsoft.DotNet.Tools.Bootstrapper.Commands.Info;
using Microsoft.DotNet.Tools.Bootstrapper.Commands.List;
@@ -14,6 +17,7 @@
using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install;
using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Uninstall;
using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Update;
+using Microsoft.DotNet.Tools.Bootstrapper.Commands.Walkthrough;
namespace Microsoft.DotNet.Tools.Bootstrapper;
@@ -58,14 +62,171 @@ private static RootCommand ConfigureCommandLine(RootCommand rootCommand)
rootCommand.Subcommands.Add(DefaultInstallCommandParser.GetCommand());
rootCommand.Subcommands.Add(PrintEnvScriptCommandParser.GetCommand());
rootCommand.Subcommands.Add(ListCommandParser.GetCommand());
+ rootCommand.Subcommands.Add(DotnetCommandParser.GetCommand());
+ rootCommand.Subcommands.Add(DotnetCommandParser.GetAliasCommand());
+ rootCommand.Subcommands.Add(WalkthroughCommandParser.GetCommand());
+
+ ConfigureHelp(rootCommand);
rootCommand.SetAction(parseResult =>
{
- // No subcommand - show help
- parseResult.InvocationConfiguration.Output.WriteLine(Strings.RootCommandDescription);
- return 0;
+ return new WalkthroughCommand(parseResult).Execute();
});
return rootCommand;
}
+
+ private static void ConfigureHelp(RootCommand rootCommand)
+ {
+ // Hide --info (shown in options section instead) and do (alias for dotnet)
+ foreach (Command cmd in rootCommand.Subcommands)
+ {
+ if (cmd.Name is "--info" or "do")
+ {
+ cmd.Hidden = true;
+ }
+ }
+
+ // Replace the help option's action with our grouped help writer
+ foreach (Option option in rootCommand.Options)
+ {
+ if (option is HelpOption helpOption)
+ {
+ helpOption.Action = new GroupedHelpAction(rootCommand);
+ break;
+ }
+ }
+ }
+
+ private sealed class GroupedHelpAction(RootCommand rootCommand) : SynchronousCommandLineAction
+ {
+ private static readonly (string Heading, string[] CommandNames)[] s_commandGroups =
+ [
+ (Strings.HelpInstallCommandsTitle, ["sdk", "runtime", "install", "update", "uninstall"]),
+ (Strings.HelpQueryCommandsTitle, ["list"]),
+ (Strings.HelpConfigCommandsTitle, ["print-env-script", "defaultinstall"]),
+ (Strings.HelpUtilityCommandsTitle, ["dotnet", "walkthrough"]),
+ ];
+
+ public override int Invoke(ParseResult parseResult)
+ {
+ TextWriter output = parseResult.InvocationConfiguration.Output;
+ Command command = parseResult.CommandResult.Command;
+
+ // Description
+ if (!string.IsNullOrWhiteSpace(command.Description))
+ {
+ output.WriteLine(Strings.HelpDescriptionLabel);
+ output.WriteLine($" {command.Description}");
+ output.WriteLine();
+ }
+
+ // Usage
+ output.WriteLine(Strings.HelpUsageLabel);
+ output.Write(" ");
+ output.Write(command.Name);
+ if (command.Subcommands.Any(c => !c.Hidden))
+ {
+ output.Write(" [command]");
+ }
+ if (command.Options.Any(o => !o.Hidden))
+ {
+ output.Write(" [options]");
+ }
+ output.WriteLine();
+ output.WriteLine();
+
+ // Options (including --info)
+ WriteOptionsSection(output, command, rootCommand);
+
+ // Command groups
+ foreach ((string heading, string[] names) in s_commandGroups)
+ {
+ WriteCommandGroup(output, heading, rootCommand, names);
+ }
+
+ return 0;
+ }
+
+ private static void WriteOptionsSection(TextWriter output, Command command, RootCommand rootCommand)
+ {
+ List<(string Label, string Description)> rows = [];
+
+ // --info is a hidden subcommand shown as an option
+ Command? infoCommand = rootCommand.Subcommands.FirstOrDefault(c => c.Name == "--info");
+ if (infoCommand is not null)
+ {
+ rows.Add((infoCommand.Name, infoCommand.Description ?? ""));
+ }
+
+ foreach (Option option in command.Options)
+ {
+ if (!option.Hidden)
+ {
+ rows.Add((FormatOptionLabel(option), option.Description ?? ""));
+ }
+ }
+
+ if (rows.Count > 0)
+ {
+ output.WriteLine(Strings.HelpOptionsTitle);
+ WriteTwoColumnRows(output, rows);
+ output.WriteLine();
+ }
+ }
+
+ private static void WriteCommandGroup(TextWriter output, string heading, RootCommand rootCommand, string[] commandNames)
+ {
+ List<(string Label, string Description)> rows = [];
+
+ foreach (string name in commandNames)
+ {
+ Command? cmd = rootCommand.Subcommands.FirstOrDefault(c => c.Name == name);
+ if (cmd is not null && !cmd.Hidden)
+ {
+ rows.Add((cmd.Name, cmd.Description ?? ""));
+ }
+ }
+
+ if (rows.Count > 0)
+ {
+ output.WriteLine(heading);
+ WriteTwoColumnRows(output, rows);
+ output.WriteLine();
+ }
+ }
+
+ private static string FormatOptionLabel(Option option)
+ {
+ string label = option.Name;
+ if (option.Aliases.Count > 0)
+ {
+ IEnumerable aliases = option.Aliases.Where(a => a != option.Name);
+ string joined = string.Join(", ", aliases);
+ if (!string.IsNullOrEmpty(joined))
+ {
+ label = $"{joined}, {label}";
+ }
+ }
+ return label;
+ }
+
+ private static void WriteTwoColumnRows(TextWriter output, List<(string Label, string Description)> rows)
+ {
+ int maxLabelWidth = rows.Max(r => r.Label.Length);
+ int padding = maxLabelWidth + 4; // 2 indent + 2 gap
+
+ foreach ((string label, string description) in rows)
+ {
+ output.Write(" ");
+ output.Write(label);
+ if (!string.IsNullOrEmpty(description))
+ {
+ output.Write(new string(' ', padding - label.Length - 2));
+ output.Write(description);
+ }
+ output.WriteLine();
+ }
+ }
+ }
}
diff --git a/src/Installer/dotnetup/Program.cs b/src/Installer/dotnetup/Program.cs
index b45df779dc10..1199d6f621d4 100644
--- a/src/Installer/dotnetup/Program.cs
+++ b/src/Installer/dotnetup/Program.cs
@@ -2,6 +2,8 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
+using Microsoft.DotNet.Cli;
+using Microsoft.DotNet.Cli.Utils;
using Microsoft.Dotnet.Installation.Internal;
using Microsoft.DotNet.Tools.Bootstrapper.Telemetry;
using Spectre.Console;
@@ -16,6 +18,11 @@ public static int Main(string[] args)
// This is DEBUG-only and removes the --debug flag from args
DotnetupDebugHelper.HandleDebugSwitch(ref args);
+ // Capture current console encoding so it can be restored on exit.
+ // Uses the same AutomaticEncodingRestorer from the .NET SDK CLI.
+ using AutomaticEncodingRestorer encodingRestorer = new();
+ ConfigureConsoleEncoding();
+
// Disable Spectre.Console line wrapping when output is redirected (piped),
// since wrapping is not useful for non-interactive consumers.
if (Console.IsOutputRedirected)
@@ -39,11 +46,9 @@ public static int Main(string[] args)
try
{
- var result = Parser.Invoke(args);
- rootActivity?.SetTag(TelemetryTagNames.ExitCode, result);
- rootActivity?.SetStatus(result == 0 ? ActivityStatusCode.Ok : ActivityStatusCode.Error);
+ var exitCode = InvokeParser(args, rootActivity);
- return result;
+ return exitCode;
}
catch (Exception ex)
{
@@ -64,22 +69,48 @@ public static int Main(string[] args)
// exporters see it. The 'using' dispose that follows is a no-op on
// an already-stopped Activity.
rootActivity?.Stop();
+ DisposeTelemetry();
+ }
+ }
+
+ private static int InvokeParser(string[] args, Activity? rootActivity)
+ {
+ var result = Parser.Invoke(args);
+ rootActivity?.SetTag(TelemetryTagNames.ExitCode, result);
+ rootActivity?.SetStatus(result == 0 ? ActivityStatusCode.Ok : ActivityStatusCode.Error);
+ return result;
+ }
+
+ private static void DisposeTelemetry()
+ {
+ // Best-effort flush with a short timeout. The Azure Monitor exporter
+ // has built-in offline storage (%LOCALAPPDATA%\Microsoft\AzureMonitor)
+ // so unsent spans survive process exit and are retried on the next run.
+ //
+ // We intentionally skip Dispose(): TracerProvider.Dispose() internally
+ // calls Shutdown() with its own (potentially long) timeout, which would
+ // block the user noticeably. Since ThreadPool threads are background
+ // threads, any remaining exporter work is terminated when Main returns.
+ try
+ {
+ DotnetupTelemetry.Instance.Flush(timeoutMilliseconds: 100);
+ }
+ catch
+ {
+ // Telemetry should never delay or crash the process exit.
+ }
+ }
- // The Azure Monitor exporter has built-in offline storage
- // (%LOCALAPPDATA%\Microsoft\AzureMonitor) so unsent telemetry
- // survives process exit and is retried on the next run.
- // Dispose on a background thread with a short timeout so we
- // never block the user waiting for a network round-trip.
- // This mirrors the pattern used by the .NET CLI, which writes
- // telemetry to disk and sends it asynchronously.
- try
- {
- Task.Run(DotnetupTelemetry.Instance.Dispose).Wait(TimeSpan.FromMilliseconds(100));
- }
- catch
- {
- // Telemetry should never delay or crash the process exit.
- }
+ ///
+ /// Sets the console output encoding to UTF-8 so Unicode glyphs render correctly.
+ /// Uses UILanguageOverride.OperatingSystemSupportsUtf8() from the .NET SDK CLI.
+ ///
+ private static void ConfigureConsoleEncoding()
+ {
+ if (Environment.GetEnvironmentVariable("DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING") != "1"
+ && UILanguageOverride.OperatingSystemSupportsUtf8())
+ {
+ Console.OutputEncoding = Encoding.UTF8;
}
}
}
diff --git a/src/Installer/dotnetup/SpectreProgressTarget.cs b/src/Installer/dotnetup/SpectreProgressTarget.cs
index 7348d97d5aa4..81c9d319f90f 100644
--- a/src/Installer/dotnetup/SpectreProgressTarget.cs
+++ b/src/Installer/dotnetup/SpectreProgressTarget.cs
@@ -17,7 +17,23 @@ private sealed class Reporter : IProgressReporter
public Reporter()
{
TaskCompletionSource tcs = new();
- var progressTask = AnsiConsole.Progress().StartAsync(async ctx =>
+ var progress = AnsiConsole.Progress();
+ var successAltStyle = Style.Parse(DotnetupTheme.Current.SuccessAlt);
+ progress.Columns(
+ new SpinnerColumn(Spinner.Known.Line) { Style = Style.Parse(DotnetupTheme.Current.Brand) },
+ new TaskDescriptionColumn { Alignment = Justify.Left },
+ new ProgressBarColumn
+ {
+ CompletedStyle = Style.Parse(DotnetupTheme.Current.Brand),
+ FinishedStyle = successAltStyle,
+ RemainingStyle = new Style(Color.Grey),
+ },
+ new PercentageColumn
+ {
+ CompletedStyle = successAltStyle,
+ });
+
+ var progressTask = progress.StartAsync(async ctx =>
{
tcs.SetResult(ctx);
await _overallTask.Task.ConfigureAwait(false);
@@ -37,19 +53,91 @@ public void Dispose()
}
}
+#pragma warning disable CA1001 // Timer is disposed in StopShimmer when the task completes
private sealed class ProgressTaskImpl : IProgressTask
+#pragma warning restore CA1001
{
private readonly Spectre.Console.ProgressTask _task;
+ private readonly string _baseDescription;
+ private readonly string? _shimmerWord;
+ private readonly string? _restEscaped;
+ private readonly Timer? _shimmerTimer;
+ private int _shimmerTick;
+ private volatile bool _shimmerStopped;
public ProgressTaskImpl(Spectre.Console.ProgressTask task)
{
_task = task;
+ _baseDescription = task.Description;
+
+ int spaceIndex = _baseDescription.IndexOf(' ');
+ if (spaceIndex > 0 && _baseDescription.StartsWith("Installing", StringComparison.Ordinal))
+ {
+ _shimmerWord = _baseDescription[..spaceIndex];
+ _restEscaped = _baseDescription[spaceIndex..].EscapeMarkup();
+ _shimmerTimer = new Timer(OnShimmerTick, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(80));
+ }
+ }
+
+ private void OnShimmerTick(object? state)
+ {
+ if (_shimmerStopped)
+ {
+ return;
+ }
+
+ try
+ {
+ int tick = Interlocked.Increment(ref _shimmerTick);
+ int wordLen = _shimmerWord!.Length;
+ // Wave sweeps across the word then briefly exits before re-entering.
+ int totalPositions = wordLen + 6;
+ // Offset by -3 so the shimmer wave starts off-screen (left of the word)
+ // and sweeps across naturally before exiting on the right.
+ int center = (tick % totalPositions) - 3;
+
+ var sb = new StringBuilder();
+ for (int i = 0; i < wordLen; i++)
+ {
+ int distance = Math.Abs(i - center);
+ string ch = _shimmerWord[i].ToString().EscapeMarkup();
+
+ sb.Append(distance switch
+ {
+ 0 => $"[white bold]{ch}[/]",
+ 1 => $"[grey85]{ch}[/]",
+ _ => $"[grey]{ch}[/]",
+ });
+ }
+
+ sb.Append(_restEscaped);
+ _task.Description = sb.ToString();
+ }
+ catch
+ {
+ // Shimmer is cosmetic — swallow any rendering errors silently.
+ }
+ }
+
+ private void StopShimmer()
+ {
+ _shimmerStopped = true;
+ _shimmerTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _shimmerTimer?.Dispose();
+ _task.Description = _baseDescription;
}
public double Value
{
get => _task.Value;
- set => _task.Value = value;
+ set
+ {
+ _task.Value = value;
+ if (value >= _task.MaxValue && _shimmerTimer is not null && !_shimmerStopped)
+ {
+ StopShimmer();
+ }
+ }
}
public string Description
diff --git a/src/Installer/dotnetup/Strings.resx b/src/Installer/dotnetup/Strings.resx
index 37e243fdf876..5df5bb328a69 100644
--- a/src/Installer/dotnetup/Strings.resx
+++ b/src/Installer/dotnetup/Strings.resx
@@ -142,7 +142,7 @@
Skip verifying that each installation is valid.
- Installed .NET (managed by dotnetup):
+ Installations (managed by dotnetup):(none)
@@ -153,7 +153,91 @@
.NET installation manager for user level installs.
+
+ Run a command using the dotnetup-managed dotnet installation.
+
+
+ Arguments to forward to dotnet.
+
+
+ Error: dotnet executable not found at '{0}'.
+
+
+ Run 'dotnetup install' to install a .NET SDK first.
+
+
+ Error: Failed to start the dotnet process.
+
dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry
+
+ Run the interactive setup walkthrough to install .NET and configure PATH settings.
+
+
+ Isolation Mode
+
+
+ Terminal Mode (Suggested)
+
+
+ Replacement Mode
+
+
+ You can run .NET commands with 'dotnetup dotnet <command>' (or 'dotnetup do <command>' for short).
+
+
+ Your shell profile has been updated. Restart your terminal or source your profile to use 'dotnet' directly.
+
+
+ PATH and DOTNET_ROOT have been updated. You can use 'dotnet' directly in new terminal sessions.
+
+
+ Use 'dotnetup dotnet' to consume installs managed by dotnetup. New installs can be used alongside your existing installs.
+
+
+ Configure the current shell profile to use installs managed by dotnetup. Only applications launched from the shell will leverage dotnetup installs.
+
+
+ Configure the current shell profile to use installs managed by dotnetup.
+
+
+ The system will be configured to use dotnetup installs over any other installs.
+
+
+ Recommended for users without admin rights, or for users who want to keep their system managed installs (e.g. {0}) as the default.
+
+
+ Modify the current shell profile file to consume dotnetup on PATH environment variables. Recommended for users who want to develop using dotnetup and launch their IDE through the terminal.
+
+
+ Only PowerShell will work in this mode. CMD has no profile file.
+
+
+ Recommended for users who want all of their debuggers, applications, and IDEs to use dotnetup installs by default. Admin privileges are required. Enables 'cmd' functionality by default. Other user accounts on this machine will be impacted and applications may break.
+
+
+ Replacement Mode is not supported on this operating system. Please choose Isolation Mode or Terminal Mode instead.
+
+
+ Options:
+
+
+ Description:
+
+
+ Usage:
+
+
+ Install Commands:
+
+
+ Query Commands:
+
+
+ Configuration Commands:
+
+
+ Utility Commands:
+
diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs
index edf14854a6f1..bc37c5eaebcc 100644
--- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs
+++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs
@@ -113,19 +113,30 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex)
var fullStackTrace = GetFullStackTrace(ex);
- return ex switch
+ return ClassifyExceptionType(ex, fullStackTrace);
+ }
+
+ private static ExceptionErrorInfo ClassifyExceptionType(Exception ex, string? fullStackTrace)
+ {
+ if (ex is DotnetInstallException installEx)
{
- // DotnetInstallException has specific error codes - categorize by error code
- // Sanitize the version to prevent PII leakage (user could have typed anything)
- // For network-related errors, also check the inner exception for more details
- DotnetInstallException installEx => GetInstallExceptionErrorInfo(installEx) with { StackTrace = fullStackTrace },
+ return GetInstallExceptionErrorInfo(installEx) with { StackTrace = fullStackTrace };
+ }
+
+ return TryClassifyNetworkOrIoException(ex, fullStackTrace)
+ ?? ClassifyOperationException(ex, fullStackTrace);
+ }
+ private static ExceptionErrorInfo? TryClassifyNetworkOrIoException(Exception ex, string? stackTrace)
+ {
+ return ex switch
+ {
// HTTP errors: 4xx client errors are often user issues, 5xx are product/server issues
HttpRequestException httpEx => new ExceptionErrorInfo(
httpEx.StatusCode.HasValue ? $"Http{(int)httpEx.StatusCode}" : "HttpRequestException",
Category: ErrorCategoryClassifier.ClassifyHttpError(httpEx.StatusCode),
StatusCode: (int?)httpEx.StatusCode,
- StackTrace: fullStackTrace),
+ StackTrace: stackTrace),
// FileNotFoundException before IOException (it derives from IOException)
// Could be user error (wrong path) or product error (our code referenced wrong file)
@@ -135,60 +146,66 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex)
Category: ErrorCategory.Product,
HResult: fnfEx.HResult,
Details: fnfEx.FileName is not null ? "file_specified" : null,
- StackTrace: fullStackTrace),
+ StackTrace: stackTrace),
// Permission denied - user environment issue (needs elevation or different permissions)
UnauthorizedAccessException => new ExceptionErrorInfo(
"PermissionDenied",
Category: ErrorCategory.User,
- StackTrace: fullStackTrace),
+ StackTrace: stackTrace),
// Directory not found - could be user specified bad path
DirectoryNotFoundException => new ExceptionErrorInfo(
"DirectoryNotFound",
Category: ErrorCategory.User,
- StackTrace: fullStackTrace),
+ StackTrace: stackTrace),
- IOException ioEx => MapIOException(ioEx, fullStackTrace),
+ IOException ioEx => MapIOException(ioEx, stackTrace),
+ _ => null
+ };
+ }
+
+ private static ExceptionErrorInfo ClassifyOperationException(Exception ex, string? stackTrace) =>
+ ex switch
+ {
// User cancelled the operation
OperationCanceledException => new ExceptionErrorInfo(
"Cancelled",
Category: ErrorCategory.User,
- StackTrace: fullStackTrace),
+ StackTrace: stackTrace),
// Invalid argument - user provided bad input
ArgumentException argEx => new ExceptionErrorInfo(
"InvalidArgument",
Category: ErrorCategory.User,
Details: argEx.ParamName,
- StackTrace: fullStackTrace),
+ StackTrace: stackTrace),
// Invalid operation - usually a bug in our code
InvalidOperationException => new ExceptionErrorInfo(
"InvalidOperation",
Category: ErrorCategory.Product,
- StackTrace: fullStackTrace),
+ StackTrace: stackTrace),
// Not supported - could be user trying unsupported scenario
NotSupportedException => new ExceptionErrorInfo(
"NotSupported",
Category: ErrorCategory.User,
- StackTrace: fullStackTrace),
+ StackTrace: stackTrace),
// Timeout - network/environment issue outside our control
TimeoutException => new ExceptionErrorInfo(
"Timeout",
Category: ErrorCategory.User,
- StackTrace: fullStackTrace),
+ StackTrace: stackTrace),
// Unknown exceptions default to product (fail-safe - we should handle known cases)
_ => new ExceptionErrorInfo(
ex.GetType().Name,
Category: ErrorCategory.Product,
- StackTrace: fullStackTrace)
+ StackTrace: stackTrace)
};
- }
///
/// Gets error info for a DotnetInstallException, enriching with inner exception details
diff --git a/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs b/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs
index ef1d33083454..f8acc0bead3d 100644
--- a/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs
+++ b/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs
@@ -73,7 +73,9 @@ public static bool IsFirstRun()
private static void ShowNotice()
{
- // Write to stderr, consistent with .NET SDK behavior
+ // Write the telemetry notice to stderr using plain Console.Error to avoid
+ // Spectre.Console rendering issues when stderr is not a terminal.
+ // The dotnetup banner is shown separately by WalkthroughCommand.
// See: https://learn.microsoft.com/dotnet/core/compatibility/sdk/10.0/dotnet-cli-stderr-output
Console.Error.WriteLine();
Console.Error.WriteLine(Strings.TelemetryNotice);
diff --git a/src/Installer/dotnetup/WindowsPathHelper.cs b/src/Installer/dotnetup/WindowsPathHelper.cs
index cf241bfaea50..094a27b44d61 100644
--- a/src/Installer/dotnetup/WindowsPathHelper.cs
+++ b/src/Installer/dotnetup/WindowsPathHelper.cs
@@ -133,14 +133,19 @@ public static void WriteUserPath(string path)
///
/// Gets the default Program Files dotnet installation path(s) by reading from the registry.
- /// Reads from HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\ to find actual installations.
+ /// For each architecture subkey under HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\{arch}:
+ /// 1. First checks {arch}\sharedhost → Path value (preferred, set by the .NET host installer)
+ /// 2. Falls back to {arch} → InstallLocation value
+ /// If the registry yields no results, falls back to %ProgramFiles%\dotnet if it exists.
+ /// See https://github.com/dotnet/designs/blob/main/accepted/2021/install-location-per-architecture.md
+ /// See https://github.com/dotnet/runtime/issues/109974
///
public static List GetProgramFilesDotnetPaths()
{
var paths = new List();
- // Read from registry to find actual dotnet installations
- // Use 32-bit registry hive to ensure we get the correct view
+ // Read from registry to find actual dotnet installations.
+ // Use 32-bit registry hive to ensure we get the correct view on WoW64.
using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32);
using var key = baseKey.OpenSubKey(@"SOFTWARE\dotnet\Setup\InstalledVersions");
if (key != null)
@@ -148,17 +153,46 @@ public static List GetProgramFilesDotnetPaths()
foreach (var archName in key.GetSubKeyNames())
{
using var archKey = key.OpenSubKey(archName);
- if (archKey != null)
+ if (archKey == null)
{
- var installLocation = archKey.GetValue("InstallLocation") as string;
- if (!string.IsNullOrEmpty(installLocation) && Directory.Exists(installLocation))
+ continue;
+ }
+
+ // Prefer sharedhost\Path — this is the canonical location set by the host installer.
+ string? installPath = null;
+ using (var sharedHostKey = archKey.OpenSubKey("sharedhost"))
+ {
+ installPath = sharedHostKey?.GetValue("Path") as string;
+ }
+
+ // Fallback to the InstallLocation value on the architecture key.
+ installPath ??= archKey.GetValue("InstallLocation") as string;
+
+ if (!string.IsNullOrEmpty(installPath))
+ {
+ var normalized = installPath.TrimEnd(Path.DirectorySeparatorChar);
+ if (Directory.Exists(normalized) && !paths.Contains(normalized, StringComparer.OrdinalIgnoreCase))
{
- paths.Add(installLocation.TrimEnd(Path.DirectorySeparatorChar));
+ paths.Add(normalized);
}
}
}
}
+ // Fallback: if registry yielded nothing, check the default Program Files location.
+ if (paths.Count == 0)
+ {
+ var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
+ if (!string.IsNullOrEmpty(programFiles))
+ {
+ var defaultPath = Path.Combine(programFiles, "dotnet");
+ if (Directory.Exists(defaultPath))
+ {
+ paths.Add(defaultPath);
+ }
+ }
+ }
+
return paths;
}
@@ -526,63 +560,80 @@ public static bool StartElevatedProcess(string operation)
WindowStyle = ProcessWindowStyle.Hidden
};
+ return RunElevatedProcessCore(startInfo);
+ }
+ finally
+ {
+ DisplayElevatedProcessOutput(outputFilePath);
+
+ // Clean up temporary directory
try
{
- using var process = Process.Start(startInfo);
- if (process == null)
- {
- throw new InvalidOperationException("Failed to start elevated process.");
- }
+ tempDirectory.Delete(recursive: true);
+ }
+ catch
+ {
+ // Ignore cleanup errors
+ }
+ }
+ }
- process.WaitForExit();
+ ///
+ /// Starts the elevated process described by , waits for it to exit,
+ /// and validates the exit code.
+ ///
+ /// True on success; false if the user cancelled the UAC prompt.
+ private static bool RunElevatedProcessCore(ProcessStartInfo startInfo)
+ {
+ try
+ {
+ using var process = Process.Start(startInfo);
+ if (process == null)
+ {
+ throw new InvalidOperationException("Failed to start elevated process.");
+ }
- if (process.ExitCode == -2147450730)
- {
- // NOTE: Process exit code -2147450730 means that the right .NET runtime could not be found
- // This should not happen when using NativeAOT dotnetup, but when testing using IL it can happen and
- // can be caused if DOTNET_ROOT has been set to a path that doesn't have the right runtime to run dotnetup.
- throw new InvalidOperationException("Elevated process failed: Unable to find matching .NET Runtime." + Environment.NewLine +
- "This is probably because dotnetup is not being run as self-contained and DOTNET_ROOT is set to a path that doesn't have a matching runtime.");
- }
- else if (process.ExitCode != 0)
- {
- throw new InvalidOperationException(
- $"Elevated process returned exit code {process.ExitCode}");
- }
+ process.WaitForExit();
- return true;
+ if (process.ExitCode == -2147450730)
+ {
+ // NOTE: Process exit code -2147450730 means that the right .NET runtime could not be found
+ // This should not happen when using NativeAOT dotnetup, but when testing using IL it can happen and
+ // can be caused if DOTNET_ROOT has been set to a path that doesn't have the right runtime to run dotnetup.
+ throw new InvalidOperationException("Elevated process failed: Unable to find matching .NET Runtime." + Environment.NewLine +
+ "This is probably because dotnetup is not being run as self-contained and DOTNET_ROOT is set to a path that doesn't have a matching runtime.");
}
- catch (System.ComponentModel.Win32Exception ex)
+ else if (process.ExitCode != 0)
{
- // User cancelled UAC prompt or elevation failed
- // ERROR_CANCELLED = 1223
- if (ex.NativeErrorCode == 1223)
- {
- return false;
- }
- throw;
+ throw new InvalidOperationException(
+ $"Elevated process returned exit code {process.ExitCode}");
}
+
+ return true;
}
- finally
+ catch (System.ComponentModel.Win32Exception ex)
{
- // Show any output from elevated process
- if (File.Exists(outputFilePath))
+ // User cancelled UAC prompt or elevation failed
+ // ERROR_CANCELLED = 1223
+ if (ex.NativeErrorCode == 1223)
{
- string outputContent = File.ReadAllText(outputFilePath);
- if (!string.IsNullOrEmpty(outputContent))
- {
- Console.WriteLine(outputContent);
- }
+ return false;
}
+ throw;
+ }
+ }
- // Clean up temporary directory
- try
- {
- tempDirectory.Delete(recursive: true);
- }
- catch
+ ///
+ /// Reads and displays any output written by the elevated process to .
+ ///
+ private static void DisplayElevatedProcessOutput(string outputFilePath)
+ {
+ if (File.Exists(outputFilePath))
+ {
+ string outputContent = File.ReadAllText(outputFilePath);
+ if (!string.IsNullOrEmpty(outputContent))
{
- // Ignore cleanup errors
+ Console.WriteLine(outputContent);
}
}
}
diff --git a/src/Installer/dotnetup/dotnetup.csproj b/src/Installer/dotnetup/dotnetup.csproj
index e5f296b3e2c3..e779cfdb2504 100644
--- a/src/Installer/dotnetup/dotnetup.csproj
+++ b/src/Installer/dotnetup/dotnetup.csproj
@@ -16,9 +16,11 @@
+ CodeAnalysisTreatWarningsAsErrors=false keeps CA* analyzer warnings as warnings instead of errors.
+ WarningsNotAsErrors demotes Meziantou MA0051 (method too long) so it stays a warning under TreatWarningsAsErrors. -->
falsefalse
+ $(WarningsNotAsErrors);MA0051$(DotnetupVersionPrefix)
@@ -47,9 +49,12 @@
+
+
+
diff --git a/src/Installer/dotnetup/xlf/Strings.cs.xlf b/src/Installer/dotnetup/xlf/Strings.cs.xlf
index dd6f3acdbefe..5d772efc32ba 100644
--- a/src/Installer/dotnetup/xlf/Strings.cs.xlf
+++ b/src/Installer/dotnetup/xlf/Strings.cs.xlf
@@ -7,11 +7,71 @@
Allows the command to stop and wait for user input or action.
+
+ Run a command using the dotnetup-managed dotnet installation.
+ Run a command using the dotnetup-managed dotnet installation.
+
+
+
+ Error: dotnet executable not found at '{0}'.
+ Error: dotnet executable not found at '{0}'.
+
+
+
+ Error: Failed to start the dotnet process.
+ Error: Failed to start the dotnet process.
+
+
+
+ Arguments to forward to dotnet.
+ Arguments to forward to dotnet.
+
+
+
+ Run 'dotnetup install' to install a .NET SDK first.
+ Run 'dotnetup install' to install a .NET SDK first.
+
+ Output format (text or json).Output format (text or json).
+
+ Configuration Commands:
+ Configuration Commands:
+
+
+
+ Description:
+ Description:
+
+
+
+ Install Commands:
+ Install Commands:
+
+
+
+ Options:
+ Options:
+
+
+
+ Query Commands:
+ Query Commands:
+
+
+
+ Usage:
+ Usage:
+
+
+
+ Utility Commands:
+ Utility Commands:
+
+ Display dotnetup information.Display dotnetup information.
@@ -38,8 +98,8 @@
- Installed .NET (managed by dotnetup):
- Installed .NET (managed by dotnetup):
+ Installations (managed by dotnetup):
+ Installations (managed by dotnetup):
@@ -57,6 +117,81 @@
Total
+
+ Use 'dotnetup dotnet' to consume installs managed by dotnetup. New installs can be used alongside your existing installs.
+ Use 'dotnetup dotnet' to consume installs managed by dotnetup. New installs can be used alongside your existing installs.
+
+
+
+ The system will be configured to use dotnetup installs over any other installs.
+ The system will be configured to use dotnetup installs over any other installs.
+
+
+
+ Configure the current shell profile to use installs managed by dotnetup. Only applications launched from the shell will leverage dotnetup installs.
+ Configure the current shell profile to use installs managed by dotnetup. Only applications launched from the shell will leverage dotnetup installs.
+
+
+
+ Configure the current shell profile to use installs managed by dotnetup.
+ Configure the current shell profile to use installs managed by dotnetup.
+
+
+
+ You can run .NET commands with 'dotnetup dotnet <command>' (or 'dotnetup do <command>' for short).
+ You can run .NET commands with 'dotnetup dotnet <command>' (or 'dotnetup do <command>' for short).
+
+
+
+ PATH and DOTNET_ROOT have been updated. You can use 'dotnet' directly in new terminal sessions.
+ PATH and DOTNET_ROOT have been updated. You can use 'dotnet' directly in new terminal sessions.
+
+
+
+ Your shell profile has been updated. Restart your terminal or source your profile to use 'dotnet' directly.
+ Your shell profile has been updated. Restart your terminal or source your profile to use 'dotnet' directly.
+
+
+
+ Isolation Mode
+ Isolation Mode
+
+
+
+ Replacement Mode
+ Replacement Mode
+
+
+
+ Terminal Mode (Suggested)
+ Terminal Mode (Suggested)
+
+
+
+ Replacement Mode is not supported on this operating system. Please choose Isolation Mode or Terminal Mode instead.
+ Replacement Mode is not supported on this operating system. Please choose Isolation Mode or Terminal Mode instead.
+
+
+
+ Recommended for users without admin rights, or for users who want to keep their system managed installs (e.g. {0}) as the default.
+ Recommended for users without admin rights, or for users who want to keep their system managed installs (e.g. {0}) as the default.
+
+
+
+ Recommended for users who want all of their debuggers, applications, and IDEs to use dotnetup installs by default. Admin privileges are required. Enables 'cmd' functionality by default. Other user accounts on this machine will be impacted and applications may break.
+ Recommended for users who want all of their debuggers, applications, and IDEs to use dotnetup installs by default. Admin privileges are required. Enables 'cmd' functionality by default. Other user accounts on this machine will be impacted and applications may break.
+
+
+
+ Modify the current shell profile file to consume dotnetup on PATH environment variables. Recommended for users who want to develop using dotnetup and launch their IDE through the terminal.
+ Modify the current shell profile file to consume dotnetup on PATH environment variables. Recommended for users who want to develop using dotnetup and launch their IDE through the terminal.
+
+
+
+ Only PowerShell will work in this mode. CMD has no profile file.
+ Only PowerShell will work in this mode. CMD has no profile file.
+
+ .NET installation manager for user level installs..NET installation manager for user level installs.
@@ -67,6 +202,11 @@
dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry
+
+ Run the interactive setup walkthrough to install .NET and configure PATH settings.
+ Run the interactive setup walkthrough to install .NET and configure PATH settings.
+
+