Skip to content

Instantly share code, notes, and snippets.

@sharadhr
Last active April 7, 2024 14:47
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sharadhr/1a02267b9749d4300c3a07a801406459 to your computer and use it in GitHub Desktop.
Save sharadhr/1a02267b9749d4300c3a07a801406459 to your computer and use it in GitHub Desktop.
Silencing the Developer PowerShell

Silencing the Developer PowerShell

TL;DR

I used dnSpy to edit a CIL DLL library, Microsoft.VisualStudio.DevShell.dll to silence the banner displayed by a Developer PowerShell terminal. I also added the Developer PowerShell functionality to all my PowerShell terminals, so I can use cl.exe and clang.exe (and clang-cl.exe) from anywhere.

But two sentences do not a blog post make.


Introduction

On Linux and most Unixes (hereafter, *nix), getting a C or C++ compiler working is a straightforward affair: install it with the system package manager, navigate to some source code, and execute clang++ -std=c++20 main.cpp. Done.

Of course, Windows is the anti-Unix, so it has to be different. This StackOverflow question puts the problem in perspective: how does one get a command-line with a compiler pre-loaded on Windows, so the above clang command works equally as seamlessly? About a decade ago, this was a quite a painful problem. Visual Studio was not free; getting gcc or clang required having some sort of GNU-on-Windows environment like Cygwin, MSYS2, or MinGW. Setting any of these up was (and still is) fairly tedious, and they weren't very user-friendly. All of these involved unnecessarily complicated cross-compilation, environment management, and it was not straightforward to debug their behaviour or binaries written with them.

Things have changed significantly in the intervening years. Firstly, there is the Windows Subsystem for Linux, giving Windows users near-seamless access to a full Linux environment without ever needing to reboot, or faff about with virtual machines.

On the native side of things, Visual Studio now has a free Community Edition. It also has an option to install and use Clang. There's even a no-IDE command-line-only Build Tools. This is similar to Apple's Xcode command-line tools (of course, the latter is particularly user-friendly: users are automatically prompted to install it upon executing clang or gcc on a Mac, which is handy).

When Visual Studio (or the Build Tools) is installed, several shortcuts to new terminals are also created in the Start menu. Now, open Developer PowerShell for VS 2022, and both cl.exe and clang.exe (if it's installed) are available. Write:

cl.exe /std:c++20 main.cpp

or

clang.exe -std=c++20 main.cpp -o main.exe

or even1

clang-cl.exe /std:c++20 main.cpp

any of which will cough up main.exe in the same folder. Neat!

The Developer PowerShell has problems

While this is convenient, a fairly typical workflow is to cd2 to a project folder (or right-click in File Explorer, and Open in Terminal), and then run build commands. This is not easily served by the Developer PowerShell, for a few reasons:

  1. It is a separate terminal session, which doesn't inherit the current terminal's environment and working directory.

  2. The initial working directory is always %USERPROFILE%\repos.

  3. It always launches Windows PowerShell 5.1, instead of the newer, nicer, open-source PowerShell 7.

  4. It prints an ugly, noisy banner every time it is launched:

    **********************************************************************
    ** Visual Studio 2022 Developer PowerShell v17.7.4
    ** Copyright (c) 2022 Microsoft Corporation
    **********************************************************************
    >
    

Okay, that last one isn't really related to the first three; however, it's still annoying all the same, and getting rid of that is what made this blog post really interesting.

Preserving the environment

One usually hears about launching vcvars*.bat, which appears to call VsDevCmd.bat:

@REM rest of file...

:call_vsdevcmd

@REM This temporary environment variable is used to control setting of VC++
@REM command prompt-specific environment variables that should not be set
@REM by the VS Developer Command prompt (specifically vsdevcmd\ext\vcvars.bat).
@REM The known case this effects is the Platform environment variable, which
@REM will override platform target for .NET builds.
set VSCMD_VCVARSALL_INIT=1

@REM Special handling for the /clean_env argument
if "%__VCVARSALL_CLEAN_ENV%" NEQ "" (
    call "%~dp0..\..\..\Common7\Tools\vsdevcmd.bat" /clean_env
    goto :end
)

call "%~dp0..\..\..\Common7\Tools\vsdevcmd.bat" %__VCVARSALL_VSDEVCMD_ARGS%

@REM rest of file...

The problem is that vcvarsall.bat is a batch script, and batch is a considerable downgrade compared to PowerShell. Hence, I wanted to know exactly what Developer PowerShell was doing, in order to replicate it. Right-clicking on the Start menu shortcut and selecting Open file location displays the location of the Start menu shortcut; right-clicking on that and selecting Properties lists the following, under Target:

C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe -noe -c "&{Import-Module """C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"""; Enter-VsDevShell a55540f8}"

In other words, it launches Windows PowerShell 5.13, loads the Microsoft.VisualStudio.DevShell.dll Common Intermediate Language (CIL) library (hereafter, simply DevShell.dll), and calls the Enter-VsDevShell cmdlet with the argument a55540f8.

In the same directory (C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools), I noticed a nice PowerShell script, Launch-VsDevShell.ps1, which also calls into DevShell.dll. This script provides a bunch of nice parameters that abstract over the DLL. This DLL is—to my knowledge—not documented.

The comment at the beginning of the script4 reads:

<#
.SYNOPSIS Launch Developer PowerShell
.DESCRIPTION
Locates and imports a Developer PowerShell module and calls the Enter-VsDevShell cmdlet.
The Developer PowerShell module is located in one of several ways:
  1) From a path in a Visual Studio installation
  2) From the latest installation of Visual Studio (higher versions first)
  3) From the instance ID of a Visual Studio installation
  4) By selecting a Visual Studio installation from a list

By default, with no parameters, the path to this script is used to locate the Developer PowerShell module.
If that fails, then the latest Visual Studio installation is used.
If both methods fail, then the user can select a Visual Studio installation from a list.
.PARAMETER VsInstallationPath
A path in a Visual Studio installation. The path is used to locate the Developer PowerShell module.
By default, this is the path to this script.
.PARAMETER Latest
Use the latest Visual Studio installation to locate the Developer PowerShell module.
.PARAMETER List
Display a list of Visual Studio installations to choose from.
The choosen installation is used to locate the Developer PowerShell module.
.PARAMETER VsInstanceId
A Visual Studio installation instance ID. The matching installation is used to locate the Developer PowerShell module.
.PARAMETER ExcludePrerelease
Excludes Prerelease versions of Visual Studio from consideration. Applies only to Latest and List.
.PARAMETER VsWherePath
Path to the vswhere utility used to located and identify Visual Studio installations.
By default, the path is the well-known location shared by Visual Studio installations.
#>

From reading the rest of the code, -Verbose and SkipAutomaticLocation are also (undocumented) parameters. With -Verbose, it turns out that a55540f8 is actually passed to VsInstanceId.

This means I could just run this script from my $PROFILE (which, for PowerShell 7.3, is in shell:Documents\PowerShell\Microsoft.PowerShell_profile.ps1)5:

. "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Launch-VsDevShell.ps1" -Arch amd64 -SkipAutomaticLocation

This solves (1), (2), and (3) in the list above!

Furling the banner

cl.exe has a /nologo argument to force it to stop printing a copyright banner. There's no such thing for DevShell.dll, nor any documentation whatsoever, so I turned to de-compiling it.

Three right turns

Enter dnSpy. It can launch, attach to, and debug any running .NET (Framework or Core) process, load and introspect/de-compile .NET assemblies (both library and executable) into C#, Visual Basic (VB), or CIL, can edit and save said assembly, and even export de-compiled output as Visual Studio solutions. It can launch, attach to, and debug any running .NET (Framework or Core) process, load and introspect/de-compile .NET assemblies (both library and executable) into C#, Visual Basic (VB), or CIL, can edit and save said assembly, and even export de-compiled output as Visual Studio solutions. It has a call-stack viewer, a thread viewer, and users are able to edit the values of variables on-the-fly.

I loaded DevShell.dll into dnSpy. Strangely, searching for repeated asterisks (as in the banner above) yielded no results whatsoever. In fact, there's no sign of the banner string in the assembly at all. However, I noticed an interesting-looking method, EndProcessing(). A snippet is below:

// more code...
ProcessStartInfo processStartInfo = new ProcessStartInfo("cmd.exe", text2)
{
    CreateNoWindow = true,
    UseShellExecute = false,
    RedirectStandardOutput = true
};
if (this.DevCmdDebugLevel == DevCmdDebugLevelValue.None && processStartInfo.EnvironmentVariables.ContainsKey("VSCMD_DEBUG"))
{
    processStartInfo.EnvironmentVariables.Remove("VSCMD_DEBUG");
}
if (this.DevCmdDebugLevel != DevCmdDebugLevelValue.None)
{
    processStartInfo.EnvironmentVariables["VSCMD_DEBUG"] = ((int)this.DevCmdDebugLevel).ToString();
}
processStartInfo.EnvironmentVariables["VSCMD_SKIP_SENDTELEMETRY"] = "1";
processStartInfo.EnvironmentVariables["VSCMD_BANNER_SHELL_NAME_ALT"] = "Developer PowerShell";
base.WriteVerbose("Starting the cmd.exe process...");
using (Process process = Process.Start(processStartInfo))
{
    StringBuilder stringBuilder = null;
    string text3;
    while ((text3 = process.StandardOutput.ReadLine()) != null)
    {
        if (EnterVsDevShellCommand.ErrorOutputRegex.IsMatch(text3))
        {
            if (stringBuilder == null)
            {
                stringBuilder = new StringBuilder();
            }
            stringBuilder.AppendLine(text3);
        }
        else if (EnterVsDevShellCommand.DebugOutputRegex.IsMatch(text3))
        {
            base.WriteVerbose(text3);
        }
        else
        {
            Match match = EnterVsDevShellCommand.EnvVarRegex.Match(text3);
            if (match.Success)
            {
                string value = match.Groups[1].Value;
                string text4 = "Env:" + value;
                string value2 = match.Groups[2].Value;
                if (base.InvokeProvider.Item.Exists(text4) && this.SkipExistingEnvironmentVariables.IsPresent)
                {
                    base.WriteVerbose("Skipping existing environment variable: " + value);
                }
                else
                {
                    base.InvokeProvider.Item.Set(text4, value2);
                }
            }
            else
            {
                base.WriteObject(text3);
            }
        }
    }
    string text5 = "Env:VSCMD_BANNER_SHELL_NAME_ALT";
    if (base.InvokeProvider.Item.Exists(text5))
    {
        base.InvokeProvider.Item.Remove(text5, false);
    }
    string text6 = "Env:VSCMD_SKIP_SENDTELEMETRY";
    if (base.InvokeProvider.Item.Exists(text6))
    {
        base.InvokeProvider.Item.Remove(text6, false);
    }
    if (stringBuilder != null)
    {
        base.WriteError(new ErrorRecord(new Exception(stringBuilder.ToString()), "DevCmdError", ErrorCategory.NotSpecified, null));
    }
    process.WaitForExit();
    process.Close();
}
// more code...

As mentioned above, this is de-compiled output, so variables names are rather unintuitive. However, the control flow is clear enough.

The snippet above is essentially:

  1. launching cmd.exe with command-line parameters in text2;
  2. reading the piped standard output from said process line-by-line into text3;
  3. parsing that standard output and setting environment variables, or re-writing that to standard output.

I set a breakpoint at the first line above, launched a pwsh.exe instance, attached dnSpy, and sourced $PROFILE just to send it through the assembly again.

It turns out text2 was @"/c ""C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\VsDevCmd.bat"" -arch=amd64 && set". So, all this ceremony just to go back to VsDevCmd.bat again. Within the .bat file itself is the following function, and finally the banner culprit:

@REM ------------------------------------------------------------------------
:print_vscmd_header

@REM Allow other Visual Studio developer shells to override just the shell name in the banner text
if "%VSCMD_BANNER_SHELL_NAME_ALT%"=="" (
    set "__VSCMD_BANNER_SHELL_NAME=Developer Command Prompt"
) else (
    set "__VSCMD_BANNER_SHELL_NAME=%VSCMD_BANNER_SHELL_NAME_ALT%"
)

@REM Allow other Visual Studio command prompts to override the banner text
if "%VSCMD_BANNER_TEXT_ALT%"=="" (
    set "__VSCMD_BANNER_TEXT=Visual Studio 2022 %__VSCMD_BANNER_SHELL_NAME% v%VSCMD_VER%"
) else (
    set "__VSCMD_BANNER_TEXT=%VSCMD_BANNER_TEXT_ALT%"
)

if "%VSCMD_ARG_no_logo%"=="" (
    @echo **********************************************************************
    @echo ** %__VSCMD_BANNER_TEXT%
    @echo ** Copyright ^(c^) 2022 Microsoft Corporation
    @echo **********************************************************************
)

set __VSCMD_BANNER_TEXT=
set __VSCMD_BANNER_SHELL_NAME=
exit /B 0

Editing the DLL

Notice the VSCMD_BANNER_SHELL_NAME_ALT environment variable being set for the ProcessStartInfo object, manipulated in the .bat file, and thereafter printed within the banner. There's also a test for VSCMD_ARG_no_logo, but for some reason, this is not exposed by the DLL's PowerShell cmdlet interface. Therefore, the fix appeared straightforward: either delete the @echos above, or set the following:

processStartInfo.EnvironmentVariables["VSCMD_ARG_no_logo"] = "";

below line 177 in the image above.

Unfortunately, neither would work, because there would be no stdout from VsDevCmd.bat, and hence the while ((text3 = process.StandardOutput.ReadLine()) != null) block completely missed, which contains the actual environment variable-setting mechanism (inside the if (match.Success) block`).

The next best solution is to delete the following block:

else
{
    base.WriteObject(text3);
}

from the end of the loop. When I tried doing just that, dnSpy complained about missing types:

which is fair, since the .NET compiler is free to elide types, names, and otherwise optimise the code when generating CIL.

This left me with editing the IL directly. dnSpy excels at this: select a line or block of code, right-click, and select Edit IL instructions. A window pops up with the corresponding CIL instructions for the selected block, and users can edit them directly:

In text, the highlighted block is:

ldarg.0
ldloc.s V_9 (9)
call instance void [System.Management.Automation]System.Management.Automation.Cmdlet::WriteObject(object)

There's another bugbear here: simply deleting these instructions also wouldn't work, since the stack would be unbalanced. A better solution is to replace the three instructions above with nops (i.e. no-operations), and hence do not affect the stack. There's a right-click shortcut in dnSpy for this, too. With this, I could compile the edited assembly, and replace the original DevShell.dll with the edited one.

I now have a PowerShell terminal that quietly supports native and .NET development without extra faff, exactly like in *nix.

Footnotes

  1. clang-cl is a cl.exe-compatible driver for clang.exe, so it can be used in place of cl.exe in most cases. See the manual for more information.

  2. In idiomatic PowerShell, use sl, which is an alias for Set-Location.

  3. This is the older closed-source Windows PowerShell 5.1 written in .NET Framework 4.X. Windows Powershell is powershell.exe, whereas the cross-platform PowerShell Core is pwsh.exe, more in line with its *nix friends.

  4. The script in full is reproduced here.

  5. shell:Documents is my own short-hand to refer to the Documents library, which is not necessarily at %USERPROFILE%\Documents.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment