Visual Studio Developer Powershell On Demand

Published on 2021-11-20

In this post I tell my story about how I use Visual Studio's Developer PowerShell in Windows Terminal, and how it evolved to having a custom function, Start-DeveloperPowerShell, to enter this environment from whatever PowerShell environment I'm currently in.

Go the end of this blog post for something you can copy-paste on your own computer.

Background

I work on NuGet, which is available in Visual Studio and msbuild.exe (which is obtained though the Visual Studio installation), in addition to the dotnet cli and nuget.exe. So, I need to frequently update Visual Studio.

I don't know if Windows Terminal will automatically get a "Visual Studio Developer PowerShell" profile if you install everything on a clean machine, but I'm still not getting one now, and it certainly didn't happen by default when I started this when Windows Terminal was still in preview.

Iteration 1: Add a Developer PowerShell profile to Windows Terminal

When I started this, Windows Terminal's settings was a json file that needed to be edited, but now there's a GUI making it much less error-prone. What we need to create a new Windows Terminal profile is the command line to run. To find how the start menu item works, open the start menu, search for "Developer PowerShell", and if you have multiple, make sure you see the one for the Visual Studio install that you want to use. Then right click it, and select "Open file location".

Image showing start menu search results, with a context menu over a search result named "Developer PowerShell for VS 2022 Int Preview", and the "Open file location" context menu item is highlighted

From here, right click on the "Developer PowerShell for ..." (again, if you see multiple shortcuts because you have multiple channels installed, select the one you want to use), and select properties. This will open Windows' file properties window, which will have a shortcut tab, probably open by default. Select all the text in the Target text box and copy it. On my machine the value is:

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

I'm not going to explain how PowerShell's argument parsing is different to what you might be used to on Linux, Mac, or other Windows commands. I'll just say that -noe is short for -NoExit, and -c is short for -Command, which tells powershell.exe to run a command, but don't exit, and give interactive usage to us. You can find out more by running powershell.exe -h.

Another thing to note is the random id passed to Enter-VsDevShell. It appears that the Visual Studio installer creates a random instance id each time you install a new channel. Therefore, you will need to modify this for whatever the value is on your own machine. And if you use multiple machines, it will be different on each, but more on that later.

Set up the rest of the Windows Terminal profile however you like, and save it. At this point I had made this profile the default, so every time I opened Windows Terminal, it would start with this profile.

Iteration 2: Run Enter-VsDevShell in my PowerShell profile

The first problem that annoyed me enough to change, I can no longer remember what was exactly, but for some reason I couldn't do it in Windows Terminal. I had to use some other shortcut or app, which didn't have msbuild.exe in the PATH.

PowerShell has a variable $PROFILE, which is the path and filename to the profile file, which gets loaded every time PowerShell starts up. Since the Developer PowerShell shortcut, and now my custom Windows Terminal profile, runs powershell.exe with a small script, we can copy the script into the $PROFILE file. After unescaping the string, and removing the scoping block, I'm left with two commands:

Import-Module "C:\Program Files\Microsoft Visual Studio\2022\IntPreview\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"; 
Enter-VsDevShell 3a8d04c1

Now, it doesn't matter where or how PowerShell is run, Visual Studio's development environment will be set up.

Iteration 3: Enter-DeveloperPowerShell as a function

The next problem that annoyed me enough to make another change is that the PowerShell module that is loaded is locked and cannot be overwritten. Since I work at Microsoft, on a component in Visual Studio, I need to update Visual Studio frequently. Since the dll is in use while the module is loaded, the VS installer would pause asking me to exit PowerShell to avoid needing a reboot to finish the VS install. Since my PowerShell profile would load the module by default, and this was my default Windows Terminal profile, I couldn't keep the Windows Terminal open while I updated Visual Studio.

The solution was simple since I didn't need msbuild.exe on the path all the time. In fact, most of the time I don't need it. But I do need it often enough to want a very simple way of getting it where I already am. Anyway, wrap the Import-Module and Enter-VsDevShell in a function declaration, then it won't run on PowerShell startup, and I only have to enter the function name to run it.

Before I show it, something I found annoying was each time I did this, the current directory would change to ~/source/repos. PowerShell has a command Get-Help, which allows you to see the parameters of any other PowerShell command, so Get-Help Enter-VsDevShell shows the various arguments. There are three overloads, but the one that takes a VS instance id is as follows.

Enter-VsDevShell [-VsInstanceId] [-SkipExistingEnvironmentVariables] [-StartInPath ] [-Arch {Default | x86 | amd64 | arm | arm64}] [-HostArch {Default | x86 | amd64}] [-DevCmdArguments ] [-DevCmdDebugLevel {None | Basic | Detailed | Trace}] [-SkipAutomaticLocation] [-SetDefaultWindowTitle] [-ReportNewInstanceType {PowerShell | Cmd | LaunchScript}] []

The argument -SkipAutomaticLocation sounds like what I want, so I changed my $PROFILE to:

function Start-DeveloperPowerShell {
  Import-Module "C:\Program Files\Microsoft Visual Studio\2022\IntPreview\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"; 
  Enter-VsDevShell 3a8d04c1 -SkipAutomaticLocation
}

If I have a console open with the dev shell, I still have to close it before I update VS, but that's unavoidable. All the times I'm not using the dev shell, it's not a problem.

Something to note is that if you use Visual Studio Code with Omnisharp, Omnisharp will open files in the Visual Studio install directory, and keep them open. So you will need to close VS Code before updating VS to avoid being told to restart your computer before you can run VS after updating. If you don't use the C# extension, or anything else that uses Omnisharp, it shouldn't be necessary to close VS Code when updating VS.

Iteration 4: Using different computers

I also need to use different computers from time to time. I have a desktop which I do most of my dev work on, but I have a laptop for when I need to be mobile, and I sometimes use virtual machines when I need to test something specific and don't want to change my dev box machine state.

Since the VS installer generates a random instance ID each time you install VS, and PowerShell's $PROFILE is in My Documents, and therefore gets mirrored to all my machines with OneDrive, it's just not suitable to have a static instance id in the profile.

Fortunately there's a tool named vswhere, which is open-source on github. It outputs many useful things about VS installations, including the instance id and installation directory. Due to my previous work on NuGet.Client's CI scripts, I already had Set-Alias vswhere -value "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" in my $PROFILE. For what it's worth, the VS installer doesn't provide customization as to where it gets installed (VS installations can be configured, but the installer itself cannot). Therefore, it's safe to hardcode the path to vswhere like this and as long as Visual Studio 2019 or above is installed (it was possibly included from some update in Visual Studio 2017), you know it will be there.

This next bit is something I really like about PowerShell over bash or any other shell. While on Linux there is a tool jq to parse and query json, as a developer, particularly a developer who is used to Object-Oriented Programming languages, it just makes more sense to me to keep an object as a variable, and then interact with that object just like I can in code or a REPL. PowerShell can parse JSON, and vswhere can output json. Finally, you need to pass a switch to vswhere to get prerelease versions, and you can pass another switch to tell it to use only the latest version, not return an array of all versions. Together we have $vs = & vswhere -latest -prerelease -format json | ConvertFrom-Json.

My current script

Putting all of the above together, this is what I have in my PowerShell $PROFILE file.

Set-Alias vswhere -Value "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"

function Start-DeveloperPowershell
{
    $vs = vswhere -prerelease -format json -latest | ConvertFrom-Json
    $installationPath = $vs.installationPath
    Import-Module "$installationPath\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
    Enter-VsDevShell $vs.instanceId -SkipAutomaticLocation
}

I also have more in my actual $PROFILE (posh-git, Set-PSReadLineKeyHandler, and dotnet-suggest). But the above can be copy-pasted into your own PS profile and will work on any machine where VS is installed.

If you don't yet have a PS profile file, or don't know what the path is, open PowerShell and enter $PROFILE. It will write the full filename. If you have VS Code installed, you can enter code $PROFILE to edit it, even if it does not yet exist. Note that PowerShell Core (6 and above) uses a different $PROFILE to Windows PowerShell (5.1 and earlier), so you may need to do this twice.

Now I can enter VS's dev shell from the Windows Terminal, or VS's code's terminal, or PowerShell ISE, or any other PowerShell prompt. I've been running like this for well over a year now, and am still quite happy with how it works.

It could be improved to take an optional argument, telling it which instance of VS to use. Probably by passing the path, because figuring out the instance id is a pain, and I haven't thought of any other deterministic way to handle cases when I have many VS instances installed. But I don't need to do this often enough to make it worth the effort yet.