Skip to content

Commit 46f95c2

Browse files
authored
Add dotnetup print-env-script command to generate shell environment configuration scripts (#52641)
2 parents 31c3b0f + 3fe1e18 commit 46f95c2

File tree

18 files changed

+974
-17
lines changed

18 files changed

+974
-17
lines changed

.vsts-pr.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pr:
1515
- CODEOWNERS
1616
- .vsts-dnup-ci.yml
1717
- .vsts-dnup-pr.yml
18+
- dotnetup.slnf
1819
- /eng/pipelines/templates/jobs/dotnetup/*
1920
- src/Installer/*
2021
- test/dotnetup.Tests/*
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# Unix Environment Setup for dotnetup
2+
3+
## Overview
4+
5+
This document describes the design for setting up the .NET environment via initialization scripts using the `dotnetup print-env-script` command. This is the first step toward enabling automatic user profile configuration for Unix as described in [issue #51582](https://github.com/dotnet/sdk/issues/51582). Note that this also supports PowerShell and thus Windows, but on Windows the main method of configuring the environment will be to set environment variables which are stored in the registry instead of written by initialization scripts.
6+
7+
## Background
8+
9+
The dotnetup tool manages multiple .NET installations in a local user hive. For .NET to be accessible from the command line, the installation directory must be:
10+
1. Added to the `PATH` environment variable
11+
2. Set as the `DOTNET_ROOT` environment variable
12+
13+
On Unix systems, this requires modifying shell configuration files (like `.bashrc`, `.zshrc`, etc.) or sourcing environment setup scripts.
14+
15+
## Design Goals
16+
17+
1. **Non-invasive**: Don't automatically modify user shell configuration files without explicit consent
18+
2. **Flexible**: Support multiple shells (bash, zsh, PowerShell)
19+
3. **Reversible**: Users should be able to easily undo environment changes
20+
4. **Single-file execution**: Generate scripts that can be sourced or saved for later use
21+
5. **Discoverable**: Make it easy for users to understand how to configure their environment
22+
23+
## The `dotnetup print-env-script` Command
24+
25+
### Command Structure
26+
27+
```bash
28+
dotnetup print-env-script [--shell <shell>] [--dotnet-install-path <path>]
29+
```
30+
31+
### Options
32+
33+
- `--shell` / `-s`: The target shell for which to generate the environment script
34+
- Supported values: `bash`, `zsh`, `pwsh`
35+
- Optional: If not specified, automatically detects the current shell from the `$SHELL` environment variable
36+
- On Windows, defaults to PowerShell (`pwsh`)
37+
38+
- `--dotnet-install-path` / `-d`: The path to the .NET installation directory
39+
- Optional: If not specified, uses the default user install path (`~/.local/share/dotnet` on Unix)
40+
41+
### Usage Examples
42+
43+
#### Auto-detect current shell
44+
```bash
45+
dotnetup print-env-script
46+
```
47+
48+
#### Generate and source in one command
49+
```bash
50+
source <(dotnetup print-env-script)
51+
```
52+
53+
#### Explicitly specify shell
54+
```bash
55+
dotnetup print-env-script --shell zsh
56+
```
57+
58+
#### Save script for later use
59+
```bash
60+
dotnetup print-env-script --shell bash > ~/.dotnet-env.sh
61+
# Later, in .bashrc or manually:
62+
source ~/.dotnet-env.sh
63+
```
64+
65+
#### Use custom installation path
66+
```bash
67+
dotnetup print-env-script --dotnet-install-path /opt/dotnet
68+
```
69+
70+
## Generated Script Format
71+
72+
The command generates shell-specific scripts that:
73+
1. Set the `DOTNET_ROOT` environment variable to the installation path
74+
2. Prepend the installation path to the `PATH` environment variable
75+
76+
### Bash/Zsh Example
77+
```bash
78+
#!/usr/bin/env bash
79+
# This script configures the environment for .NET installed at /home/user/.local/share/dotnet
80+
# Source this script to add .NET to your PATH and set DOTNET_ROOT
81+
82+
export DOTNET_ROOT='/home/user/.local/share/dotnet'
83+
export PATH='/home/user/.local/share/dotnet':$PATH
84+
```
85+
86+
### PowerShell Example
87+
```powershell
88+
# This script configures the environment for .NET installed at /home/user/.local/share/dotnet
89+
# Source this script (dot-source) to add .NET to your PATH and set DOTNET_ROOT
90+
# Example: . ./dotnet-env.ps1
91+
92+
$env:DOTNET_ROOT = '/home/user/.local/share/dotnet'
93+
$env:PATH = '/home/user/.local/share/dotnet' + [IO.Path]::PathSeparator + $env:PATH
94+
```
95+
96+
## Implementation Details
97+
98+
### Provider Model
99+
100+
The implementation uses a provider model similar to `System.CommandLine.StaticCompletions`, making it easy to add support for additional shells in the future.
101+
102+
**Interface**: `IEnvShellProvider`
103+
```csharp
104+
public interface IEnvShellProvider
105+
{
106+
string ArgumentName { get; } // Shell name for CLI (e.g., "bash")
107+
string Extension { get; } // File extension (e.g., "sh")
108+
string? HelpDescription { get; } // Help text for the shell
109+
string GenerateEnvScript(string dotnetInstallPath);
110+
}
111+
```
112+
113+
**Implementations**:
114+
- `BashEnvShellProvider`: Generates bash-compatible scripts
115+
- `ZshEnvShellProvider`: Generates zsh-compatible scripts
116+
- `PowerShellEnvShellProvider`: Generates PowerShell Core scripts
117+
118+
### Shell Detection
119+
120+
The command automatically detects the current shell when the `--shell` option is not provided:
121+
122+
1. **On Unix**: Reads the `$SHELL` environment variable and extracts the shell name from the path
123+
- Example: `/bin/bash``bash`
124+
2. **On Windows**: Defaults to PowerShell (`pwsh`)
125+
126+
### Security Considerations
127+
128+
**Path Escaping**: All installation paths are properly escaped to prevent shell injection vulnerabilities:
129+
- **Bash/Zsh**: Uses single quotes with `'\''` escaping for embedded single quotes
130+
- **PowerShell**: Uses single quotes with `''` escaping for embedded single quotes
131+
132+
This ensures that paths containing special characters, spaces, or shell metacharacters are handled safely.
133+
134+
## Advantages of Generated Scripts
135+
136+
As noted in the discussion, generating scripts dynamically has several advantages over using embedded resource files:
137+
138+
1. **Single-file execution**: Users can source the script directly from the command output without needing to extract files
139+
2. **Flexibility**: Easy to customize the installation path or add future features
140+
3. **No signing required**: Generated text doesn't require code signing, unlike downloaded executables or scripts
141+
4. **Immediate availability**: No download or extraction step needed
142+
5. **Transparency**: Users can easily inspect what the script does by running the command
143+
144+
## Future Work
145+
146+
This command provides the foundation for future enhancements:
147+
148+
1. **Automatic profile modification**: Add a command to automatically update shell configuration files (`.bashrc`, `.zshrc`, etc.) with user consent
149+
2. **Profile backup**: Create backups of shell configuration files before modification
150+
3. **Uninstall/removal**: Add commands to remove dotnetup configuration from shell profiles
151+
4. **Additional shells**: Support for fish, tcsh, and other shells
152+
5. **Environment validation**: Commands to verify that the environment is correctly configured
153+
154+
## Related Issues
155+
156+
- [Issue #51582](https://github.com/dotnet/sdk/issues/51582): Parent issue tracking user profile modification on Unix
157+
- [dotnet/designs dnvm-e2e-experience](https://github.com/dotnet/designs/tree/dnvm-e2e-experience/proposed): Design proposal for local .NET hive management
158+
159+
## Testing
160+
161+
The implementation includes comprehensive tests:
162+
- Parser tests for command validation
163+
- Shell provider tests for script generation
164+
- Security tests for special character handling
165+
- Help documentation tests
166+
167+
All tests ensure that the generated scripts are syntactically correct and properly escape paths.

dotnetup.slnf

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"src\\Installer\\dotnetup\\dotnetup.csproj",
66
"src\\Installer\\Microsoft.Dotnet.Installation\\Microsoft.Dotnet.Installation.csproj",
77
"test\\dotnetup.Tests\\dotnetup.Tests.csproj",
8-
"src\\Resolvers\\Microsoft.DotNet.NativeWrapper\\Microsoft.DotNet.NativeWrapper.csproj"
8+
"src\\Resolvers\\Microsoft.DotNet.NativeWrapper\\Microsoft.DotNet.NativeWrapper.csproj",
9+
"src\\Cli\\Microsoft.DotNet.Cli.Utils\\Microsoft.DotNet.Cli.Utils.csproj"
910
]
1011
}
1112
}

src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,13 @@ private void ExtractTarFileEntry(TarEntry entry, string targetDir, IProgressTask
310310
using var outStream = File.Create(destPath);
311311
entry.DataStream?.CopyTo(outStream);
312312
installTask?.Value += 1;
313+
314+
// On Unix platforms, set the file permissions after extraction
315+
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
316+
{
317+
File.SetUnixFileMode(destPath, entry.Mode);
318+
}
319+
313320
}
314321

315322
/// <summary>

src/Installer/dotnetup/ArchiveInstallationValidator.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ public static bool ComponentFilesExist(DotnetInstall install)
103103

104104
private bool ValidateWithHostFxr(string installRoot, ReleaseVersion resolvedVersion, InstallComponent component)
105105
{
106+
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
107+
{
108+
// Calling HostFxr is not working on Linux, so don't use it for validation until we fix that
109+
// See https://github.com/dotnet/sdk/issues/52821
110+
return true;
111+
}
112+
106113
try
107114
{
108115
var environmentInfo = HostFxrWrapper.getInfo(installRoot);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript;
5+
6+
public class BashEnvShellProvider : IEnvShellProvider
7+
{
8+
public string ArgumentName => "bash";
9+
10+
public string Extension => "sh";
11+
12+
public string? HelpDescription => "Bash shell";
13+
14+
public override string ToString() => ArgumentName;
15+
16+
public string GenerateEnvScript(string dotnetInstallPath)
17+
{
18+
// Escape single quotes in the path for bash by replacing ' with '\''
19+
var escapedPath = dotnetInstallPath.Replace("'", "'\\''");
20+
21+
return
22+
$"""
23+
#!/usr/bin/env bash
24+
# This script configures the environment for .NET installed at {dotnetInstallPath}
25+
# Source this script to add .NET to your PATH and set DOTNET_ROOT
26+
#
27+
# Note: If you had a different dotnet in PATH before sourcing this script,
28+
# you may need to run 'hash -d dotnet' to clear the cached command location.
29+
# When dotnetup modifies shell profiles directly, it will handle this automatically.
30+
31+
export DOTNET_ROOT='{escapedPath}'
32+
export PATH='{escapedPath}':$PATH
33+
""";
34+
}
35+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript;
5+
6+
/// <summary>
7+
/// Provides shell-specific environment configuration scripts.
8+
/// </summary>
9+
public interface IEnvShellProvider
10+
{
11+
/// <summary>
12+
/// The name of this shell as exposed on the command line arguments.
13+
/// </summary>
14+
string ArgumentName { get; }
15+
16+
/// <summary>
17+
/// The file extension typically used for this shell's scripts (sans period).
18+
/// </summary>
19+
string Extension { get; }
20+
21+
/// <summary>
22+
/// This will be used when specifying the shell in CLI help text.
23+
/// </summary>
24+
string? HelpDescription { get; }
25+
26+
/// <summary>
27+
/// Generates a shell-specific script that configures PATH and DOTNET_ROOT.
28+
/// </summary>
29+
/// <param name="dotnetInstallPath">The path to the .NET installation directory</param>
30+
/// <returns>A shell script that can be sourced to configure the environment</returns>
31+
string GenerateEnvScript(string dotnetInstallPath);
32+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript;
5+
6+
public class PowerShellEnvShellProvider : IEnvShellProvider
7+
{
8+
public string ArgumentName => "pwsh";
9+
10+
public string Extension => "ps1";
11+
12+
public string? HelpDescription => "PowerShell Core (pwsh)";
13+
14+
public override string ToString() => ArgumentName;
15+
16+
public string GenerateEnvScript(string dotnetInstallPath)
17+
{
18+
// Escape single quotes in the path for PowerShell by replacing ' with ''
19+
var escapedPath = dotnetInstallPath.Replace("'", "''");
20+
21+
return
22+
$"""
23+
# This script configures the environment for .NET installed at {dotnetInstallPath}
24+
# Source this script (dot-source) to add .NET to your PATH and set DOTNET_ROOT
25+
# Example: . ./dotnet-env.ps1
26+
27+
$env:DOTNET_ROOT = '{escapedPath}'
28+
$env:PATH = '{escapedPath}' + [IO.Path]::PathSeparator + $env:PATH
29+
""";
30+
}
31+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.CommandLine;
5+
6+
namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript;
7+
8+
internal class PrintEnvScriptCommand : CommandBase
9+
{
10+
private readonly IEnvShellProvider? _shellProvider;
11+
private readonly string? _dotnetInstallPath;
12+
private readonly IDotnetInstallManager _dotnetInstaller;
13+
14+
public PrintEnvScriptCommand(ParseResult result, IDotnetInstallManager? dotnetInstaller = null) : base(result)
15+
{
16+
_dotnetInstaller = dotnetInstaller ?? new DotnetInstallManager();
17+
_shellProvider = result.GetValue(PrintEnvScriptCommandParser.ShellOption);
18+
_dotnetInstallPath = result.GetValue(PrintEnvScriptCommandParser.DotnetInstallPathOption);
19+
}
20+
21+
public override int Execute()
22+
{
23+
try
24+
{
25+
// Check if shell provider was successfully determined
26+
if (_shellProvider == null)
27+
{
28+
var shellPath = Environment.GetEnvironmentVariable("SHELL");
29+
if (shellPath is null)
30+
{
31+
Console.Error.WriteLine("Error: Unable to detect current shell. The SHELL environment variable is not set.");
32+
Console.Error.WriteLine($"Please specify the shell using --shell option. Supported shells: {string.Join(", ", PrintEnvScriptCommandParser.SupportedShells.Select(s => s.ArgumentName))}");
33+
}
34+
else
35+
{
36+
var shellName = Path.GetFileName(shellPath);
37+
Console.Error.WriteLine($"Error: Unsupported shell '{shellName}'.");
38+
Console.Error.WriteLine($"Supported shells: {string.Join(", ", PrintEnvScriptCommandParser.SupportedShells.Select(s => s.ArgumentName))}");
39+
Console.Error.WriteLine("Please specify the shell using --shell option.");
40+
}
41+
return 1;
42+
}
43+
44+
// Determine the dotnet install path
45+
string installPath = _dotnetInstallPath ?? _dotnetInstaller.GetDefaultDotnetInstallPath();
46+
47+
// Generate the shell script
48+
string script = _shellProvider.GenerateEnvScript(installPath);
49+
50+
// Output the script to stdout
51+
Console.WriteLine(script);
52+
53+
return 0;
54+
}
55+
catch (Exception ex)
56+
{
57+
Console.Error.WriteLine($"Error generating environment script: {ex.Message}");
58+
return 1;
59+
}
60+
}
61+
}

0 commit comments

Comments
 (0)