Skip to content

Commit e7c7092

Browse files
committed
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. #226
1 parent dcaa7f5 commit e7c7092

6 files changed

Lines changed: 204 additions & 5 deletions

File tree

.github/workflows/tests.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,33 @@ jobs:
8989
run: |
9090
pytest -m integration tests/ -v
9191
92+
# The posix activate written on Windows is only useful in a shell that
93+
# can source it, and `shell: bash` on a windows runner is git-bash -
94+
# exactly the environment of issue #226.
95+
git-bash:
96+
runs-on: windows-latest
97+
timeout-minutes: 10
98+
defaults:
99+
run:
100+
shell: bash
101+
102+
steps:
103+
- uses: actions/checkout@v4
104+
105+
- name: Set up Python 3.14
106+
uses: actions/setup-python@v5
107+
with:
108+
python-version: '3.14'
109+
110+
- name: Install dependencies
111+
run: |
112+
python -m pip install --upgrade pip
113+
pip install -r requirements-dev.txt
114+
115+
- name: Run git-bash activation test
116+
run: |
117+
pytest -m integration -k git_bash tests/ -v
118+
92119
coverage:
93120
runs-on: ubuntu-latest
94121
steps:

CHANGES

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ Version [unreleased]
3232
- Documented that `--mirror` takes a `file://` URL, so a local directory can
3333
serve as the download source
3434
`#193 <https://github.com/ekalinin/nodeenv/issues/193>`_
35+
- The posix `activate` is now written on Windows too, into "Scripts", so
36+
git-bash and the other posix shells there can activate an environment
37+
`#226 <https://github.com/ekalinin/nodeenv/issues/226>`_
3538

3639
Version 1.3.1
3740
-------------

README.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ Activate new environment::
9999

100100
$ . env/bin/activate
101101

102+
On Windows the environment is created in ``env\Scripts`` instead, with a
103+
script per shell: ``activate.bat`` for cmd, ``Activate.ps1`` for
104+
PowerShell and ``activate`` for posix shells such as git-bash::
105+
106+
$ . env/Scripts/activate
107+
102108
Check versions of main packages::
103109

104110
(env) $ node -v

nodeenv.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,7 +1191,11 @@ def install_activate(env_dir, args):
11911191
Install virtual environment activation script
11921192
"""
11931193
if is_WIN:
1194+
# `activate` is written on Windows too, for git-bash and the other
1195+
# posix shells available there
1196+
# https://github.com/ekalinin/nodeenv/issues/226
11941197
files = {
1198+
'activate': ACTIVATE_SH,
11951199
'activate.bat': ACTIVATE_BAT,
11961200
"deactivate.bat": DEACTIVATE_BAT,
11971201
"Activate.ps1": ACTIVATE_PS1
@@ -1214,7 +1218,9 @@ def install_activate(env_dir, args):
12141218
if args.node == "system":
12151219
files["node"] = SHIM
12161220

1217-
mod_dir = join('lib', 'node_modules')
1221+
# npm keeps the global modules next to node.exe on Windows,
1222+
# under lib/ everywhere else
1223+
mod_dir = 'Scripts/node_modules' if is_WIN else join('lib', 'node_modules')
12181224
prompt = args.prompt or '(%s)' % os.path.basename(os.path.abspath(env_dir))
12191225

12201226
if args.node == "system":
@@ -1238,6 +1244,10 @@ def install_activate(env_dir, args):
12381244
['cygpath', '-w', os.path.abspath(bin_dir)],
12391245
show_stdout=False, in_shell=False)
12401246
content = content.replace('__NPM_CONFIG_PREFIX__', cyg_bin_dir[0])
1247+
elif is_WIN:
1248+
# npm's prefix on Windows is the directory holding node.exe
1249+
content = content.replace('__NPM_CONFIG_PREFIX__',
1250+
'$NODE_VIRTUAL_ENV/Scripts')
12411251
else:
12421252
content = content.replace('__NPM_CONFIG_PREFIX__',
12431253
'$NODE_VIRTUAL_ENV')
@@ -1796,7 +1806,7 @@ def main():
17961806
17971807
# Detect calling this file as a script
17981808
case $0 in
1799-
*/bin/activate )
1809+
*/bin/activate | */Scripts/activate )
18001810
echo "Do not call $0 directly. Instead source it with \`source $0\`."
18011811
exit 1
18021812
;;
@@ -1825,7 +1835,7 @@ def main():
18251835
export NODE_VIRTUAL_ENV
18261836
18271837
_OLD_NODE_VIRTUAL_PATH="$PATH"
1828-
PATH="$NODE_VIRTUAL_ENV/lib/node_modules/.bin:$NODE_VIRTUAL_ENV/__BIN_NAME__:$PATH"
1838+
PATH="$NODE_VIRTUAL_ENV/__MOD_NAME__/.bin:$NODE_VIRTUAL_ENV/__BIN_NAME__:$PATH"
18291839
export PATH
18301840
18311841
_OLD_NODE_PATH="${NODE_PATH:-}"
@@ -1840,6 +1850,17 @@ def main():
18401850
export npm_config_prefix
18411851
__NPM_ISOLATE__
18421852
1853+
# Windows shells (git-bash, MSYS, Cygwin) run a native node.exe, which
1854+
# cannot read the posix paths built above: hand it the native ones.
1855+
# $PATH stays posix, that one is read by the shell itself.
1856+
case "$(uname -s 2>/dev/null)" in
1857+
CYGWIN*|MSYS*|MINGW*)
1858+
NODE_PATH="$(cygpath -w "$NODE_PATH")"
1859+
NPM_CONFIG_PREFIX="$(cygpath -w "$NPM_CONFIG_PREFIX")"
1860+
npm_config_prefix="$NPM_CONFIG_PREFIX"
1861+
;;
1862+
esac
1863+
18431864
if [ -z "${NODE_VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
18441865
_OLD_NODE_VIRTUAL_PS1="${PS1:-}"
18451866
if [ "x__NODE_VIRTUAL_PROMPT__" != x ] ; then

tests/nodeenv_test.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ def test_smoke(tmpdir):
7272
])
7373
assert os.path.exists(nenv_path)
7474
if sys.platform == 'win32':
75-
# on Windows nodeenv installs into Scripts/ and provides
76-
# activate.bat/Activate.ps1, there is no posix activate script
75+
# on Windows nodeenv installs into Scripts/, the posix activate
76+
# written there is covered by test_smoke_git_bash
7777
subprocess.check_call([
7878
os.path.join(nenv_path, 'Scripts', 'node.exe'), '--version',
7979
])
@@ -90,6 +90,40 @@ def test_smoke(tmpdir):
9090
assert version, '%s --version printed nothing' % command
9191

9292

93+
@pytest.mark.integration
94+
@pytest.mark.skipif(
95+
sys.platform != 'win32', reason='git-bash only exists on Windows')
96+
def test_smoke_git_bash(tmpdir):
97+
"""
98+
The posix activate written on Windows has to work from git-bash.
99+
https://github.com/ekalinin/nodeenv/issues/226
100+
"""
101+
nenv_path = tmpdir.join('nenv').strpath
102+
subprocess.check_call([
103+
'coverage', 'run', '-p',
104+
'-m', 'nodeenv', '--prebuilt', nenv_path,
105+
])
106+
107+
# node.exe and npm report native paths, so both answers can be
108+
# compared with the environment directory as python knows it
109+
script = (
110+
'set -e\n'
111+
'env_dir="$(cygpath "$1")"\n'
112+
'. "$env_dir/Scripts/activate"\n'
113+
'node -p "process.execPath"\n'
114+
'npm root -g\n'
115+
)
116+
out = subprocess.check_output(['bash', '-c', script, 'bash', nenv_path])
117+
node_exe, npm_root = out.decode('utf-8').splitlines()
118+
119+
assert _inside(node_exe, nenv_path), \
120+
'node resolved to %s, outside %s' % (node_exe, nenv_path)
121+
# npm would answer with a path outside the environment if activate
122+
# had left it a posix prefix it cannot read
123+
assert _inside(npm_root, nenv_path), \
124+
'npm root -g is %s, outside %s' % (npm_root, nenv_path)
125+
126+
93127
@pytest.mark.integration
94128
@pytest.mark.skipif(sys.platform == 'win32', reason='-n system is posix only')
95129
def test_smoke_n_system_special_chars(tmpdir):

tests/test_install_activate.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,114 @@ def test_isolate_npm_shim_content(tmpdir):
412412
assert content.index('npm_config_cache') < content.index('exec ')
413413

414414

415+
# Windows also gets the posix `activate`, for git-bash and friends.
416+
# https://github.com/ekalinin/nodeenv/issues/226
417+
#
418+
# These run on every platform: is_WIN is faked so the Scripts/ layout can
419+
# be checked without a Windows host.
420+
421+
422+
@pytest.fixture
423+
def fake_win():
424+
"""
425+
Pretend the host is Windows. install_activate() links nodejs.exe with
426+
mklink there, which exists on Windows only, so callit() is stubbed too.
427+
"""
428+
with mock.patch.object(nodeenv, 'is_WIN', True):
429+
with mock.patch.object(nodeenv, 'callit'):
430+
yield
431+
432+
433+
def _install_win(tmpdir, *extra_args):
434+
bin_dir = tmpdir.join('Scripts')
435+
if not bin_dir.check():
436+
bin_dir.mkdir()
437+
438+
argv = ['nodeenv'] + list(extra_args) + [str(tmpdir)]
439+
with mock.patch.object(sys, 'argv', argv):
440+
opts = nodeenv.parse_args()
441+
nodeenv.install_activate(str(tmpdir), opts)
442+
return bin_dir
443+
444+
445+
def test_win_writes_posix_activate(tmpdir, fake_win):
446+
bin_dir = _install_win(tmpdir)
447+
448+
assert sorted(p.basename for p in bin_dir.listdir()) == [
449+
'Activate.ps1', 'activate', 'activate.bat', 'deactivate.bat']
450+
451+
452+
def test_win_activate_puts_scripts_on_path(tmpdir, fake_win):
453+
content = _install_win(tmpdir).join('activate').read()
454+
455+
assert ('PATH="$NODE_VIRTUAL_ENV/Scripts/node_modules/.bin:'
456+
'$NODE_VIRTUAL_ENV/Scripts:$PATH"') in content
457+
458+
459+
def test_win_activate_points_node_at_scripts(tmpdir, fake_win):
460+
# npm keeps the global modules next to node.exe on Windows, there is
461+
# no lib/node_modules there
462+
content = _install_win(tmpdir).join('activate').read()
463+
464+
assert 'NODE_PATH="$NODE_VIRTUAL_ENV/Scripts/node_modules"' in content
465+
assert 'NPM_CONFIG_PREFIX="$NODE_VIRTUAL_ENV/Scripts"' in content
466+
assert 'npm_config_prefix="$NODE_VIRTUAL_ENV/Scripts"' in content
467+
468+
469+
def test_win_activate_has_no_placeholders_left(tmpdir, fake_win):
470+
content = _install_win(tmpdir).join('activate').read()
471+
472+
for placeholder in ('__NODE_VIRTUAL_PROMPT__', '__NODE_VIRTUAL_ENV__',
473+
'__SHIM_NODE__', '__BIN_NAME__', '__MOD_NAME__',
474+
'__NPM_ISOLATE__', '__NPM_UNISOLATE__',
475+
'__NPM_CONFIG_PREFIX__'):
476+
assert placeholder not in content
477+
478+
479+
def test_win_activate_converts_paths_for_node_exe(tmpdir, fake_win):
480+
# node.exe is a native binary: it cannot read the /c/... paths a
481+
# Windows shell hands out, so the script converts them back
482+
content = _install_win(tmpdir).join('activate').read()
483+
484+
assert 'CYGWIN*|MSYS*|MINGW*)' in content
485+
assert 'NODE_PATH="$(cygpath -w "$NODE_PATH")"' in content
486+
assert 'NPM_CONFIG_PREFIX="$(cygpath -w "$NPM_CONFIG_PREFIX")"' in content
487+
# the conversion must come after the variables are built
488+
assert content.index('NODE_PATH="$NODE_VIRTUAL_ENV') < \
489+
content.index('cygpath -w')
490+
491+
492+
def test_win_activate_is_valid_sh(tmpdir, fake_win):
493+
activate = _install_win(tmpdir).join('activate')
494+
495+
subprocess.check_call(['sh', '-n', str(activate)])
496+
497+
498+
def test_win_activate_refuses_to_be_run_directly(tmpdir, fake_win):
499+
activate = str(_install_win(tmpdir).join('activate'))
500+
501+
proc = subprocess.Popen(
502+
['sh', activate], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
503+
out, _ = proc.communicate()
504+
505+
assert proc.returncode == 1
506+
assert b'Do not call' in out
507+
508+
509+
def test_win_python_virtualenv_appends_to_activate(tmpdir, fake_win):
510+
# nodeenv -p inside a python venv: venv wrote Scripts/activate for
511+
# git-bash already, nodeenv has to extend it, not replace it
512+
bin_dir = tmpdir.join('Scripts')
513+
bin_dir.mkdir()
514+
bin_dir.join('activate').write('# python venv activate\n')
515+
516+
_install_win(tmpdir, '-p')
517+
518+
content = bin_dir.join('activate').read()
519+
assert content.startswith('# python venv activate\n')
520+
assert 'NODE_VIRTUAL_ENV_DISABLE_PROMPT=1' in content
521+
522+
415523
@pytest.mark.skipif(nodeenv.is_WIN, reason='system node is POSIX only')
416524
def test_isolate_npm_node_system_shim_exports(tmpdir):
417525
bin_dir = tmpdir.join('bin')

0 commit comments

Comments
 (0)