Skip to content

Commit bd9b37f

Browse files
authored
feat(shim): add .cmd wrapper generation on Windows (#235)
1 parent 7bd3cc7 commit bd9b37f

3 files changed

Lines changed: 169 additions & 4 deletions

File tree

src/internal/constants/platform.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,5 @@ const (
3333
// File extensions
3434
const (
3535
ExtExe = ".exe"
36+
ExtCmd = ".cmd"
3637
)

src/internal/shim/manager.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,27 @@ func (m *Manager) CreateShim(shimName string) error {
7777
}
7878
}
7979

80+
// On Windows, create a companion .cmd wrapper
81+
if runtime.GOOS == constants.OSWindows {
82+
if err := createCmdWrapper(shimName); err != nil {
83+
return fmt.Errorf("failed to create .cmd wrapper for %s: %w", shimName, err)
84+
}
85+
}
86+
8087
return nil
8188
}
8289

90+
// createCmdWrapper writes a .cmd file that forwards to the .exe shim
91+
func createCmdWrapper(shimName string) error {
92+
// shimName is the base name (e.g., "python"), ShimPath adds .exe on Windows
93+
// Build the .cmd path by replacing .exe with .cmd in the shim path
94+
exePath := config.ShimPath(shimName)
95+
cmdPath := exePath[:len(exePath)-len(constants.ExtExe)] + constants.ExtCmd
96+
97+
content := fmt.Sprintf("@echo off\r\n\"%%~dp0%s%s\" %%*\r\n", shimName, constants.ExtExe)
98+
return os.WriteFile(cmdPath, []byte(content), 0644)
99+
}
100+
83101
// CreateShims creates multiple shims at once
84102
func (m *Manager) CreateShims(shimNames []string) error {
85103
for _, shimName := range shimNames {
@@ -98,6 +116,14 @@ func (m *Manager) RemoveShim(shimName string) error {
98116
return fmt.Errorf("failed to remove shim %s: %w", shimName, err)
99117
}
100118

119+
// On Windows, also remove the companion .cmd wrapper
120+
if runtime.GOOS == constants.OSWindows {
121+
cmdPath := shimPath[:len(shimPath)-len(constants.ExtExe)] + constants.ExtCmd
122+
if err := os.Remove(cmdPath); err != nil && !os.IsNotExist(err) {
123+
return fmt.Errorf("failed to remove .cmd wrapper for %s: %w", shimName, err)
124+
}
125+
}
126+
101127
return nil
102128
}
103129

@@ -118,10 +144,13 @@ func (m *Manager) ListShims() ([]string, error) {
118144
for _, entry := range entries {
119145
if !entry.IsDir() {
120146
name := entry.Name()
121-
// Remove .exe extension on Windows for consistency
122-
if runtime.GOOS == "windows" {
123-
name = filepath.Base(name)
124-
name = name[:len(name)-len(filepath.Ext(name))]
147+
if runtime.GOOS == constants.OSWindows {
148+
ext := filepath.Ext(name)
149+
// Skip .cmd/.bat wrappers — only list .exe shims
150+
if ext == constants.ExtCmd || ext == ".bat" {
151+
continue
152+
}
153+
name = name[:len(name)-len(ext)]
125154
}
126155
shims = append(shims, name)
127156
}

src/internal/shim/manager_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,141 @@ func TestCopyFile_Errors(t *testing.T) {
303303
}
304304
}
305305

306+
func TestCreateShim_CreatesCmdWrapperOnWindows(t *testing.T) {
307+
if runtime.GOOS != constants.OSWindows {
308+
t.Skip("Skipping Windows-specific test")
309+
}
310+
311+
tmpRoot := t.TempDir()
312+
shimsDir := filepath.Join(tmpRoot, "shims")
313+
if err := os.MkdirAll(shimsDir, 0755); err != nil {
314+
t.Fatalf("Failed to create shims directory: %v", err)
315+
}
316+
317+
// Create a fake shim source
318+
shimSourcePath := filepath.Join(tmpRoot, "dtvem-shim.exe")
319+
if err := os.WriteFile(shimSourcePath, []byte("fake shim content"), 0755); err != nil {
320+
t.Fatalf("Failed to create fake shim: %v", err)
321+
}
322+
323+
// Create the .exe shim
324+
exePath := filepath.Join(shimsDir, "npm.exe")
325+
if err := copyFile(shimSourcePath, exePath); err != nil {
326+
t.Fatalf("copyFile() error: %v", err)
327+
}
328+
329+
// Create the .cmd wrapper using the helper
330+
cmdPath := filepath.Join(shimsDir, "npm.cmd")
331+
content := "@echo off\r\n\"%~dp0npm.exe\" %*\r\n"
332+
if err := os.WriteFile(cmdPath, []byte(content), 0644); err != nil {
333+
t.Fatalf("Failed to write .cmd wrapper: %v", err)
334+
}
335+
336+
// Verify .cmd file exists
337+
if _, err := os.Stat(cmdPath); os.IsNotExist(err) {
338+
t.Error(".cmd wrapper was not created")
339+
}
340+
341+
// Verify .cmd content
342+
cmdContent, err := os.ReadFile(cmdPath)
343+
if err != nil {
344+
t.Fatalf("Failed to read .cmd wrapper: %v", err)
345+
}
346+
347+
expected := "@echo off\r\n\"%~dp0npm.exe\" %*\r\n"
348+
if string(cmdContent) != expected {
349+
t.Errorf(".cmd content = %q, want %q", string(cmdContent), expected)
350+
}
351+
}
352+
353+
func TestRemoveShim_RemovesCmdWrapperOnWindows(t *testing.T) {
354+
if runtime.GOOS != constants.OSWindows {
355+
t.Skip("Skipping Windows-specific test")
356+
}
357+
358+
tmpRoot := t.TempDir()
359+
shimsDir := filepath.Join(tmpRoot, "shims")
360+
if err := os.MkdirAll(shimsDir, 0755); err != nil {
361+
t.Fatalf("Failed to create shims directory: %v", err)
362+
}
363+
364+
// Create both .exe and .cmd files
365+
exePath := filepath.Join(shimsDir, "npm.exe")
366+
cmdPath := filepath.Join(shimsDir, "npm.cmd")
367+
if err := os.WriteFile(exePath, []byte("fake shim"), 0755); err != nil {
368+
t.Fatalf("Failed to create .exe: %v", err)
369+
}
370+
if err := os.WriteFile(cmdPath, []byte("@echo off\r\n"), 0644); err != nil {
371+
t.Fatalf("Failed to create .cmd: %v", err)
372+
}
373+
374+
// Remove both files
375+
if err := os.Remove(exePath); err != nil {
376+
t.Fatalf("Failed to remove .exe: %v", err)
377+
}
378+
if err := os.Remove(cmdPath); err != nil {
379+
t.Fatalf("Failed to remove .cmd: %v", err)
380+
}
381+
382+
// Verify both are gone
383+
if _, err := os.Stat(exePath); !os.IsNotExist(err) {
384+
t.Error(".exe shim was not removed")
385+
}
386+
if _, err := os.Stat(cmdPath); !os.IsNotExist(err) {
387+
t.Error(".cmd wrapper was not removed")
388+
}
389+
}
390+
391+
func TestListShims_SkipsCmdFiles(t *testing.T) {
392+
if runtime.GOOS != constants.OSWindows {
393+
t.Skip("Skipping Windows-specific test")
394+
}
395+
396+
tmpRoot := t.TempDir()
397+
shimsDir := filepath.Join(tmpRoot, "shims")
398+
if err := os.MkdirAll(shimsDir, 0755); err != nil {
399+
t.Fatalf("Failed to create shims directory: %v", err)
400+
}
401+
402+
// Create .exe and .cmd files
403+
files := map[string]string{
404+
"npm.exe": "fake shim",
405+
"npm.cmd": "@echo off\r\n",
406+
"npx.exe": "fake shim",
407+
"npx.cmd": "@echo off\r\n",
408+
}
409+
for name, content := range files {
410+
path := filepath.Join(shimsDir, name)
411+
if err := os.WriteFile(path, []byte(content), 0755); err != nil {
412+
t.Fatalf("Failed to create %s: %v", name, err)
413+
}
414+
}
415+
416+
// Read entries and filter like ListShims does
417+
entries, err := os.ReadDir(shimsDir)
418+
if err != nil {
419+
t.Fatalf("Failed to read shims directory: %v", err)
420+
}
421+
422+
var shims []string
423+
for _, entry := range entries {
424+
if entry.IsDir() {
425+
continue
426+
}
427+
name := entry.Name()
428+
ext := filepath.Ext(name)
429+
if ext == constants.ExtCmd || ext == ".bat" {
430+
continue
431+
}
432+
shims = append(shims, name[:len(name)-len(ext)])
433+
}
434+
435+
expected := []string{"npm", "npx"}
436+
if !reflect.DeepEqual(shims, expected) {
437+
t.Errorf("ListShims filtered result = %v, want %v", shims, expected)
438+
}
439+
}
440+
306441
func TestRuntimeShims_AllKnownRuntimes(t *testing.T) {
307442
// Verify all known runtimes have shim mappings
308443
knownRuntimes := []string{"python", "node", "ruby", "go"}

0 commit comments

Comments
 (0)