microsoft / winget-cli

WinGet is the Windows Package Manager. This project includes a CLI (Command Line Interface), PowerShell modules, and a COM (Component Object Model) API (Application Programming Interface).
https://learn.microsoft.com/windows/package-manager/
MIT License
22.96k stars 1.43k forks source link

Installing a package doesn't add it it to path #549

Open matifali opened 4 years ago

matifali commented 4 years ago

Brief description of your issue

Installing a package doesn't add it it to path

Steps to reproduce

  1. Install Vim using winget install Vim
  2. Try to run it using Vim test.txt

Expected behavior

Vim should open the file if it exist or create a new file.

Actual behavior

Vim : The term 'Vim' is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling of the name, or if a path was included,
verify that the path is correct and try again.
At line:1 char:1
+ Vim .\test.txt
+ ~~~
    + CategoryInfo          : ObjectNotFound: (Vim:String) [], CommandNotFoundException
    + FullyQualifiedErrorId : CommandNotFoundException

Environment

Copyright (c) Microsoft Corporation. All rights reserved.

Windows: Windows.Desktop v10.0.19041.450
Package: Microsoft.DesktopAppInstaller v1.10.42241.0
denelon commented 1 year ago

@voronoipotato I'm not familiar with neovim. If you download and "run" the installer, does it add itself to the path? I've seen a couple of cases where the "interactive" installer has an option to add the directory to the path, but when run "silently" it doesn't. I'm not sure if that's the case here or not. If it's just a switch that needs to be passed, the manifest can be updated to include that switch for "silent" and "silent with progress" variations of install.

I just ran winget install neovim in Windows Terminal (PowerShell) in user mode. I agreed to the UAC prompt. After it succeeded I closed and reopened Windows Terminal, and it appears to have worked.

nvim
voronoipotato commented 1 year ago

@denelon Ah, I closed the individual tab of windows terminal without closing the whole program. Very strange behavior but that worked, thank you. I had expected that each tab ran independently. I wonder how many other people here ran into the same snag.

BatmanAoD commented 1 year ago

@Masamune3210 That's technically not quite the same issue, since the screenshot you show is for installing Chocolatey itself. Once you've installed it, it provides a function (aliased as refreshenv) to reload your env vars, and the installation text output prompts the user to run it after something new has been installed.

FWIW, from a user perspective, I have no problem with junctions. It's a bit annoying that they require admin permissions to create (IIRC), but otherwise I've been using them without issue since...probably 2015ish, I think.

safakgur commented 1 year ago

@Victor-N-Suadicani Why is winget different in this respect to practically all other package managers? Every user is going to expect winget to add the package executable to the path. Not doing that is tantamount to a bug, especially from the perspective of a user.

I think the cause of confusion here is the fact that winget supports multiple installer types. When you have a non-portable app (e.g. msi, inno), it is the installer's responsibility to add what's necessary to PATH. The entire point of an installer is to setup the app completely, including updating the PATH if needed. Even if winget wanted to work around that, some installers are just black boxes where retrieving the installation directory itself is a challenge.

The situation is different when you have a portable app (e.g. portable, zip) since if the app is portable, then winget itself becomes the installer, which means it's now the winget manifest's responsibility to know which executables to make available in PATH. Portable app support of winget is relatively new (#182), and according to the following comment, it now supports adding the new app to PATH (technically, it uses shims like Scoop instead of touching PATH with every installation, but the result is the same):

@denelon (in original thread) ...Windows Package Manager 1.3 release candidate use symbolic links for portable applications to avoid cluttering a user's path environment variable...

Implementation-wise, I believe shims are indeed the way to go as using them doesn't require the PATH to be updated with every app and it also makes the new app immediately available without restarting the shell. The use of symbolic links on the other hand sounds a bit odd considering they require elevated access to create, which is why Scoop uses junctions instead (here's a related complaint: #2802).

Also, I can't find anything in the schema regarding defining what gets a shim when you have multiple executables you want (or explicitly do not want) in PATH.


The tldr; is making applications available in PATH (with or without shims) is winget's responsibility only for portable apps. If a non-portable app (an app with an installer) needs to be called from the console, then its installer needs to update PATH accordingly.

I appreciate that this would sound more complicated than necessary to folks coming from Linux. Unfortunately, Windows app ecosystem has been all about GUIs and installers where you had to keep clicking "next" until something is installed, mostly due to the lack of good CLI apps and package managers. So, it's now up to the app creators and the community to add proper support for winget. App creators can either fix their installers to add their CLI apps to PATH, or better yet, publish their apps as portable binaries (single exe or zipped) so that winget could automatically make the app available after installation.

What winget needs to do. I think, is extend the manifest so we can specify which executables we want (all? some? none?) in PATH, but again, this only applies to portable apps where winget itself acts as the installer.

Victor-N-Suadicani commented 1 year ago

I think the cause of confusion here is the fact that winget supports multiple installer types. When you have a non-portable app (e.g. msi, inno), it is the installer's responsibility to add what's necessary to PATH.

I'm honestly surprised winget bothers supporting non-portable apps. Why is that? I would not expect winget to support msi installers, yet it does. This is surprising. If I wanted to install something with an msi installer, I would expect that I would have to run it manually. I wouldn't want winget to run an installer like that for me.

Okeanos commented 1 year ago

I think the cause of confusion here is the fact that winget supports multiple installer types. When you have a non-portable app (e.g. msi, inno), it is the installer's responsibility to add what's necessary to PATH.

I'm honestly surprised winget bothers supporting non-portable apps. Why is that? I would not expect winget to support msi installers, yet it does. This is surprising. If I wanted to install something with an msi installer, I would expect that I would have to run it manually. I wouldn't want winget to run an installer like that for me.

On the contrary! I think it's quite nice that multiple installer types are supported because that way I can actually use winget to install nearly everything and even provide invocation flags for the installers to fully automate them. See for example here what I mean.

Victor-N-Suadicani commented 1 year ago

I feel that the main purpose of a package manager is to provide portable packages, not to automate or configure installers. Perhaps this functionality should have been kept behind a flag or something to avoid the confusion that occurs when users install something that then isn't actually available in the command line once installation is complete.

voronoipotato commented 1 year ago

@Okeanos It would be a good idea if it worked that way out of the box. It's the only package manager I've ever seen where you must provide bespoke override flags for individual packages to install as expected. Packages should work without --override or they should be removed until they are fixed.

Okeanos commented 1 year ago

Any package manager I can currently think of (homebrew, apt, yum, dnf, …) allows installing complex, non-portable software. Even Scoop and Chocolatey allow that.

The overrides and additional flags I use for e.g. Git and Firefox in my example I use by choice because I wanted to maximise automation for my use case. If I wanted to do non-default things with other package managers I would have to similarly provide instructions. So yes, I add the completely optional flags so it installs "as expected". That is expected by me. However, my expectations are likely very different from other people's expectations. Take the Git example – there's a huge number of flags and settings users are asked to modify and choosing the "right" one for everybody is effectively impossible. winget will defer to the vendor defaults during installation if nothing else is specified and "just work". If you want different defaults that'd be on the original installer's maintainers, not winget.

bparkin1283 commented 1 year ago

yigitemres commented Aug 25, 2020

What happened to @yigitemres's idea? This is where everyone is correct but too far into the infrastructural weeds.

Some simple interface emitting to the effect of "winget could not confirm an installation or path location and it's possible the installer cannot either, here are some options and info, ie run THIS COMMAND and restart your terminal"

I heard @denelon talk about how a design goal was to make it easy and inviting for package maintainers - how about the same courtesy for users of the tool? and by users, I mean at the API level. I think we are okay with some flag argument configuration, but this seems like an easy win.

I think it's hilarious there's comments about "MS is different than linux", lol then why make the tool at all? it's clearly a clone of Apt/etc (which is a good thing!).

denelon commented 1 year ago

Hey all, I've been talking with the team about adding some additional information after installing a portable package. The behavior depends on a few different factors. We're looking at better messaging as a hint to let the user know if they need to restart their shell/terminal or not.

In most cases, the first portable install will require the shell/terminal to be restarted. For portable packages installed in user mode (and developer mode disabled) the shell/terminal restart will most likely be required. For subsequent portable packages installed in admin mode the shell/terminal restart will most likely not be required. For subsequent portable packages installed with developer mode enabled the shell/terminal restart will most likely not be required.

In this example (Vim), and with many "installers"; restarting the shell/terminal will be necessary. In many cases with upgrade (assuming it's an upgrade to an existing package and not a side-by-side installation); restarting the shell/terminal will not be necessary.

We don't have a good reliable mechanism for dealing with a refresh of the Path environment variable as we could be called by any number of shells that all deal with environment variables a bit differently, and we aren't given the context necessary to figure out exactly what needs to be done in all cases.

We do have additional affordances possible with PowerShell and the WinGet cmdlets that may make it better, but that's not the case with cmd.exe and many others.

billwert commented 1 year ago

@denelon I think I may be hitting a flavor of this. I just installed ripgrep (winget install ripgrep). Even after restarting wt/pwsh, it's still not on my path. I do however see it in my user environment variable's PATH. Any idea what gives?

denelon commented 1 year ago

Do you have developer mode enabled on your machine? Did you install via administrator or user (Terminal, PowerShell, CMD.exe).

If it appears in your user environment path, it most likely means you didn't have developer mode enabled or use an administrator shell.

I was going to test, but I get two results. Which did you install?

winget search ripgrep
Name         Id                      Version Source
----------------------------------------------------
RipGrep MSVC BurntSushi.ripgrep.MSVC 13.0.0  winget
RipGrep GNU  BurntSushi.ripgrep.GNU  13.0.0  winget
billwert commented 1 year ago

@denelon I didn't have dev mode enabled, but doing so didn't change it. I installed it as a user in a non-admin windows terminal instance using powershell.

winget install BurntSushi.ripgrep.MSVC

The first time I tried this it added the full path to %PATH%:

C:\Users\$NAME\AppData\Local\Microsoft\WinGet\Packages\BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe\ripgrep-13.0.0-x86_64-pc-windows-msvc\rg.exe

This time it added a symlink here:

C:\Users\$NAME\AppData\Local\Microsoft\WinGet\Links

But when I restart the shell it still isn't at the end of the path, either in wt or cmd.

Ah. I wonder if this is tripping on needing a logout/in for user environment changes? That's annoying if so since system path just works..

denelon commented 1 year ago

The critical timing for the symbolic link creation is when you install the package. Once it's installed, it should either be in the path with the symbolic links, or as a path entry in the environment variable. Unfortunately, a new terminal Window isn't sufficient to get the environment. The entire Windows Terminal needs to be restarted if you're using Windows Terminal.

zeratax commented 1 year ago

@billwert interesting that my winget (version v1.5.441-preview) doesn't do either of those things lol. it added C:\Users\username\AppData\Local\Microsoft\WinGet\Packages\BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe\ to my PATH, which obviously isn't the correct path since, the executable is one level deeper in ripgrep-13.0.0-x86_64-pc-windows-msvc.

I also wonder when those symlinks are created. it did for yt-dlp, but I installed that with an older version of winget, so maybe that's why?

mdanish-kh commented 1 year ago

@zeratax That is related to https://github.com/microsoft/winget-cli/issues/2909 and the fix for it was merged in https://github.com/microsoft/winget-cli/pull/3002. The fix will likely appear in the next release for WinGet.

AhmadHdr commented 1 year ago

I think you need to see the product by

winget search vim

Then you choose the product that you like to install, this applies to search any other application

rickdgray commented 1 year ago

I would really like some movement on this. This issue defeats the point; if I cannot immediately use a command after installing with winget, then I'm not even going to waste the time. It's easier for me to just click through an msi installer.

Masamune3210 commented 1 year ago

...You cant use a command after installing it through a msi either. The issue isn't just with winget not adding to the path, the issue also lies with Terminal, Powershell, and the legacy command prompt all also not adhering to any new path changes due to only picking up environment blocks at launch and having no automatic way to refresh it if changed.

doctordns commented 1 year ago

This has been a challenge for decades. If you are in a Windows process (eg in Powershell), and run an MSI that adds to the Path environment variable,, unless you do something yourself, your copy of that environment variable;e is not updated. You just restart the process (eg PowerShell).

voronoipotato commented 1 year ago

I mean it's not impossible to do, Winget could do it.

Masamune3210 commented 1 year ago

It's not a winget issue though. Anything done would be a hack at best. Terminal already has a issue open about refreshing the environment block iirc

doctordns commented 1 year ago

If it were easy, we;d have had a solution years (decades?) ago. I would argue winget is working as designed and this is not a bug.

Hathoute commented 1 year ago

If it were easy, we;d have had a solution years (decades?) ago. I would argue winget is working as designed and this is not a bug.

I agree with this. I forgot how powershell and terminals in windows are supposed to work, hence why I (and probably most people) got here.

voronoipotato commented 1 year ago

It's not a winget issue though. Anything done would be a hack at best. Terminal already has a issue open about refreshing the environment block iirc

@Masamune3210 A hack would be vastly better than the abysmal and confusing experience we have. If you're going to pass the buck to a different team, you should link to the related issue. The closest I could find was an improvement for refreshing environment variables, which could be useful to us in implementing a fix but the fix needs to happen from winget's side. Most of the time people are not refreshing the environment variables, so it does not make much sense to do it constantly. Additionally the proposed solution of kicking the responsibility to terminal does nothing for people using windows server.

https://github.com/microsoft/terminal/issues/15102

@doctordns This is almost always false. Many problems are not addressed not because they are hard, but because they are lower priority. Software development has a triage process and breaking bugs and issues get first priority. This can mean that important but not required usability improvements go unaddressed, and if long enough even forgotten. User experience issues tend to be the items that are disruptive to use, but nonetheless never get fixed. We blame the users for being incompetent and move on. This sounds great until we ourselves are the users, and we ourselves have to remember little strange quirks for 80 different small applications that we use once every 3 years. I could understand that maybe it doesn't refresh env on git-bash or whatever but it should definitely work on powershell.

# too hard??? Really????
$Env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine")

@Hathoute If your users are routinely falling into a confusing trap, you should fix it. Winget in windows is supposed to work in an unsurprising way. This behavior is very surprising, as this thread indicates. We can say this is how it does work, but it is not supposed to work this way.

zadjii-msft commented 1 year ago

Also, FWIW, https://github.com/microsoft/terminal/issues/15102 is not the solution to this issue. That's tracking a perf improvement to the way that Terminal reloads env variables in 1.18+. In 1.18, the Terminal will reload environment variables when making a new tab (so you'll no longer need to quit out of the Terminal and restart). You'll be able to just open a new tab (or restart the connection).

But ultimately, there's no way for the Terminal to force a shell (cmd, pwsh) to update its own environment variables.

jazzdelightsme commented 1 year ago

@voronoipotato :

# too hard??? Really???? $Env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine")

Even supposing that were possible (you would have to do that to a different process (the shell process)), you would be surprised how much stuff that would break: there are many systems out there that depend on environment customization from the parent process (so reading fresh values straight from the registry would wipe those out).

So what you would have to do is compute a diff: read the Machine and User values straight from the registry, do the install, read the values from the registry again, and compute the diff (and assume that if anything else was messing with the registry values concurrently, "it's fiiiine"). But then you still have the problem of adding the new stuff to the correct shell process.

However, your posting of PS script gives me an idea of a hack/workaround that may be both "good enough", and not so hacky/gross as to disqualify it as a legit step forward:

What if winget.exe also shipped with a winget.ps1 file next to it? When in powershell, if a user were to run winget, they by default would get the .ps1 before the .exe... so then the .ps1 could handle doing the path diff'ing and apply it in-process... @denelon, what do you think of that?

rickdgray commented 1 year ago

If it were easy, we;d have had a solution years (decades?) ago. I would argue winget is working as designed and this is not a bug.

Oh it's too difficult to solve. OK, nevermind then; didn't realize this would be hard to solve. Let's just forget the issue and deal with it then.

denelon commented 1 year ago

@jazzdelightsme,

That's an interesting idea. It's something we could experiment with.

The real challenge is the App Execution Alias for winget might get resolved before the .ps1. If we're running a PowerShell cmdlet we might be able to do that more cleanly than in the CMD.exe shell.

jazzdelightsme commented 1 year ago

The real challenge is the App Execution Alias for winget might get resolved before the .ps1.

Ugh, I was thinking of resolution preference between e.g. .ps1 and .cmd (where .ps1 would win), but you're right that it would be a problem for .exe versus .ps1; my bad.

But having a winget command exported from a PowerShell module would take preference. It would have to be a "simple function" (as opposed to an "advanced function") so as to be able to function as a completely transparent stand-in for the winget.exe command.

However I think it is still worth considering dealing with %PATH% order, in order to also provide a similar .cmd solution for cmd.exe users. I.e. if we put winget.ps1 and winget.cmd files into C:\windows\system32, they should be ahead of the windowsapps alias EXEs on the PATH; and then if a user just says "winget" (instead of "winget.exe") then the right thing would magically happen in either shell. (Nobody likes putting more stuff into system32, but I think it is justified in this case.)

Masamune3210 commented 1 year ago

Could the exe possibly detect what terminal its running in and call into the .ps1 if it detects powershell?

denelon commented 1 year ago

Sadly, no. There is a distinction between the shell used to communicate with the kernel and the terminal used for rendering input/output. The winget.exe cli is being called in a process, and it doesn't have a reliable way to look up and see what called it. There are special cases that allow us to know when the PowerShell cmdlets are being invoked by virtue of the code being in a PowerShell module so that may be one area where we can relatively easily enlighten ourselves.

voronoipotato commented 1 year ago

I'd be okay with a minimum viable product of "it works with powershell" , with cmd fix provided later. I could tell my team, "Hey this is a temporary thing, but the experience is less confusing if you use it in powershell". It's not perfect but as a workaround for the immediate term it would still be very valuable. Thank you @denelon for being consistently supportive.

asilverman commented 1 year ago

First, thank you so much for all the efforts made in this space. I want to share my opinion about this. As a first principle, making the package manager seamlessly install and make available the applications is its fundamental main use-case. Anything that hinders or adds obstacles to attain this goal is a barrier for adoption and a risk to the success of the project.

Think about your own out of the box experiences and how they affect your perception of a product.

I want to contribute to this conversation with some technical details that can assist with the implementation of such a feature:

To check if you are running on CMD, Windows Powershell or Powershell 7

(dir 2>&1 *`|echo CMD);&<# rem #>echo ($PSVersionTable).PSEdition
# Returns one of: CMD, Core, Desktop

image

To update the PATH variable from withing a CMD prompt

MSDN says: To programmatically add or modify system environment variables, add them to the HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager\Environment registry key, then broadcast a WM_SETTINGCHANGE message with lParam set to the string "Environment".

This allows applications, such as the shell, to pick up your updates.

To reload the environment variables in powershell

$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") 

To set the PATH variable in all unix systems

export PATH=$PATH:the/file/path
set PATH=$PATH:the/file/path
asilverman commented 1 year ago

The current behavior is not customer friendly since it doesn't even show a warning about this issue, I would recommend as a stop-gap to add a yellow warning with the description of the current behavior (which is not to set the PATH) and describe in detail what are the manual steps that must be taken to overcome this pain

florelis commented 1 year ago

As a bit of an explanation of why this is an issue with winget that other package managers don't have:

Unlike other package managers, winget doesn't have a package format like .deb, .rpm or .msix. Instead, what winget uses are the existing installers for applications, like .msi or .exe files. I remember seeing an issue that it shouldn't be called a "package manager" because of that.

With a package file we know the contents of and we manually place on the system, it's relatively easy to add a single directory to the path the first time, then put links there pointing to the known location of the installed executable. Then everything works right after installing since the new file is already on a directory on the path. And this is actually what happens with portable apps in winget. But with an existing installer, we'd have to know if the installer needs something added to the PATH, whether it does it on its own, and then re-load the environment to have the PATH changes take place.

Another issue is that on Linux there are more standard paths that are already on the PATH, like /bin/, so you just put something there and you don't even need to edit the PATH. But on Windows, apps tend to go under C:\Program Files\<app>\, so you need to add the specific app to the PATH. (Though this wouldn't be a problem if we had actual packages.)

Doing things this way does cause problems, like this, or not being able to ensure clean uninstalls. On the other hand, it lets us work with existing apps without having the publishers repackage them. It also lets us manage apps that one has already installed on the machine, instead of only the ones installed through winget.

BTW, Windows already has a package manager/package format that guarantees a lot of the stuff one would want, for example clean uninstalls or having commands immediately available. That is MSIX, but it requires some effort to re-package an app into that format, so many popular apps are not available in that format.

jazzdelightsme commented 1 year ago

Here's a full write-up of the key design constraints that I observe for a solution to automagically update the PATH environment variable in a user's shell, along with my suggested approach.

TL;DR: if you want to try my suggested solution, put the files at the bottom of this post into your C:\windows\system32 directory, and as long as you always say "winget" instead of "winget.exe", things will probably work as you expect (environment will be updated) in cmd.exe, powershell.exe, and pwsh.exe.

Also, first some important caveats:

  1. I do not work on the winget team, nor do I have special visibility into their scheduling/priorities. I believe the standard place to point to for the question "when will this be addressed?" is this roadmap page and the milestones page.
  2. They may choose a different approach (though it will necessarily be constrained as described below), and they may face additional design constraints or challenges (for example, though you can try my solution by dropping a few scripts into your system32 folder, I'm pretty sure the DesktopAppInstaller package can be installed without elevation, so they likely will not want to use that location). Though my proof of concept works great for things like nvim, I know that they face much more complicated situations/packages, too.
  3. There may be additional work for them to do; for example, to figure out how to expose this as an experimental feature in order to evaluate the impact on a very large and diverse ecosystem.

With all that said, let's jump in.

The crux of the problem is that environment variables need to be updated in the user’s shell, which is going to be a different process than the winget.exe process. This leads to the main constraint for any possible solution:

Requirement 1: somehow, someway, there will need to be code that runs in the shell process.

Where does that code come from? One could imagine that it could be built into the shell itself: for instance, if pwsh.exe itself created a hidden window, in order to listen for WM_SETTINGCHANGE messages; upon receipt of said message, it could refresh its environment block.

But there are some problems with that:

So in my mind that idea (code being built in to the shell itself) is a non-starter.

More about the “diff” problem: it is absolutely not a simple operation to “just refresh the PATH environment variable”. The PATH variables (I’m thinking specifically of PATH and PSModulePath) are stored in the registry, but you can’t just read the current registry value and say “well, that’s the current path!”, because it’s not. Many shells and environments, including powershell.exe and pwsh.exe, modify their PATH environment variables at runtime, depending on myriad and complicated factors. (Want a headache? Just try to figure out how powershell.exe and pwsh.exe heuristically update PSModulePath at runtime (it can become… problematic).) And it’s not just the shells themselves; many shell-based “operating environments” also perform heavy and critical customization of PATH-related environment variables. So you absolutely cannot “just read the current values from the registry” and apply them in-memory. Doing so would break approximately a million things.

So how could we update the environment, in as safe and non-breaking a way as possible? The critical thing is to not mess up any in-memory customizations, so ideally we just tack on the bare minimum of “what actually changed” onto the very end. (It’s possible that an installer does something super fancy, and purposefully updates %PATH% to stick something new in the middle, before some other paths; but I think that’s a rare case, and getting it wrong by updating the current process’s PATH to add the new thing at the very end is at least still not disruptive to any other existing in-memory customizations.)

This is what gives rise to:

Requirement 2: There has to be a “diff”: we need to know what actually changed, so we can add just that to the end of the current, in-memory value.

So we have to compare a “before” and “after”… and with the WM_SETTINGCHANGE broadcast there is no clear “before” point. Trying to compare an in-memory, potentially highly customized value to what’s in the registry, with no frame of reference, is doomed to be extraordinarily fragile and impossible to get completely right. Like a three-way merge without the “base”.

Okay, back to requirement 1: we have to have code that runs in the shell process. How can we do that? I see only two options:

  1. Invasively inject a thread into the shell’s process. a. This is a nuclear option. The CreateRemoteThread API is one of those scary APIs with documentation that says things like "the application can deadlock if the thread attempts to obtain ownership of locks that another thread is using". Given that we have a narrow list of shell processes to target, it could probably be made to work… but it's a pretty serious thing to go injecting code into an unsuspecting process.
  2. Use a shell-specific mechanism. a. For cmd: a .cmd/.bat script. b. For PowerShell: a .ps1 script. c. Bash: a .sh script. d. Etc. e. Because winget is Windows-specific, just handling cmd and PS is good enough; who in the world is running winget commands from bash, amiright? :D

Okay, so we’re going to have a script.

This brings us to the last piece of the puzzle: how is the [shell-specific] script going to get executed in the proper shell process? Winget.exe can’t do it directly (and don’t say CreateRemoteThread again)…

Easy: just have the user do it! :D

We train people to just run “winget <arguments>”… but that does not have to directly be winget.EXE. If we have winget.ps1 and winget.cmd, which come before winget.exe on the %PATH%, then when you are in cmd.exe, and you run “winget”, you will run winget.CMD; when in pwsh.exe, you will get winget.PS1.

And here’s what the script will do:

  1. [If we are doing an "install",] read the current PATH values out of the registry (the “before” snapshot). This is possibly very different from what’s in memory, but that’s okay; this is just a reference point to find out what the installer has actually changed.
  2. Run winget.exe, passing through all arguments (%*/$args).
  3. Read the current PATH values out of the registry (the “after” snapshot), compare to “before” to see what the installer changed, and tack the additions onto the end of the in-memory environment values.

Et voilà!

(This is actually a pretty standard trick; to wrap things in a script wrapper for various reasons.)

If you want to try this out, a convenient, guaranteed-to-exist-and-be-on-the-PATH-and-come-before-the-windowsapps-dir path is C:\windows\system32. So you can put the script files below into system32 as an easy way to evaluate this, but remember that this is probably not an attractive "real" solution for the winget package (which means somebody needs to do some work to arrange for a different, user-specific (so it won't require elevation) path to be on the %PATH%, before the windowsapps dir (where the winget.exe command is found).

(Also, note that you don't actually need the .Tests.ps1 file; that's just test code, which is convenient if you want to play around with it. Note that it requires you to install Pester v5.)

Script files:

winget.cmd:

@ECHO off
SETLOCAL ENABLEEXTENSIONS ENABLEDELAYEDEXPANSION

REM This wrapper script is a straight "pass-through" to winget.exe, and then after running
REM an install, it will update your in-process PATH environment variable.

IF "%1" == "install" (
    SET TheHelperCommand=powershell.exe -NoLogo -NoProfile -ExecutionPolicy RemoteSigned -Command . %~dp0wingetHelper.ps1 ; GetStaticPathFromRegistry PATH
    FOR /F "tokens=* USEBACKQ" %%i IN (`!TheHelperCommand!`) DO (
        SET StaticPathBefore=%%i
    )
)

winget.exe %*

IF NOT "%StaticPathBefore%" == "" (
    FOR /F "tokens=* USEBACKQ" %%i IN (`!TheHelperCommand!`) DO (
        SET StaticPathAfter=%%i
    )

    SET TheHelperCommand=powershell.exe -NoLogo -NoProfile -ExecutionPolicy RemoteSigned -Command . %~dp0wingetHelper.ps1 ; CalculateAdditions 'PATH' '%StaticPathBefore%' '!StaticPathAfter!'

    FOR /F "tokens=* USEBACKQ" %%i IN (`!TheHelperCommand!`) DO (
        SET Additions=%%i
    )
    REM ECHO Additions are: !Additions!
)

IF NOT "%Additions%" == "" (
    ENDLOCAL & SET PATH=%PATH%;%Additions%
)

winget.ps1:

# This wrapper script is a straight "pass-through" to winget.exe, and then after running
# an install, it will update your in-process Path environment variables (in your current
# shell).
#
# N.B. This is a "simple function" (as opposed to an "advanced function") (no
# "[CmdletBinding()]" attribute). This is important so that the PowerShell parameter
# binder does not get involved, and we can pass everything straight to winget.exe as-is.

try
{
    $pathBefore = ''
    $psModulePathBefore = ''
    if( $args -and ($args.Length -gt 0) -and ($args[ 0 ] -eq 'install') )
    {
        . $PSScriptRoot\wingetHelper.ps1

        $pathBefore = GetStaticPathFromRegistry 'PATH'
        $psModulePathBefore = GetStaticPathFromRegistry 'PSModulePath'
    }

    winget.exe @args

    if( $pathBefore )
    {
        UpdateCurrentProcessPathBasedOnDiff 'PATH' $pathBefore
        UpdateCurrentProcessPathBasedOnDiff 'PSModulePath' $psModulePathBefore
    }
}
catch
{
    Write-Error $_
}

wingetHelper.ps1:


# Split out for mocking.
function GetEnvVar
{
    [CmdletBinding()]
    param( $EnvVarName, $Target )

    # (the cast is so that a null return value gets converted to an empty string)
    return [string] ([System.Environment]::GetEnvironmentVariable( $EnvVarName, $Target ))
}

# Gets the "static" (as stored in the registry) value of a specified PATH-style
# environment variable (combines the Machine and User values with ';'). Note that this may
# be significantly different than the "live" environment value in the memory of the
# current process.
function GetStaticPathFromRegistry
{
    [CmdletBinding()]
    param( $EnvVarName )

    (@( 'Machine', 'User' ) | ForEach-Object { GetEnvVar $EnvVarName $_ }) -join ';'
}

# Split out for mocking.
function UpdateCurrentProcessPath
{
    [CmdletBinding()]
    param( $EnvVarName, $Additions )

    Set-Content Env:\$EnvVarName -Value ((Get-Content Env:\$EnvVarName) + ';' + $additions)
}

function UpdateCurrentProcessPathBasedOnDiff
{
    [CmdletBinding()]
    param( $EnvVarName, $Before )

    $pathAfter = GetStaticPathFromRegistry $EnvVarName

    $additions = CalculateAdditions $EnvVarName $Before $pathAfter

    if( $additions )
    {
        UpdateCurrentProcessPath $EnvVarName $additions
    }
}

# Given two strings representing PATH-like environment variables (a set of strings
# separated by ';'), returns the PATHs that are present in the second ($After) but not in
# the first ($Before) and not in the current (in-memory) variable, in PATH format (joined
# by ';'). (Does not do anything about removals or reordering.)
function CalculateAdditions
{
    [CmdletBinding()]
    param( [string] $EnvVarName, [string] $Before, [string] $After )

    try
    {
        $additions = @()
        $setBefore = @( $Before.Split( ';' ) )
        $currentInMemory = @( (GetEnvVar $EnvVarName 'Process').Split( ';' ) )

        foreach( $p in $After.Split( ';' ) )
        {
            if( ($setBefore -notcontains $p) -and ($currentInMemory -notcontains $p) )
            {
                $additions += $p
            }
        }

        return $additions -join ';'
    }
    finally { }
}

wingetHelper.Tests.ps1:

BeforeAll {
    $parentDir = Split-Path $PSCommandPath -Parent

    . $PSCommandPath.Replace('.Tests.ps1', '.ps1')

    function ResetFakeRegistry
    {
        $script:FakeRegistry = @{
            User = @{
                Path = ''
                PSModulePath = ''
            }
            Machine = @{
                Path = 'testPath1;testPath2;testPath3'
                PSModulePath = 'testPsModulePath1'
            }
            Process = @{
                Path = 'testPath1;testPath2;testPath3;runtimePath1'
                PSModulePath = 'testPsModulePath1;runtimePsModulePath1'
            }
        }
    }

    Mock GetEnvVar {
        return ($script:FakeRegistry)[ $Target ][ $EnvVarName ]
    }

    Mock UpdateCurrentProcessPath {
        # (nothing)
    }

    # Hide the real winget.exe:
    function winget.exe
    {
        # (nothing)
    }
}

Describe 'winget.ps1' {
    BeforeEach {
        ResetFakeRegistry
    }

    It 'should do nothing if not an install command' {

        # N.B. Dot sourcing here is important, so that it executes in the current scope.
        . $PSScriptRoot\winget.ps1 some other command

        Should -Invoke -CommandName 'GetEnvVar' -Exactly 0
        Should -Invoke -CommandName 'UpdateCurrentProcessPath' -Exactly 0
    }

    It 'should do nothing if no change in path' {

        # N.B. Dot sourcing here is important, so that it executes in the current scope.
        . $PSScriptRoot\winget.ps1 install something

        Should -Invoke -CommandName 'GetEnvVar' -Exactly 10
        Should -Invoke -CommandName 'UpdateCurrentProcessPath' -Exactly 0
    }

    It 'should update current process paths if install updated paths' {

        $newPaths = 'newPath1;newPath2'
        $newPsModulePaths = 'newPath3'
        Mock winget.exe {
            # Simulate install updating the registry:
            $script:FakeRegistry[ 'User' ][ 'PATH' ] = $newPaths
            $script:FakeRegistry[ 'Machine' ][ 'PSModulePath' ] = $newPsModulePaths
        }

        # N.B. Dot sourcing here is important, so that it executes in the current scope.
        . $PSScriptRoot\winget.ps1 install something

        Should -Invoke -CommandName 'GetEnvVar' -Exactly 10
        Should -Invoke -CommandName 'UpdateCurrentProcessPath' -Exactly 1 -ParameterFilter {
            ($EnvVarName -eq 'PATH') -and ($Additions -eq $newPaths)
        }
        Should -Invoke -CommandName 'UpdateCurrentProcessPath' -Exactly 1 -ParameterFilter {
            ($EnvVarName -eq 'PSModulePath') -and ($Additions -eq $newPsModulePaths)
        }
    }

    It 'should not add duplicate paths' {

        $newPsModulePaths = 'newPath3'
        $script:FakeRegistry[ 'Process' ][ 'PSModulePath' ] = $newPsModulePaths
        Mock winget.exe {
            # Simulate install updating the registry:
            $script:FakeRegistry[ 'Machine' ][ 'PSModulePath' ] = $newPsModulePaths
        }

        # N.B. Dot sourcing here is important, so that it executes in the current scope.
        . $PSScriptRoot\winget.ps1 install something

        Should -Invoke -CommandName 'GetEnvVar' -Exactly 10
        Should -Invoke -CommandName 'UpdateCurrentProcessPath' -Exactly 0
    }
}
jazzdelightsme commented 1 year ago

(I forgot to add: a small downside to the "script wrapper" approach is that if someone runs "winget.exe" instead of "winget", then they don't get the magical path-refresh behavior. But there's a very good upside to this: it means that if there were some targeted situation where you didn't want path refresh, you could avoid it by just running winget.exe instead.)

Masamune3210 commented 1 year ago

Good news, at least in the realm of stop gaps, Terminal now has the ability to get a new environment block on tap open, at least in preview. At least we don't have to close the whole terminal anymore, just opening a new tab should be enough

denelon commented 1 year ago

I do suggest closing the "first" tab though, I've confused myself more than once switching to a different tab that hadn't been updated.

voronoipotato commented 1 year ago

@denelon Do you think we'll get this for powershell users in the near future?

denelon commented 1 year ago

"Near future" is subjective. My preference is to have an experimental feature before just rolling it out to "everyone". We're looking at the challenges with Windows PowerShell vs. PowerShell 7 currently. I'm expecting we will make incremental progress with this one.

I'm also looking at the nuances between how we do experimental features in WinGet and how they are done in PowerShell. I want to make sure we're following the right idioms between the two types of experiences (Windows CLI vs. native PowerShell).

There are lots of 👍 on this Issue so it does bring more priority to this issue compared with many others.

voronoipotato commented 1 year ago

Thanks for the update :). I understand that there's a lot of important work but I'm grateful that it's being treated as a priority.

Masamune3210 commented 1 year ago

I do suggest closing the "first" tab though, I've confused myself more than once switching to a different tab that hadn't been updated.

Not a bad idea, I see how that would get pretty confusing. I get confused enough just having multiple types of processor in the same window lol

jazzdelightsme commented 1 year ago

In an idle moment, it occurred to me that there is another way you could arrange for the wrapper scripts to take precedence over the EXE, without having to fiddle with %PATH%, if you were, hm, not sure how best to describe it... willing to take drastic action in service of the user, maybe?: you could rename the EXE (e.g. winget-real.exe, or maybe winget-cli.exe, ha) (and add additional wrapper scripts named winget.exe.cmd and winget.exe.ps1 in order to keep invocations of winget.exe working).

The price (what would stop working) is anything that expected "winget.exe" to be an actual PE file (like someone using CreateProcess( "winget.exe", ... ) directly, instead of ShellExecute or such). I would expect such usage to be very low. (There would still be a "don't want the wrapper script" workaround; it would be to just call winget-real.exe instead.)

What is most interesting (to me) about this thought experiment is the utter revulsion I feel about renaming the EXE. Why does it seem so gross? From the point of view of the end user, I think that they don't care at all (99.9% wouldn't even notice); they just want to run "winget install whatever" and have it work. So why does renaming the EXE just so these shell wrapper scripts can take precedence feel like some kind of violation? It makes me wonder if I am holding things sacred that I should not be.

denelon commented 1 year ago

We do have plenty of cases where the MSIX behavior isn't what customers want, and they are sensitive to changes in the name of the "primary" executable. If this does actually end up being the right thing to do to resolve the challenges associated with path, it might be OK though.

Several users have automated solutions calling winget.exe directly in the path where the files get laid down rather than using the App Execution Alias that typically catches "winget" via Windows Terminal, PowerShell, or what ever shell they happen to be using.

denelon commented 1 year ago

Our preference for integration is to call the COM API rather than winget.exe. In the case of PowerShell users, I think we could just make that work as we would like, but we'll have to continue investigating.

amoscatelli commented 1 year ago

I am not sure if this is the same issue but I am observing this behavior :

image

image

The same applies with Powershell. Everytime I launch cmd or powershell binaries previously installed with winget seem not to be in PATH, I click on new tab, then I can use the binaries ...

voronoipotato commented 1 year ago

@amoscatelli Winget does not currently refresh the path. It is still effectively in alpha from a UX perspective. It can be used, and is stable, but until this issue is fixed I would not recommend it to anyone other than power users who want to play with something new. It's too confusing and will make people resent an otherwise pretty good tool.