From ab87b61bbab55836d01a2186a3aed573b1bc19cb Mon Sep 17 00:00:00 2001 From: Rashid Razak Date: Wed, 13 May 2026 12:18:17 +0800 Subject: [PATCH] Fix menubar showing empty data after reboot when CLI is installed via fnm/nvm Login-item launches don't source .zshrc, leaving version-manager bin directories (fnm, nvm, volta, asdf) absent from PATH. The menubar's augmentedPath only covered /opt/homebrew/bin and /usr/local/bin, so codeburn was never found after a cold reboot. - Add discoverNodeManagerBinDirs() that dynamically scans for fnm, nvm, volta, and asdf installations and adds the latest Node version's bin directory to PATH - Add PATH logging to DataClient spawn error for easier future diagnosis - Log the swallowed error in hydrateCache() catch block so silent cache-empty failures are visible in stderr - Add scripts/diagnose-menubar-cli.sh for testing restricted-PATH CLI execution without rebuilding the menubar app --- .../CodeBurnMenubar/Data/DataClient.swift | 2 + .../Security/CodeburnCLI.swift | 58 +++++++++++++++ scripts/diagnose-menubar-cli.sh | 73 +++++++++++++++++++ src/cli.ts | 5 +- 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100755 scripts/diagnose-menubar-cli.sh diff --git a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift index 4b0083c..c3bcdb4 100644 --- a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift +++ b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift @@ -58,6 +58,8 @@ struct DataClient { do { try process.run() } catch { + let path = ProcessInfo.processInfo.environment["PATH"] ?? "(no PATH)" + NSLog("CodeBurn: CLI spawn failed. PATH=%@ error=%@", path, error.localizedDescription) throw DataClientError.spawn(error.localizedDescription) } diff --git a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift index 4f4a5f8..88a4e3c 100644 --- a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift +++ b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift @@ -59,6 +59,64 @@ enum CodeburnCLI { for extra in additionalPathEntries where !parts.contains(extra) { parts.append(extra) } + for dir in discoverNodeManagerBinDirs() where !parts.contains(dir) { + parts.append(dir) + } return parts.joined(separator: ":") } + + /// Login-item launches don't source .zshrc, so nvm / fnm / volta / asdf bin + /// directories are absent from PATH. Scan common version-manager locations + /// and add the latest Node version's bin dir so `codeburn` can be found. + private static func discoverNodeManagerBinDirs() -> [String] { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let fm = FileManager.default + + // fnm: ~/.local/share/fnm/node-versions//installation/bin + let fnmVersionsDir = "\(home)/.local/share/fnm/node-versions" + if let latest = latestVersionDir(in: fnmVersionsDir) { + let binDir = "\(fnmVersionsDir)/\(latest)/installation/bin" + if fm.fileExists(atPath: "\(binDir)/node") { + return [binDir] + } + } + + // nvm: ~/.nvm/versions/node//bin + let nvmVersionsDir = "\(home)/.nvm/versions/node" + if let latest = latestVersionDir(in: nvmVersionsDir) { + let binDir = "\(nvmVersionsDir)/\(latest)/bin" + if fm.fileExists(atPath: "\(binDir)/node") { + return [binDir] + } + } + + // volta: ~/.volta/bin (flat, no version dirs) + let voltaBin = "\(home)/.volta/bin" + if fm.fileExists(atPath: "\(voltaBin)/node") { + return [voltaBin] + } + + // asdf: ~/.asdf/shims (flat shim dir) + let asdfShims = "\(home)/.asdf/shims" + if fm.fileExists(atPath: "\(asdfShims)/node") { + return [asdfShims] + } + + return [] + } + + /// Returns the latest version directory name (e.g. "v22.15.0") from a + /// parent directory containing version-named subdirectories. + private static func latestVersionDir(in parent: String) -> String? { + let fm = FileManager.default + var isDir: ObjCBool = false + guard fm.fileExists(atPath: parent, isDirectory: &isDir), isDir.boolValue, + let entries = try? fm.contentsOfDirectory(atPath: parent) else { + return nil + } + return entries + .filter { $0.hasPrefix("v") } + .sorted() + .last + } } diff --git a/scripts/diagnose-menubar-cli.sh b/scripts/diagnose-menubar-cli.sh new file mode 100755 index 0000000..8e171e6 --- /dev/null +++ b/scripts/diagnose-menubar-cli.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Replicates the menubar's restricted PATH environment to test if the CLI +# can find and run codeburn with the same PATH the menubar provides. +# +# The menubar augments PATH with: /opt/homebrew/bin /usr/local/bin +# The base PATH for a Login Item is typically: /usr/bin:/bin:/usr/sbin:/sbin + +set -euo pipefail + +RESTRICTED_PATH="/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin" + +echo "=== Menubar PATH Diagnostic ===" +echo "" +echo "Using restricted PATH: $RESTRICTED_PATH" +echo "" + +# 1. Check if codeburn is found +echo "--- Step 1: Locate codeburn binary ---" +FOUND=$(PATH="$RESTRICTED_PATH" /usr/bin/env which codeburn 2>&1 || true) +if [ -z "$FOUND" ]; then + echo "FAIL: codeburn not found in restricted PATH" + echo "" + echo "Where codeburn actually is:" + /usr/bin/env which -a codeburn 2>/dev/null || echo "(not found anywhere)" + echo "" + echo "Fix: codeburn is installed outside the menubar's PATH. Options:" + echo " 1. Add the install directory to CodeburnCLI.additionalPathEntries" + echo " 2. Symlink codeburn into /usr/local/bin" + exit 1 +fi +echo "OK: codeburn found at: $FOUND" +echo "" + +# 2. Check if node is found (needed for codeburn shell wrapper) +echo "--- Step 2: Locate node binary ---" +NODE_FOUND=$(PATH="$RESTRICTED_PATH" /usr/bin/env which node 2>&1 || true) +if [ -z "$NODE_FOUND" ]; then + echo "WARNING: node not found in restricted PATH" + echo "This may cause codeburn to fail if it's a shell wrapper." + echo "" +else + echo "OK: node found at: $NODE_FOUND" + echo "Node version: $(PATH="$RESTRICTED_PATH" node --version 2>&1 || echo 'failed')" +fi +echo "" + +# 3. Run the command the menubar spawns +echo "--- Step 3: Run menubar-equivalent CLI command ---" +echo "Command: codeburn status --format menubar-json --period today --provider all" +echo "" + +STDERR_FILE=$(mktemp) +trap 'rm -f "$STDERR_FILE"' EXIT + +if PATH="$RESTRICTED_PATH" /usr/bin/env -- codeburn status --format menubar-json --period today --provider all 2>"$STDERR_FILE"; then + echo "" + if [ -s "$STDERR_FILE" ]; then + echo "Warnings/errors on stderr:" + cat "$STDERR_FILE" + fi + echo "" + echo "SUCCESS: CLI ran successfully with restricted PATH." +else + EXIT_CODE=$? + echo "" + echo "FAIL: CLI exited with code $EXIT_CODE" + if [ -s "$STDERR_FILE" ]; then + echo "" + echo "Stderr output:" + cat "$STDERR_FILE" + fi + exit 1 +fi diff --git a/src/cli.ts b/src/cli.ts index abc2b0b..3efb62c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -32,7 +32,10 @@ async function hydrateCache() { (range) => parseAllSessions(range, 'all'), aggregateProjectsIntoDays, ) - } catch { + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const stack = err instanceof Error && err.stack ? `\n${err.stack}` : '' + process.stderr.write(`codeburn: hydrateCache failed, returning empty cache: ${message}${stack}\n`) return emptyCache() } }