From dd10653fef8dcb5b7f73fa8b5b602c9deafe50cc Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 2 Oct 2025 09:41:10 +0000 Subject: [PATCH 1/5] fix(model): implement custom lookPath for node package managers Replace exec.LookPath with a custom lookPath function that checks common installation locations for bunx and npx executables. This fixes the issue where the daemon service cannot find these executables because the PATH environment variable doesn't include user-specific Node.js installation paths. The custom lookPath function: - First tries standard exec.LookPath (system PATH) - Falls back to checking common locations (npm, pnpm, bun, nvm) - Supports multiple platforms (Windows, macOS, Linux) - Checks for executable permissions on Unix-like systems Fixes #112 Co-authored-by: Le He --- model/ccusage_service.go | 94 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 91 insertions(+), 3 deletions(-) diff --git a/model/ccusage_service.go b/model/ccusage_service.go index e76f761..6de765e 100644 --- a/model/ccusage_service.go +++ b/model/ccusage_service.go @@ -9,6 +9,8 @@ import ( "os" "os/exec" "os/user" + "path/filepath" + "runtime" "time" ) @@ -207,11 +209,97 @@ func (s *ccUsageService) getLastSyncTimestamp(ctx context.Context, endpoint Endp return lastSyncAt, nil } +// lookPath searches for an executable in common locations, falling back to system PATH. +// This is necessary because when running as a daemon service, the PATH environment +// variable may not include user-specific Node.js installation paths. +func lookPath(name string) (string, error) { + // First, try the standard exec.LookPath which checks system PATH + if path, err := exec.LookPath(name); err == nil { + return path, nil + } + + // Get the current user's home directory + currentUser, err := user.Current() + if err != nil { + return "", fmt.Errorf("%s not found in PATH and unable to get user home directory: %w", name, err) + } + homeDir := currentUser.HomeDir + + // Common installation locations for node package managers + var searchPaths []string + + if runtime.GOOS == "windows" { + // Windows paths + searchPaths = []string{ + filepath.Join(homeDir, "AppData", "Roaming", "npm", name+".cmd"), + filepath.Join(homeDir, "AppData", "Roaming", "npm", name+".ps1"), + filepath.Join(homeDir, ".bun", "bin", name+".exe"), + filepath.Join(homeDir, ".bun", "bin", name), + filepath.Join(os.Getenv("ProgramFiles"), "nodejs", name+".cmd"), + filepath.Join(os.Getenv("ProgramFiles(x86)"), "nodejs", name+".cmd"), + } + } else { + // Unix-like systems (Linux, macOS, etc.) + searchPaths = []string{ + // User-specific npm global installations + filepath.Join(homeDir, ".npm-global", "bin", name), + filepath.Join(homeDir, ".npm", "bin", name), + // User-specific pnpm installation + filepath.Join(homeDir, ".local", "share", "pnpm", name), + // Bun installation + filepath.Join(homeDir, ".bun", "bin", name), + // NVM (Node Version Manager) current version + filepath.Join(homeDir, ".nvm", "current", "bin", name), + // Homebrew on macOS (Intel) + "/usr/local/bin/" + name, + // Homebrew on macOS (Apple Silicon) + "/opt/homebrew/bin/" + name, + // Common system paths + "/usr/bin/" + name, + "/bin/" + name, + } + + // Add Node.js versions from nvm if NVM_DIR is set + if nvmDir := os.Getenv("NVM_DIR"); nvmDir != "" { + // Try to find the default/current version + searchPaths = append(searchPaths, + filepath.Join(nvmDir, "current", "bin", name), + filepath.Join(nvmDir, "versions", "node", "*", "bin", name), + ) + } + } + + // Search each path + for _, path := range searchPaths { + // Handle glob patterns (like nvm versions) + if matches, err := filepath.Glob(path); err == nil && len(matches) > 0 { + // Use the first match + path = matches[0] + } + + // Check if the file exists and is executable + if info, err := os.Stat(path); err == nil { + if !info.IsDir() { + // On Unix-like systems, check if it's executable + if runtime.GOOS != "windows" { + if info.Mode()&0111 == 0 { + continue + } + } + slog.Debug("Found executable", "name", name, "path", path) + return path, nil + } + } + } + + return "", fmt.Errorf("%s not found in PATH or common installation locations", name) +} + // collectData collects usage data using bunx or npx ccusage command func (s *ccUsageService) collectData(ctx context.Context, since time.Time) (*CCUsageData, error) { - // Check if bunx exists - bunxPath, bunxErr := exec.LookPath("bunx") - npxPath, npxErr := exec.LookPath("npx") + // Check if bunx exists using custom lookPath that checks common installation locations + bunxPath, bunxErr := lookPath("bunx") + npxPath, npxErr := lookPath("npx") if bunxErr != nil && npxErr != nil { return nil, fmt.Errorf("neither bunx nor npx found in system PATH") From 78617246fb9a609c9b1a39c0c6438eb14ced0014 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 2 Oct 2025 09:51:41 +0000 Subject: [PATCH 2/5] fix(model): add fnm support to custom lookPath Add Fast Node Manager (fnm) installation paths to the custom lookPath function. This includes both standard fnm locations (~/.local/share/fnm and ~/.fnm) as well as support for the FNM_DIR environment variable. The implementation mirrors the existing NVM support, searching for executables in fnm's node-versions subdirectories. Co-authored-by: Le He --- model/ccusage_service.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/model/ccusage_service.go b/model/ccusage_service.go index 6de765e..bb511e3 100644 --- a/model/ccusage_service.go +++ b/model/ccusage_service.go @@ -250,6 +250,9 @@ func lookPath(name string) (string, error) { filepath.Join(homeDir, ".bun", "bin", name), // NVM (Node Version Manager) current version filepath.Join(homeDir, ".nvm", "current", "bin", name), + // fnm (Fast Node Manager) installations + filepath.Join(homeDir, ".local", "share", "fnm", "node-versions", "*", "installation", "bin", name), + filepath.Join(homeDir, ".fnm", "node-versions", "*", "installation", "bin", name), // Homebrew on macOS (Intel) "/usr/local/bin/" + name, // Homebrew on macOS (Apple Silicon) @@ -267,6 +270,13 @@ func lookPath(name string) (string, error) { filepath.Join(nvmDir, "versions", "node", "*", "bin", name), ) } + + // Add Node.js versions from fnm if FNM_DIR is set + if fnmDir := os.Getenv("FNM_DIR"); fnmDir != "" { + searchPaths = append(searchPaths, + filepath.Join(fnmDir, "node-versions", "*", "installation", "bin", name), + ) + } } // Search each path From 7cc3a790d940f50f5024f75c100cd257571df2e7 Mon Sep 17 00:00:00 2001 From: Le He Date: Thu, 2 Oct 2025 23:12:11 +0800 Subject: [PATCH 3/5] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- model/ccusage_service.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/model/ccusage_service.go b/model/ccusage_service.go index bb511e3..0b45c2b 100644 --- a/model/ccusage_service.go +++ b/model/ccusage_service.go @@ -254,12 +254,12 @@ func lookPath(name string) (string, error) { filepath.Join(homeDir, ".local", "share", "fnm", "node-versions", "*", "installation", "bin", name), filepath.Join(homeDir, ".fnm", "node-versions", "*", "installation", "bin", name), // Homebrew on macOS (Intel) - "/usr/local/bin/" + name, + filepath.Join("/usr/local/bin", name), // Homebrew on macOS (Apple Silicon) - "/opt/homebrew/bin/" + name, + filepath.Join("/opt/homebrew/bin", name), // Common system paths - "/usr/bin/" + name, - "/bin/" + name, + filepath.Join("/usr/bin", name), + filepath.Join("/bin", name), } // Add Node.js versions from nvm if NVM_DIR is set From 4d0693cabf0dfe40859c9856f476ea17f5ba120e Mon Sep 17 00:00:00 2001 From: Le He Date: Thu, 2 Oct 2025 23:12:25 +0800 Subject: [PATCH 4/5] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- model/ccusage_service.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/model/ccusage_service.go b/model/ccusage_service.go index 0b45c2b..7188aa9 100644 --- a/model/ccusage_service.go +++ b/model/ccusage_service.go @@ -283,8 +283,9 @@ func lookPath(name string) (string, error) { for _, path := range searchPaths { // Handle glob patterns (like nvm versions) if matches, err := filepath.Glob(path); err == nil && len(matches) > 0 { - // Use the first match - path = matches[0] + // Use the last match, which is likely to be the latest version + // since Glob returns a sorted list. + path = matches[len(matches)-1] } // Check if the file exists and is executable From 976c6666ef864d78247cb9a90bd0f75877b91337 Mon Sep 17 00:00:00 2001 From: Le He Date: Thu, 2 Oct 2025 23:12:36 +0800 Subject: [PATCH 5/5] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- model/ccusage_service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model/ccusage_service.go b/model/ccusage_service.go index 7188aa9..c6e5108 100644 --- a/model/ccusage_service.go +++ b/model/ccusage_service.go @@ -313,7 +313,7 @@ func (s *ccUsageService) collectData(ctx context.Context, since time.Time) (*CCU npxPath, npxErr := lookPath("npx") if bunxErr != nil && npxErr != nil { - return nil, fmt.Errorf("neither bunx nor npx found in system PATH") + return nil, fmt.Errorf("neither bunx nor npx found in system PATH or common installation locations") } // Build command arguments