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
This commit is contained in:
Rashid Razak 2026-05-13 12:18:17 +08:00
parent f5b0ac500f
commit ab87b61bba
4 changed files with 137 additions and 1 deletions

View file

@ -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)
}

View file

@ -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/<version>/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/<version>/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
}
}

73
scripts/diagnose-menubar-cli.sh Executable file
View file

@ -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

View file

@ -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()
}
}