diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 83da04d4fb..ef298e8cb0 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -677,7 +677,16 @@ def _resolve_event_command_argv( # subprocess.run(shell=False); invoke via `pwsh -File` (PowerShell 7+), # falling back to `powershell -File` (Windows PowerShell) when pwsh is # absent (S6). The default Windows script type would otherwise fail. - launcher = shutil.which("pwsh") or shutil.which("powershell") or "pwsh" + # When NEITHER is on PATH, degrade to "no argv" like every other + # failure branch in this resolver (and its documented stdlib mirror, + # the generated dispatcher's `_resolve_argv`) — a bare "pwsh" here + # would make subprocess.run() raise FileNotFoundError, surfacing as a + # confusing "[Errno 2] No such file or directory: 'pwsh'" instead of + # the clean "No script found for event command" the caller reports + # for a genuinely missing script. + launcher = shutil.which("pwsh") or shutil.which("powershell") + if not launcher: + return None return [launcher, "-File", str(script_abs), *rest_args] # sh: the script is chmod'd executable during install on POSIX. On Windows diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index f74aeaaa36..ba6deb12c0 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -1340,6 +1340,37 @@ def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path): assert argv[1] == "-File" assert PurePath(argv[2]).as_posix().endswith(".specify/scripts/powershell/boot.ps1") + def test_ps_variant_returns_none_when_no_launcher_available(self, tmp_path, monkeypatch): + """When NEITHER pwsh nor powershell is on PATH, the resolver must + degrade to "no argv" like every other failure branch in this + function — not fall back to a bare "pwsh" string, which would make + subprocess.run() raise FileNotFoundError instead of the caller's + clean "No script found for event command" warning. + + The generated dispatcher's documented stdlib mirror, `_resolve_argv`, + already does this correctly (`if not launcher: return None`). + """ + from specify_cli.events import _resolve_event_command_argv + import shutil as _shutil + + cmd_dir = tmp_path / ".specify" / "templates" / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "boot.md").write_text( + "---\n" + "description: \"Boot\"\n" + "scripts:\n" + " ps: scripts/powershell/boot.ps1\n" + "---\nBody\n", + encoding="utf-8", + ) + ps_dir = tmp_path / ".specify" / "scripts" / "powershell" + ps_dir.mkdir(parents=True) + (ps_dir / "boot.ps1").write_text("exit 0\n", encoding="utf-8") + + monkeypatch.setattr(_shutil, "which", lambda name: None) + argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) + assert argv is None + def test_run_command_executes_with_project_root_cwd(self, tmp_path): """R1: the event command runs with cwd set to the project root, not the caller's arbitrary working directory, so project-relative script logic