Skip to content

fix(shim): handle Windows .exe case + seed shim-map at install#245

Merged
CalvinAllen merged 3 commits intomainfrom
fix/shim/windows-case-and-install-seed
Apr 23, 2026
Merged

fix(shim): handle Windows .exe case + seed shim-map at install#245
CalvinAllen merged 3 commits intomainfrom
fix/shim/windows-case-and-install-seed

Conversation

@CalvinAllen
Copy link
Copy Markdown
Contributor

Summary

Two related shim-resolution bugs surfaced by running Python-driven tools (mmdc invoked via subprocess by shutil.which-resolved paths) against a globally npm install -g-ed mermaid-cli:

  1. Case-sensitive .exe strippinggetShimName used strings.TrimSuffix(shimName, ".exe") on os.Args[0]. Windows command resolution via PATHEXT (which defaults to uppercase .EXE on many systems) causes Python's shutil.which — and any other tool that preserves the case returned by PATHEXT-based path lookup — to produce paths like mmdc.EXE. The uppercase extension survived TrimSuffix, so the shim name passed downstream was "mmdc.EXE". That key was absent from the shim-map cache (lowercased at build time), the provider-registry prefix match (HasPrefix("mmdc.EXE", "node"/"npm"/"npx") all false), and the provider lookup (no registered mmdc.EXE provider) — producing the misleading runtime provider not found: runtime provider 'mmdc.EXE' not found error for a command that was otherwise correctly installed and shimmed.

  2. Install-time shim-map gap — runtime providers' createShims() wrote the shim binaries for the runtime's core commands (node/npm/npx, python/pip/..., ruby/gem/...) but did not update cache/shim-map.json. SaveShimMap was only ever called from the full Rehash path. In practice this was papered over by mapShimToRuntime's provider-fallback — npm resolved via Node.Shims() membership even when the cache didn't know it — but the fallback only matches core shim names and breaks down the moment a globally installed npm package (e.g., mmdc) gets invoked before any subsequent reshim has run.

Changes

Fix 1 — fix(shim): strip .exe suffix case-insensitively when resolving shim name (cdb883c)

  • Refactor getShimNameshimNameFromPath (pure function, testable).
  • Use filepath.Ext + strings.EqualFold(ext, ".exe") so .exe / .EXE / .Exe all strip.
  • Non-.exe extensions (.bat, .cmd) are preserved unchanged.
  • New src/cmd/shim/main_test.go with 7-case table-driven test (unix bare, lowercase/uppercase/mixed-case .exe, forward-slash paths, bare names, non-.exe preservation).

Fix 2 — fix(shim): seed shim-map cache during runtime install (7e2ec38)

  • shim.MergeShimMap(entries ShimMap) — merges entries into the on-disk map, creating it when absent; resets the in-memory cache so subsequent LoadShimMap reads the merged state. Preferred over Rehash() at install time because it doesn't re-scan every runtime's bin directory.
  • Manager.CreateShimsForRuntime(runtimeName, shimNames) — creates shim files and registers them in the cache in one call, keeping the two representations in sync by construction.
  • node, python, and ruby provider createShims() all switched from bare CreateShims to CreateShimsForRuntime.
  • Bare CreateShims retained for tests and any future caller that only needs file creation.
  • 4 new TestMergeShimMap_* tests covering fresh cache creation, disjoint merge, overwrite semantics, and in-memory cache invalidation.

Why two fixes, not one

Each is necessary but not sufficient. Fix 1 alone still leaves users broken on a cold install (cache is empty until the first successful reshim, and Python-invoked shims can't rely on the provider-prefix fallback when the caller is a non-core binary like mmdc). Fix 2 alone still leaves Python-driven invocations broken even after the cache is correctly populated, because case-sensitive extension stripping yields a cache-miss key. Both bugs have to be fixed to get Python-driven Windows tooling working reliably against dtvem shims.

Test plan

  • go test ./src/internal/shim/... ./src/cmd/shim/... — all tests pass (11 new cases: 7 TestShimNameFromPath + 4 TestMergeShimMap_*)
  • ./rnr check (format, lint, full test suite) — exit 0
  • Manual Windows repro: on a clean dtvem install, dtvem install node <ver>, inspect ~/.dtvem/cache/shim-map.json — core node/npm/npx entries should appear immediately (previously would only appear after first reshim)
  • Manual Windows repro: npm install -g @mermaid-js/mermaid-cli && dtvem reshim && python -c "import subprocess; subprocess.run(['mmdc', '--version'])" — should succeed (previously failed with runtime provider 'mmdc.EXE' not found because Python's shutil.which returns uppercase .EXE)

Scope notes

  • No behavior change on Unix — filepath.Ext on unix-style shim paths returns "", EqualFold("", ".exe") is false, strip is a no-op.
  • No API break — CreateShims signature unchanged; CreateShimsForRuntime is additive.
  • Extension stripping only — the base shim name is not lowercased. If other case sensitivities surface (e.g., users manually typing MMDC), that's a separate consideration and likely belongs in mapShimToRuntime rather than getShimName.

On Windows, getShimName read os.Args[0] and stripped ".exe" via byte-exact
strings.TrimSuffix. This broke whenever the invoker passed an uppercase
extension — most notably Python's shutil.which, which returns paths like
"mmdc.EXE" when the system PATHEXT contains ".EXE" (the common default on
Windows). With the uppercase extension left attached, the shim name fell
through the shim-map cache lookup (keyed by lowercase base names) AND the
provider prefix-match (Shims() returns lowercase), and dtvem-shim surfaced
a misleading "runtime provider not found: runtime provider 'mmdc.EXE' not
found" error for a command that was otherwise correctly installed.

Extract shimNameFromPath as a pure function and use filepath.Ext +
strings.EqualFold to strip .exe / .EXE / .Exe regardless of case. Add a
table-driven test that covers lowercase, uppercase, mixed-case, bare names,
and non-.exe extensions (which must be preserved).

Note: only the extension stripping is case-normalized — the base shim name
itself is left as-is, consistent with the existing behavior of downstream
lookups.
Runtime providers' createShims() only wrote shim files to disk — they did
not update the shim-map cache. That left an install-time gap where the
shim files for node/npm/npx (and python/pip, ruby/gem, etc.) existed, but
the cache entry mapping them back to their runtime did not.

In practice the gap was papered over by dtvem-shim's provider-prefix
fallback in mapShimToRuntime: calling "npm" still resolved to "node"
because the Node provider's Shims() list contains "npm". But the moment
something invoked a shim whose name wasn't on any provider's Shims() list
(e.g., a globally-installed npm package like mmdc), and the cache hadn't
yet been rebuilt by "dtvem reshim" or the post-install reshim prompt, the
shim errored with "runtime provider not found" — even though the shim file
had been placed correctly.

Fix by registering the core shims in the cache at install time:

  - Add MergeShimMap(entries) to cache.go — merges new entries into the
    on-disk map, creating it if absent, and resets the in-memory cache so
    subsequent LoadShimMap reflects the merged state. Preferred over
    Rehash() for install-time registration because it doesn't require
    re-scanning every installed runtime's bin directory.

  - Add Manager.CreateShimsForRuntime(runtimeName, shimNames) — creates
    the shim files AND calls MergeShimMap so files and cache stay in sync.

  - Update node, python, and ruby provider createShims() to use
    CreateShimsForRuntime instead of bare CreateShims.

Bare CreateShims is kept for any caller that genuinely only wants the shim
files (for example, tests that don't care about the cache), but the
in-repo runtime providers all route through the new method.

Tests:
  - 4 new MergeShimMap tests covering fresh cache creation, disjoint
    merge, overwrite semantics, and in-memory cache invalidation.
  - CreateShimsForRuntime is covered compositionally by existing shim
    creation tests + the new MergeShimMap tests, matching the codebase's
    convention of testing primitives rather than the manager layer
    (which requires os.Executable() plumbing).
shimNameFromPath used filepath.Base, which only recognizes the host
OS separator. On Linux, Windows-style test paths failed to split,
leaving the directory attached to the name.
@CalvinAllen CalvinAllen merged commit 94f3c88 into main Apr 23, 2026
12 checks passed
@CalvinAllen CalvinAllen deleted the fix/shim/windows-case-and-install-seed branch April 23, 2026 18:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant