Skip to content

fix: PATHEXT resolution on Windows for JS/TS and Python tools#492

Closed
FlorianBruniaux wants to merge 2 commits intodevelopfrom
fix/issue-212
Closed

fix: PATHEXT resolution on Windows for JS/TS and Python tools#492
FlorianBruniaux wants to merge 2 commits intodevelopfrom
fix/issue-212

Conversation

@FlorianBruniaux
Copy link
Collaborator

Summary

  • Add which crate (v8) for cross-platform binary resolution honoring PATHEXT on Windows
  • Two new helpers in utils.rs: resolve_binary() and tool_exists() replacing all Command::new("which") usages
  • Apply resolve_binary() to all JS/TS and Python tool entry points, plus the 3 central passthrough/TOML/proxy entry points in main.rs
  • Remove 4 duplicate local which_command() functions (mypy, pytest, pip, ccusage)

Root cause

On Windows, JS/TS tools (vitest, eslint, tsc, prettier, pnpm, next, playwright, prisma...) are installed as .CMD/.BAT/.PS1 wrappers. std::process::Command::new("vitest") doesn't resolve PATHEXT, so all these commands fail with "program not found". Additionally, Command::new("which") doesn't exist on Windows — it's where.

Test plan

  • cargo fmt --all && cargo clippy --all-targets — 0 errors
  • cargo test — 762 tests pass
  • 4 new unit tests for resolve_binary() and tool_exists() helpers
  • Manual smoke test: rtk git status works
  • Windows validation by issue reporter (@F0rty-Tw0)

Fixes #212

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings March 10, 2026 23:21
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Windows command spawning failures by resolving tool binaries in a cross-platform way that honors PATHEXT (e.g., .CMD/.BAT shims for Node/Python tools), replacing prior reliance on the external which executable.

Changes:

  • Add which crate and introduce utils::resolve_binary() + utils::tool_exists() as the canonical binary resolution/check mechanism.
  • Update JS/TS, Python, and central passthrough/proxy entry points to spawn commands via resolve_binary() (and replace ad-hoc which checks).
  • Remove duplicated per-command which_command() helpers and update docs/version references to 0.28.2.

Reviewed changes

Copilot reviewed 21 out of 22 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/utils.rs Adds resolve_binary() / tool_exists() helpers and unit tests; updates package_manager_exec() to use them
src/tsc_cmd.rs Uses tool_exists() + resolve_binary() for tsc/npx
src/tree.rs Replaces which tree check and spawns tree via resolved binary
src/ruff_cmd.rs Spawns ruff via resolved binary
src/pytest_cmd.rs Uses tool_exists() + resolve_binary() and removes local which_command()
src/prisma_cmd.rs Uses tool_exists() + resolve_binary() for prisma/npx
src/pnpm_cmd.rs Spawns pnpm via resolved binary across subcommands/passthrough
src/playwright_cmd.rs Spawns pnpm/yarn/npx via resolved binary
src/pip_cmd.rs Uses tool_exists() + resolve_binary() and removes local which_command()
src/npm_cmd.rs Spawns npm via resolved binary
src/next_cmd.rs Uses tool_exists() + resolve_binary() for next/npx
src/mypy_cmd.rs Uses tool_exists() + resolve_binary() and removes local which_command()
src/main.rs Applies resolve_binary() to fallback execution, proxy mode, and some npx paths
src/lint_cmd.rs Spawns python linters via resolved binary
src/golangci_cmd.rs Spawns golangci-lint via resolved binary
src/format_cmd.rs Spawns non-JS formatters via resolved binary
src/ccusage.rs Uses tool_exists() + resolve_binary() for ccusage/npx fallback
README.md Updates expected rtk --version output
Cargo.toml Bumps version and adds which = "8" dependency
Cargo.lock Locks which dependency
CLAUDE.md Updates expected rtk --version output
ARCHITECTURE.md Updates referenced RTK version

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +408 to +432
#[test]
fn test_resolve_binary_known() {
// cargo exists on all dev machines
let resolved = resolve_binary("cargo");
assert!(!resolved.is_empty());
}

#[test]
fn test_resolve_binary_unknown() {
let resolved = resolve_binary("nonexistent_tool_xyz_99999");
assert_eq!(
resolved,
std::ffi::OsString::from("nonexistent_tool_xyz_99999")
);
}

#[test]
fn test_tool_exists_positive() {
assert!(tool_exists("cargo"));
}

#[test]
fn test_tool_exists_negative() {
assert!(!tool_exists("nonexistent_tool_xyz_99999"));
}
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new unit tests assume cargo is discoverable on PATH (via tool_exists("cargo")). That’s not guaranteed in all test runners (e.g., if cargo test is invoked via an absolute path and PATH doesn’t include the cargo bin dir), which can make these tests flaky. Consider making the tests self-contained by creating a temporary executable/shim in a temp dir, prepending that dir to PATH (and on Windows using a .cmd file to exercise PATHEXT), then asserting tool_exists() and resolve_binary() behave as expected.

Copilot uses AI. Check for mistakes.
Comment on lines 11 to 18
// Try tsc directly first, fallback to npx if not found
let tsc_exists = Command::new("which")
.arg("tsc")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let tsc_exists = tool_exists("tsc");

let mut cmd = if tsc_exists {
Command::new("tsc")
Command::new(resolve_binary("tsc"))
} else {
let mut c = Command::new("npx");
let mut c = Command::new(resolve_binary("npx"));
c.arg("tsc");
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pattern does two PATH scans (tool_exists("tsc") and then resolve_binary("tsc"), both calling which::which). Consider avoiding the duplicate lookup by calling the resolver once (e.g., a helper that returns Option<OsString>/PathBuf), and branching on that result.

Copilot uses AI. Check for mistakes.
FlorianBruniaux and others added 2 commits March 11, 2026 00:35
Add `which` crate (v8) and two central helpers in utils.rs:
- `resolve_binary(name)` resolves a binary to its full path, honoring
  PATHEXT on Windows so `.CMD`/`.BAT`/`.PS1` wrappers are found
- `tool_exists(name)` replaces all `Command::new("which")` usages
  (which does not exist on Windows) with a cross-platform check

Apply `resolve_binary()` to:
- All JS/TS tool entry points (tsc, next, prisma, playwright, npm, pnpm,
  ruff, golangci-lint, vitest via package_manager_exec)
- Python tool entry points (mypy, pytest, pip/uv)
- The 3 central passthrough/TOML/proxy entry points in main.rs
- `package_manager_exec()` in utils.rs (covers vitest, prettier, lint,
  format, biome automatically)

Remove 4 local `which_command()` duplicates (mypy_cmd, pytest_cmd,
pip_cmd, ccusage) and the `binary_exists()` helper in ccusage.

Fixes: #212

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
@F0rty-Tw0
Copy link

Nice work.
What about #269 ? Are we closing it?

Copy link
Collaborator

@pszymkowiak pszymkowiak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok reviewed

@pszymkowiak
Copy link
Collaborator

Great work @FlorianBruniaux! Clean and well-scoped fix. A few things to address:

P1 — go_cmd.rs not updated
4 Command::new("go") calls at lines 43, 102, 155, 213 were not updated to resolve_binary("go"). Not broken today since go ships as .exe, but breaks the consistency of the fix.

P1 — cargo_cmd.rs not updated
Same situation — 2 Command::new("cargo") calls. Single-line fix for uniformity.

P1 — Double PATH scan pattern
Several modules call tool_exists(name) then immediately resolve_binary(name) in the taken branch — that's two which::which() calls. Appears in next_cmd.rs, tsc_cmd.rs, mypy_cmd.rs, pytest_cmd.rs, prisma_cmd.rs, ccusage.rs. Idiomatic fix:

let mut cmd = match which::which("next") {
    Ok(path) => Command::new(path),
    Err(_) => {
        let mut c = Command::new(resolve_binary("npx"));
        c.arg("next");
        c
    }
};

P2 — test_resolve_binary_known could be stronger
Currently asserts !resolved.is_empty(). Could assert path.is_absolute() to verify actual resolution vs fallback.

Otherwise LGTM — good crate choice, DRY cleanup (4 duplicate which_command() removed), correct fallback semantics. Ready to merge after P1 fixes.

@pszymkowiak
Copy link
Collaborator

Hey @FlorianBruniaux, closing this in favor of #269 by @F0rty-Tw0 which addresses the same issue and was submitted first (Feb 26). It was already reviewed and LGTM'd. Sorry about the duplicate — we have too many PRs in flight right now and this one slipped through. Thanks for understanding!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants