Skip to content

fix(install): find programs on PATH without which, and count a symlinked PATH dir once - #804

Merged
sirmalloc merged 9 commits into
sirmalloc:mainfrom
eric-engberg:fix/install-path-detection
Nov 6, 2026
Merged

sirmalloc merged 9 commits into
sirmalloc:mainfrom
eric-engberg:fix/install-path-detection

Conversation

@eric-engberg

Copy link
Copy Markdown
Contributor

What

Two fixes to how the TUI looks for programs on PATH:

  • No which binary: on systems without one, the TUI no longer reports npm, npx, bun and bunx as missing or says ccstatusline isn't on PATH.
  • Symlinked PATH directories: one ccstatusline reached through two PATH entries that are the same directory (/bin and /usr/bin on merged-/usr Linux, or bin and Bin on a case-insensitive macOS volume under Node) no longer triggers "Multiple ccstatusline binaries are on PATH". When there is a real second install, "Other matches" lists it once instead of also naming the first match's other spelling.

Why (evidence)

No which. The Install screen asks which <tool> whether npm, npx, bun and bunx exist, and Manage Installation asks which -a ccstatusline where the command resolves. which is its own package, and minimal Fedora/RHEL/UBI images and a base Arch install don't include it. When it's missing, the lookup fails exactly as if the tool were missing:

  • On a PATH holding node, npm, npx, bun and bunx but no which, the availability check returns {"npm":false,"npx":false,"bun":false,"bunx":false} under both Bun and Node.
  • The Install screen lists npm install -g ccstatusline@2.2.32 (npm not installed), bun add -g … (bun not installed), npx -y ccstatusline@latest (npx not installed) and bunx … (bunx not installed), and none of them can be selected. The TUI can't install at all, even though the user just started it with npx.
  • Update actions are disabled for the same reason, and Manage Installation says "ccstatusline is not currently resolvable on PATH".
  • On the same PATH, sh -c 'which npm' exits 127 (which: command not found) while command -v npm finds npm.

Symlinked PATH directories. The multiple-binaries warning counts the directories which -a lists, comparing them as text. On Ubuntu 22.04+ and Debian 12, /bin is a symlink to usr/bin and the default PATH lists both. With npm's global prefix at /usr (NodeSource's Node, for example), which -a ccstatusline prints /usr/bin/ccstatusline and /bin/ccstatusline, which are the same file. Manage Installation and the install/update notices then show "⚠ Multiple ccstatusline binaries are on PATH … Other matches: /bin/ccstatusline". Reproduced with a usr/bin directory and a bin -> usr/bin symlink both on PATH. With a real second install listed after those two, "Other matches" named /bin/ccstatusline as well as the other install, because the list took every path after the first. On macOS, Node's fs.realpathSync keeps the letter case it is given while Bun's returns the case on disk. With one scratch directory on PATH as both .../bin and .../Bin, which which -a lists twice, the check behind the install and update notices warned under Node v26.10.0 and not under Bun 1.4.2.

How

  • PATH search fallback. When which can't be started (ENOENT), getCommandResolutionPaths searches PATH itself through the new findAllOnPosixPath in executable-path.ts: each file the user can run, in PATH order, as which -a lists them. Empty and relative PATH entries are skipped: they name directories relative to wherever the TUI runs, which says nothing about what Claude Code runs, and the Windows lookup in the same file already counts only absolute entries.
  • Why not command -v. It could answer "is npm installed" but can't list every match, which the multiple-binaries warning needs, so both checks share one PATH search.
  • One lookup for both checks. On POSIX the availability check now goes through the same lookup as the ccstatusline resolution, so the two can't disagree.
  • Unchanged elsewhere. Where which exists nothing changes (a normal "not found" still returns nothing), and Windows keeps using where.
  • Duplicate check. Both duplicate checks now compare each directory with its symlinks resolved (getRealDirectory, which falls back to the listed path when it can't be resolved). It calls fs.realpathSync.native, which returns the on-disk letter case under both runtimes. "Other matches" lists the first path in each other directory, so each other install appears once. The warning text keeps the paths as PATH spells them.
  • Left as is: two checks still compare paths as text: the "outside the npm global bin directory" check in the install and update notices, and the check Manage Installation uses to pick the active package manager. They misfire whenever PATH lists a symlinked spelling of the global bin directory before its real one. On Arch, /usr/sbin is a symlink to bin and npm's global prefix is /usr. With Docker's default PATH (/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin), a root npm i -g ccstatusline therefore resolves first as /usr/sbin/ccstatusline. On that layout this branch drops the false "Multiple ccstatusline binaries" warning but shows "ccstatusline resolves to /usr/sbin/ccstatusline, which is outside the npm global bin directory (/usr/bin)" instead. Manage Installation detects no package manager, as on main, and says the binary is "outside the detected npm and bun global bin directories". Reproduced with a fake root of that layout under Bun and Node. Ubuntu and Debian aren't affected: /usr/sbin is a real directory there, and their default PATH lists /usr/bin before /bin.

Demo

Install to Claude Code with no which on PATH. Before, every package manager reads "not installed" and can't be picked; after, they're listed normally.

Powerline

Before:

Install screen without which before the fix, Powerline

After:

Install screen without which after the fix, Powerline

Plain

Before:

Install screen without which before the fix, Plain

After:

Install screen without which after the fix, Plain

Manage Installation with one binary reached through usr/bin and bin -> usr/bin. Before, a false "Multiple ccstatusline binaries" warning; after, none.

Powerline

Before:

Manage Installation on merged /usr before the fix, Powerline

After:

Manage Installation on merged /usr after the fix, Powerline

Plain

Before:

Manage Installation on merged /usr before the fix, Plain

After:

Manage Installation on merged /usr after the fix, Plain

Testing

  • On main as of fix(powerline): keep the Custom theme when Powerline is enabled again #793 (a0bedc0): it merges cleanly. bun run lint is clean, and bun test gives 4162 pass, 0 fail (4147 on main). The built CLI under Bun 1.4.4 and Node 26.13.0 agrees across runtimes and matches main in plain and Powerline mode, and the TUI opens and exits cleanly under both. It adds no JavaScript or Node API newer than Node 14, the build's target.

  • 15 new tests:

    • the PATH search's order, de-duplication, skipped empty and relative entries, missing PATH, reading a process.env replaced after load, and the real permission check (executable file, non-executable file, directory, symlink);
    • the resolution fallback when which is missing, and no fallback when which runs and fails;
    • package-manager availability without which;
    • the merged-/usr layout for both duplicate checks, alone and with a second install after it (listed once in "Other matches");
    • a directory spelled in another letter case resolving to one path, through the native realpath.

    The behaviour tests fail on main, except the failing-which case: it keeps what main already does and fails once the ENOENT check is dropped. The letter-case test fails before its fix only under Node, since Bun's realpathSync already returned the on-disk case, so a second test checks that the native call is made; it fails under bun test when the call is reverted.

  • Node (vitest), changed test files: the only failures are the existing "Cannot spy on export … Module namespace is not configurable in ESM" kind (7 more than main, all new mock-based tests failing the same way); the new PATH search tests and the letter-case tests pass under Node.

  • End to end with a PATH of node, bun, npm, npx and bunx but no which, under both runtimes: main shows all four managers as not installed; this branch lists them and finds ccstatusline.

  • End to end with a merged-/usr layout under both runtimes: main shows the false warning; this branch doesn't.

  • End to end on macOS with one scratch directory on PATH as .../bin and .../Bin (real which -a): before the letter-case fix Node warned and Bun didn't; now neither does.

…which

The TUI asked `which` whether npm, npx, bun and bunx are installed and
where ccstatusline resolves. `which` is a separate package that minimal
Linux images (Fedora, RHEL/UBI) and a base Arch install leave out.
Without it every lookup failed the way a missing tool does: the Install
screen marked all four package managers "not installed" and disabled
every option, update actions were disabled too, and Manage Installation
said ccstatusline is not resolvable on PATH.

When `which` can't be started, the lookup now searches PATH itself and
lists what `which -a` would: each file the user can run, in PATH order.
`command -v` could answer "is npm installed" but can't list every match,
which the multiple-binaries warning needs, so both checks share this one
search. The availability check now goes through the same lookup as the
ccstatusline resolution, so the two can't disagree. Where `which` exists
nothing changes, and Windows still uses `where`.
The "Multiple ccstatusline binaries are on PATH" warning counts the
directories `which -a` lists ccstatusline in, comparing them as text.
On merged-/usr Linux (Ubuntu 22.04+, Debian 12) /bin is a symlink to
usr/bin and the default PATH lists both, so with npm's global prefix at
/usr one installed binary shows up as /usr/bin/ccstatusline and
/bin/ccstatusline. Manage Installation and the install and update
notices then warned about a second install that doesn't exist.

Both duplicate checks now compare each directory with its symlinks
resolved, and fall back to the path as listed when it can't be
resolved. The warning still shows the paths as PATH spells them.
The PATH search used when there is no `which` kept the process.env object
that existed when its module loaded. Code that replaces process.env with a
new object afterwards (a test restoring a saved environment does) left the
search reading the old object, so a PATH set on the new one was never seen.
The default search now reads process.env each time it runs.
…arning

The warning counts installs by directory, with symlinks resolved, but
built its "Other matches" list from every path `which -a` printed after
the first. With a real second install on a merged-/usr system, where
/bin links to usr/bin, PATH order gives /usr/bin/ccstatusline,
/bin/ccstatusline and then the other one, so the list named
/bin/ccstatusline as another binary although it is the first match
reached a second way.

The list now takes the first path listed in each directory and leaves
out the first match's directory, so each other install appears once,
spelled as PATH lists it.
…de and Bun

The duplicate-binary checks resolve each PATH directory with
fs.realpathSync before comparing. Where the file system ignores letter
case (macOS by default), Bun's realpathSync returns the case stored on
disk but Node's returns the case it was given. A PATH that spells one
directory two ways, such as .../bin and .../Bin, therefore showed
"Multiple ccstatusline binaries are on PATH" when the TUI ran under
Node and no warning under Bun.

The lookup now uses fs.realpathSync.native, which asks the operating
system and returns the on-disk case under both runtimes. A directory
that can't be resolved still falls back to the path as listed.
…iling

The lookup searches PATH itself only when `which` can't be started
(ENOENT). When `which` runs and exits non-zero, it found nothing, and
that answer stands. No test covered this: with the ENOENT check removed,
every failed `which -a ccstatusline` would fall through to the PATH
search, and the tests still passed.

The new case has `which -a` exit with status 1 while PATH holds an
executable ccstatusline, and expects no matches.
… realpath

The letter-case test passes under Bun with or without the native call,
because Bun's plain realpathSync already reports the on-disk case, and
it is skipped where the file system is case-sensitive. A second test
checks the call itself, so a revert fails under bun test on any system.
findAllOnPosixPath spelled its default search as an object literal inside
the parameter list, which made the signature hard to read at a glance. The
default now comes from defaultPosixPathSearch(), next to the isExecutableFile
check it uses.

A default parameter is evaluated on every call that leaves it out, so the
helper still builds a fresh search from process.env as it is when the search
runs: an env object swapped in after the module loaded is still seen.
# Conflicts:
#	src/utils/__tests__/global-package-manager.test.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants