From e7c70925a2bc6ed3a8c073152dddc4f22f0bb075 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Sun, 20 Sep 2026 22:58:22 +0300 Subject: [PATCH 1/4] feat(nodeenv): write the posix activate on Windows for git-bash On Windows only activate.bat, deactivate.bat and Activate.ps1 were written, so git-bash and the other posix shells there had nothing to source. Python's venv treats its bash activate as "common" and installs it on every platform; do the same. The script itself needs two Windows adjustments: node.exe keeps the global modules next to itself, in Scripts/node_modules rather than lib/node_modules, and it cannot read the /c/... paths a Windows shell hands out, so NODE_PATH and NPM_CONFIG_PREFIX are converted back with cygpath while $PATH stays posix. A new CI job runs the activation under `shell: bash` on a windows runner, which is git-bash. https://github.com/ekalinin/nodeenv/issues/226 --- .github/workflows/tests.yml | 27 +++++++++ CHANGES | 3 + README.rst | 6 ++ nodeenv.py | 27 ++++++++- tests/nodeenv_test.py | 38 +++++++++++- tests/test_install_activate.py | 108 +++++++++++++++++++++++++++++++++ 6 files changed, 204 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 33cb31f..bf47383 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -89,6 +89,33 @@ jobs: run: | pytest -m integration tests/ -v + # The posix activate written on Windows is only useful in a shell that + # can source it, and `shell: bash` on a windows runner is git-bash - + # exactly the environment of issue #226. + git-bash: + runs-on: windows-latest + timeout-minutes: 10 + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run git-bash activation test + run: | + pytest -m integration -k git_bash tests/ -v + coverage: runs-on: ubuntu-latest steps: diff --git a/CHANGES b/CHANGES index 8426744..1cc1356 100644 --- a/CHANGES +++ b/CHANGES @@ -32,6 +32,9 @@ Version [unreleased] - Documented that `--mirror` takes a `file://` URL, so a local directory can serve as the download source `#193 `_ +- The posix `activate` is now written on Windows too, into "Scripts", so + git-bash and the other posix shells there can activate an environment + `#226 `_ Version 1.3.1 ------------- diff --git a/README.rst b/README.rst index 0106269..7e2cb64 100644 --- a/README.rst +++ b/README.rst @@ -99,6 +99,12 @@ Activate new environment:: $ . env/bin/activate +On Windows the environment is created in ``env\Scripts`` instead, with a +script per shell: ``activate.bat`` for cmd, ``Activate.ps1`` for +PowerShell and ``activate`` for posix shells such as git-bash:: + + $ . env/Scripts/activate + Check versions of main packages:: (env) $ node -v diff --git a/nodeenv.py b/nodeenv.py index 0e1a676..2d6c738 100644 --- a/nodeenv.py +++ b/nodeenv.py @@ -1191,7 +1191,11 @@ def install_activate(env_dir, args): Install virtual environment activation script """ if is_WIN: + # `activate` is written on Windows too, for git-bash and the other + # posix shells available there + # https://github.com/ekalinin/nodeenv/issues/226 files = { + 'activate': ACTIVATE_SH, 'activate.bat': ACTIVATE_BAT, "deactivate.bat": DEACTIVATE_BAT, "Activate.ps1": ACTIVATE_PS1 @@ -1214,7 +1218,9 @@ def install_activate(env_dir, args): if args.node == "system": files["node"] = SHIM - mod_dir = join('lib', 'node_modules') + # npm keeps the global modules next to node.exe on Windows, + # under lib/ everywhere else + mod_dir = 'Scripts/node_modules' if is_WIN else join('lib', 'node_modules') prompt = args.prompt or '(%s)' % os.path.basename(os.path.abspath(env_dir)) if args.node == "system": @@ -1238,6 +1244,10 @@ def install_activate(env_dir, args): ['cygpath', '-w', os.path.abspath(bin_dir)], show_stdout=False, in_shell=False) content = content.replace('__NPM_CONFIG_PREFIX__', cyg_bin_dir[0]) + elif is_WIN: + # npm's prefix on Windows is the directory holding node.exe + content = content.replace('__NPM_CONFIG_PREFIX__', + '$NODE_VIRTUAL_ENV/Scripts') else: content = content.replace('__NPM_CONFIG_PREFIX__', '$NODE_VIRTUAL_ENV') @@ -1796,7 +1806,7 @@ def main(): # Detect calling this file as a script case $0 in - */bin/activate ) + */bin/activate | */Scripts/activate ) echo "Do not call $0 directly. Instead source it with \`source $0\`." exit 1 ;; @@ -1825,7 +1835,7 @@ def main(): export NODE_VIRTUAL_ENV _OLD_NODE_VIRTUAL_PATH="$PATH" -PATH="$NODE_VIRTUAL_ENV/lib/node_modules/.bin:$NODE_VIRTUAL_ENV/__BIN_NAME__:$PATH" +PATH="$NODE_VIRTUAL_ENV/__MOD_NAME__/.bin:$NODE_VIRTUAL_ENV/__BIN_NAME__:$PATH" export PATH _OLD_NODE_PATH="${NODE_PATH:-}" @@ -1840,6 +1850,17 @@ def main(): export npm_config_prefix __NPM_ISOLATE__ +# Windows shells (git-bash, MSYS, Cygwin) run a native node.exe, which +# cannot read the posix paths built above: hand it the native ones. +# $PATH stays posix, that one is read by the shell itself. +case "$(uname -s 2>/dev/null)" in + CYGWIN*|MSYS*|MINGW*) + NODE_PATH="$(cygpath -w "$NODE_PATH")" + NPM_CONFIG_PREFIX="$(cygpath -w "$NPM_CONFIG_PREFIX")" + npm_config_prefix="$NPM_CONFIG_PREFIX" + ;; +esac + if [ -z "${NODE_VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then _OLD_NODE_VIRTUAL_PS1="${PS1:-}" if [ "x__NODE_VIRTUAL_PROMPT__" != x ] ; then diff --git a/tests/nodeenv_test.py b/tests/nodeenv_test.py index 4e887e2..fba02a5 100644 --- a/tests/nodeenv_test.py +++ b/tests/nodeenv_test.py @@ -72,8 +72,8 @@ def test_smoke(tmpdir): ]) assert os.path.exists(nenv_path) if sys.platform == 'win32': - # on Windows nodeenv installs into Scripts/ and provides - # activate.bat/Activate.ps1, there is no posix activate script + # on Windows nodeenv installs into Scripts/, the posix activate + # written there is covered by test_smoke_git_bash subprocess.check_call([ os.path.join(nenv_path, 'Scripts', 'node.exe'), '--version', ]) @@ -90,6 +90,40 @@ def test_smoke(tmpdir): assert version, '%s --version printed nothing' % command +@pytest.mark.integration +@pytest.mark.skipif( + sys.platform != 'win32', reason='git-bash only exists on Windows') +def test_smoke_git_bash(tmpdir): + """ + The posix activate written on Windows has to work from git-bash. + https://github.com/ekalinin/nodeenv/issues/226 + """ + nenv_path = tmpdir.join('nenv').strpath + subprocess.check_call([ + 'coverage', 'run', '-p', + '-m', 'nodeenv', '--prebuilt', nenv_path, + ]) + + # node.exe and npm report native paths, so both answers can be + # compared with the environment directory as python knows it + script = ( + 'set -e\n' + 'env_dir="$(cygpath "$1")"\n' + '. "$env_dir/Scripts/activate"\n' + 'node -p "process.execPath"\n' + 'npm root -g\n' + ) + out = subprocess.check_output(['bash', '-c', script, 'bash', nenv_path]) + node_exe, npm_root = out.decode('utf-8').splitlines() + + assert _inside(node_exe, nenv_path), \ + 'node resolved to %s, outside %s' % (node_exe, nenv_path) + # npm would answer with a path outside the environment if activate + # had left it a posix prefix it cannot read + assert _inside(npm_root, nenv_path), \ + 'npm root -g is %s, outside %s' % (npm_root, nenv_path) + + @pytest.mark.integration @pytest.mark.skipif(sys.platform == 'win32', reason='-n system is posix only') def test_smoke_n_system_special_chars(tmpdir): diff --git a/tests/test_install_activate.py b/tests/test_install_activate.py index 78d1967..269163e 100644 --- a/tests/test_install_activate.py +++ b/tests/test_install_activate.py @@ -412,6 +412,114 @@ def test_isolate_npm_shim_content(tmpdir): assert content.index('npm_config_cache') < content.index('exec ') +# Windows also gets the posix `activate`, for git-bash and friends. +# https://github.com/ekalinin/nodeenv/issues/226 +# +# These run on every platform: is_WIN is faked so the Scripts/ layout can +# be checked without a Windows host. + + +@pytest.fixture +def fake_win(): + """ + Pretend the host is Windows. install_activate() links nodejs.exe with + mklink there, which exists on Windows only, so callit() is stubbed too. + """ + with mock.patch.object(nodeenv, 'is_WIN', True): + with mock.patch.object(nodeenv, 'callit'): + yield + + +def _install_win(tmpdir, *extra_args): + bin_dir = tmpdir.join('Scripts') + if not bin_dir.check(): + bin_dir.mkdir() + + argv = ['nodeenv'] + list(extra_args) + [str(tmpdir)] + with mock.patch.object(sys, 'argv', argv): + opts = nodeenv.parse_args() + nodeenv.install_activate(str(tmpdir), opts) + return bin_dir + + +def test_win_writes_posix_activate(tmpdir, fake_win): + bin_dir = _install_win(tmpdir) + + assert sorted(p.basename for p in bin_dir.listdir()) == [ + 'Activate.ps1', 'activate', 'activate.bat', 'deactivate.bat'] + + +def test_win_activate_puts_scripts_on_path(tmpdir, fake_win): + content = _install_win(tmpdir).join('activate').read() + + assert ('PATH="$NODE_VIRTUAL_ENV/Scripts/node_modules/.bin:' + '$NODE_VIRTUAL_ENV/Scripts:$PATH"') in content + + +def test_win_activate_points_node_at_scripts(tmpdir, fake_win): + # npm keeps the global modules next to node.exe on Windows, there is + # no lib/node_modules there + content = _install_win(tmpdir).join('activate').read() + + assert 'NODE_PATH="$NODE_VIRTUAL_ENV/Scripts/node_modules"' in content + assert 'NPM_CONFIG_PREFIX="$NODE_VIRTUAL_ENV/Scripts"' in content + assert 'npm_config_prefix="$NODE_VIRTUAL_ENV/Scripts"' in content + + +def test_win_activate_has_no_placeholders_left(tmpdir, fake_win): + content = _install_win(tmpdir).join('activate').read() + + for placeholder in ('__NODE_VIRTUAL_PROMPT__', '__NODE_VIRTUAL_ENV__', + '__SHIM_NODE__', '__BIN_NAME__', '__MOD_NAME__', + '__NPM_ISOLATE__', '__NPM_UNISOLATE__', + '__NPM_CONFIG_PREFIX__'): + assert placeholder not in content + + +def test_win_activate_converts_paths_for_node_exe(tmpdir, fake_win): + # node.exe is a native binary: it cannot read the /c/... paths a + # Windows shell hands out, so the script converts them back + content = _install_win(tmpdir).join('activate').read() + + assert 'CYGWIN*|MSYS*|MINGW*)' in content + assert 'NODE_PATH="$(cygpath -w "$NODE_PATH")"' in content + assert 'NPM_CONFIG_PREFIX="$(cygpath -w "$NPM_CONFIG_PREFIX")"' in content + # the conversion must come after the variables are built + assert content.index('NODE_PATH="$NODE_VIRTUAL_ENV') < \ + content.index('cygpath -w') + + +def test_win_activate_is_valid_sh(tmpdir, fake_win): + activate = _install_win(tmpdir).join('activate') + + subprocess.check_call(['sh', '-n', str(activate)]) + + +def test_win_activate_refuses_to_be_run_directly(tmpdir, fake_win): + activate = str(_install_win(tmpdir).join('activate')) + + proc = subprocess.Popen( + ['sh', activate], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + out, _ = proc.communicate() + + assert proc.returncode == 1 + assert b'Do not call' in out + + +def test_win_python_virtualenv_appends_to_activate(tmpdir, fake_win): + # nodeenv -p inside a python venv: venv wrote Scripts/activate for + # git-bash already, nodeenv has to extend it, not replace it + bin_dir = tmpdir.join('Scripts') + bin_dir.mkdir() + bin_dir.join('activate').write('# python venv activate\n') + + _install_win(tmpdir, '-p') + + content = bin_dir.join('activate').read() + assert content.startswith('# python venv activate\n') + assert 'NODE_VIRTUAL_ENV_DISABLE_PROMPT=1' in content + + @pytest.mark.skipif(nodeenv.is_WIN, reason='system node is POSIX only') def test_isolate_npm_node_system_shim_exports(tmpdir): bin_dir = tmpdir.join('bin') From 32f26573084e928310cd6eeb137e77ae5e1615d6 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Mon, 21 Sep 2026 18:09:06 +0300 Subject: [PATCH 2/4] test(nodeenv): make the git-bash probe report why it failed The first CI run of the new job failed with a bare exit 1 and no diagnostic: the probe ran through `bash -c` with the script and the environment path as arguments, so the quoting rules of two command line parsers sat between the test and what the shell ran, and stderr was not captured at all. It now writes the probe to a file, traces it with `set -x` and puts both streams into the assertion message. The direct call guard matches on $0, which a shell reports in the form it was given; the test was passing the native `C:\...` path, which no posix shell would produce. --- tests/nodeenv_test.py | 31 +++++++++++++++++++++---------- tests/test_install_activate.py | 4 +++- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/tests/nodeenv_test.py b/tests/nodeenv_test.py index fba02a5..9540070 100644 --- a/tests/nodeenv_test.py +++ b/tests/nodeenv_test.py @@ -104,18 +104,29 @@ def test_smoke_git_bash(tmpdir): '-m', 'nodeenv', '--prebuilt', nenv_path, ]) - # node.exe and npm report native paths, so both answers can be - # compared with the environment directory as python knows it - script = ( - 'set -e\n' - 'env_dir="$(cygpath "$1")"\n' - '. "$env_dir/Scripts/activate"\n' + # bash reads the script from a file: passing it inline would put the + # quoting rules of two command line parsers between the test and what + # the shell ends up running. `set -x` sends a trace to stderr, which + # is only reported when the probe fails. + # node.exe and npm answer with native paths, so both can be compared + # with the environment directory as python knows it. + probe = tmpdir.join('probe.sh') + probe.write( + 'set -ex\n' + '. "%s/Scripts/activate"\n' 'node -p "process.execPath"\n' - 'npm root -g\n' + 'npm root -g\n' % nenv_path.replace(os.sep, '/') ) - out = subprocess.check_output(['bash', '-c', script, 'bash', nenv_path]) - node_exe, npm_root = out.decode('utf-8').splitlines() - + proc = subprocess.run( + ['bash', probe.strpath.replace(os.sep, '/')], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + report = 'exit %s\n--- stdout ---\n%s\n--- stderr ---\n%s' % ( + proc.returncode, + proc.stdout.decode('utf-8', 'replace'), + proc.stderr.decode('utf-8', 'replace')) + + assert proc.returncode == 0, report + node_exe, npm_root = proc.stdout.decode('utf-8').splitlines()[-2:] assert _inside(node_exe, nenv_path), \ 'node resolved to %s, outside %s' % (node_exe, nenv_path) # npm would answer with a path outside the environment if activate diff --git a/tests/test_install_activate.py b/tests/test_install_activate.py index 269163e..11b0642 100644 --- a/tests/test_install_activate.py +++ b/tests/test_install_activate.py @@ -496,7 +496,9 @@ def test_win_activate_is_valid_sh(tmpdir, fake_win): def test_win_activate_refuses_to_be_run_directly(tmpdir, fake_win): - activate = str(_install_win(tmpdir).join('activate')) + # the guard matches on $0, and a shell reports the path it was given: + # from a posix shell on Windows that is the forward slash form + activate = str(_install_win(tmpdir).join('activate')).replace(os.sep, '/') proc = subprocess.Popen( ['sh', activate], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) From 628fe9778a537b9f8a395a597f43bf4b528ce2a9 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Mon, 21 Sep 2026 18:28:54 +0300 Subject: [PATCH 3/4] test(nodeenv): run the git-bash probe under git-bash, not WSL `bash` on PATH is C:\Windows\System32\bash.exe, the WSL launcher: on a runner without a distribution it answers "Windows Subsystem for Linux has no installed distributions" in UTF-16 and exits 1, which is what the job was reporting. Look git-bash up next to git instead. --- tests/nodeenv_test.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/nodeenv_test.py b/tests/nodeenv_test.py index 9540070..4d790d3 100644 --- a/tests/nodeenv_test.py +++ b/tests/nodeenv_test.py @@ -9,6 +9,7 @@ import io import os.path import pathlib +import shutil import subprocess import sys import sysconfig @@ -50,6 +51,22 @@ def _resolve_and_run(activate, command): return resolved, version +def _git_bash(): + """ + Path of the git-bash executable, or None. + + `bash` on PATH is the WSL launcher shipped in System32, which answers + "Windows Subsystem for Linux has no installed distributions" and + exits 1, so git-bash is looked up next to git itself instead. + """ + git = shutil.which('git') + if git is None: + return None + git_dir = os.path.dirname(os.path.dirname(git)) + bash = os.path.join(git_dir, 'bin', 'bash.exe') + return bash if os.path.exists(bash) else None + + def _inside(path, env_dir): """ Is `path` inside `env_dir`? @@ -98,6 +115,9 @@ def test_smoke_git_bash(tmpdir): The posix activate written on Windows has to work from git-bash. https://github.com/ekalinin/nodeenv/issues/226 """ + bash = _git_bash() + assert bash, 'git-bash not found, this test would prove nothing' + nenv_path = tmpdir.join('nenv').strpath subprocess.check_call([ 'coverage', 'run', '-p', @@ -118,7 +138,7 @@ def test_smoke_git_bash(tmpdir): 'npm root -g\n' % nenv_path.replace(os.sep, '/') ) proc = subprocess.run( - ['bash', probe.strpath.replace(os.sep, '/')], + [bash, probe.strpath.replace(os.sep, '/')], stdout=subprocess.PIPE, stderr=subprocess.PIPE) report = 'exit %s\n--- stdout ---\n%s\n--- stderr ---\n%s' % ( proc.returncode, From ae70eef1c696d75ff537d62f265ed380b4a7c714 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Mon, 21 Sep 2026 20:54:11 +0300 Subject: [PATCH 4/4] test(nodeenv): take git-bash from the shell running the job Looking git-bash up next to git.exe missed: on PATH inside git-bash git is Git/mingw64/bin/git.exe, and there is no bash.exe beside it. The job already runs in the right shell, so it now passes $BASH to the test, which keeps a PATH lookup that skips the System32 launcher for runs outside CI. --- .github/workflows/tests.yml | 3 +++ tests/nodeenv_test.py | 32 +++++++++++++++++++++++--------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bf47383..490e707 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -113,7 +113,10 @@ jobs: pip install -r requirements-dev.txt - name: Run git-bash activation test + # $BASH is the shell running this step, which is the git-bash the + # test needs: `bash` from PATH would be the WSL launcher run: | + export NODEENV_GIT_BASH="$(cygpath -w "$BASH")" pytest -m integration -k git_bash tests/ -v coverage: diff --git a/tests/nodeenv_test.py b/tests/nodeenv_test.py index 4d790d3..fe4d44e 100644 --- a/tests/nodeenv_test.py +++ b/tests/nodeenv_test.py @@ -55,16 +55,30 @@ def _git_bash(): """ Path of the git-bash executable, or None. - `bash` on PATH is the WSL launcher shipped in System32, which answers - "Windows Subsystem for Linux has no installed distributions" and - exits 1, so git-bash is looked up next to git itself instead. + The CI job already runs inside git-bash and passes its own shell in + NODEENV_GIT_BASH. Outside it `bash` from PATH is used, unless that + is the WSL launcher shipped in System32: with no distribution + installed it answers "Windows Subsystem for Linux has no installed + distributions" and exits 1. git-bash also ships sh.exe, which + System32 does not, so bash.exe is looked for next to it as well. """ - git = shutil.which('git') - if git is None: - return None - git_dir = os.path.dirname(os.path.dirname(git)) - bash = os.path.join(git_dir, 'bin', 'bash.exe') - return bash if os.path.exists(bash) else None + from_env = os.environ.get('NODEENV_GIT_BASH') + if from_env: + return from_env + + system_root = os.environ.get('SystemRoot', r'C:\Windows').lower() + candidates = [] + on_path = shutil.which('bash') + if on_path and not on_path.lower().startswith(system_root): + candidates.append(on_path) + sh = shutil.which('sh') + if sh: + candidates.append(os.path.join(os.path.dirname(sh), 'bash.exe')) + + for candidate in candidates: + if os.path.exists(candidate): + return candidate + return None def _inside(path, env_dir):