Skip to content

[codex] Improve self-update PATH shadowing handling#135

Closed
BlackHole1 wants to merge 3 commits into
mainfrom
codex/self-update-path-shadowing
Closed

[codex] Improve self-update PATH shadowing handling#135
BlackHole1 wants to merge 3 commits into
mainfrom
codex/self-update-path-shadowing

Conversation

@BlackHole1
Copy link
Copy Markdown
Member

Summary

  • Detect when oo install or oo update leaves another oo executable earlier on PATH and print an explicit shadowing note.
  • Reuse shared command path/path comparison helpers for self-update resolution checks.
  • Improve legacy npm global cleanup by recognizing npm-global paths and passing the detected --prefix when uninstalling old @oomol-lab/oo-cli installs.
  • Update npm wrapper detection, localized output, tests, and command docs for the new behavior.

Root Cause

oo update correctly installed and activated the managed executable under ~/.local/bin, but it did not verify that the current shell would actually resolve oo to that managed entrypoint. A prior npm-global installation, such as a custom QClaw/npm-global/bin/oo, could remain earlier on PATH, making oo --version continue to report the old version after a successful update.

Validation

  • bun run lint:fix
  • bun run ts-check
  • bun run test src/application/self-update/command-resolution.test.ts src/application/self-update/legacy-installation.test.ts src/application/self-update/installation.test.ts src/application/commands/self-update-output.test.ts src/application/commands/self-update.cli.test.ts contrib/npm/oo.test.ts
  • bun run test with Bun 1.3.13: 799 pass, 9 skip, 0 fail

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 2, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fe67c2b4-0a9e-4806-992c-694a2c780e70

📥 Commits

Reviewing files that changed from the base of the PR and between 9223564 and 75b3846.

📒 Files selected for processing (3)
  • contrib/npm/oo.test.ts
  • src/application/self-update/command-path.test.ts
  • src/application/self-update/command-path.ts
✅ Files skipped from review due to trivial changes (2)
  • src/application/self-update/command-path.test.ts
  • src/application/self-update/command-path.ts

Summary by CodeRabbit

  • New Features

    • Detects and notifies when an older executable shadows the managed installation in PATH; prints a shadowing note with actionable guidance (localized).
  • Improvements

    • Broadened npm detection to recognize npm-global-style directories.
    • Legacy npm cleanup now respects custom global prefixes when uninstalling.
  • Documentation

    • Clarified PATH shadowing behavior and resolution steps in install/update docs.
  • Tests

    • Added coverage for path/command resolution and related self-update scenarios.

Walkthrough

This PR adds command-resolution to self-update: it scans PATH for candidate oo executables, classifies the result as managed, missing, or shadowed (using direct and realpath comparisons), and propagates that result through self-update flows. Install/update call sites forward the resolution to the output helper, which now emits a pathShadowedNote when an earlier oo is found on PATH. The change also broadens npm detection to recognize npm-global/npm_global segments and updates legacy uninstall logic to derive and pass npm global prefixes for uninstall commands. Tests and docs were added/updated.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CLI as Install/Update Command
    participant Core as Self-Update Core
    participant CmdRes as Command Resolution
    participant PathScan as PATH Scanner
    participant Output as Self-Update Output

    User->>CLI: run install/update
    activate CLI
    CLI->>Core: performSelfUpdateOperation()
    activate Core

    Core->>CmdRes: resolveSelfUpdateCommandResolution(env, executablePath, platform)
    activate CmdRes

    CmdRes->>PathScan: resolveCommandPathCandidates(env, executableNames, platform)
    activate PathScan
    PathScan->>PathScan: read PATH → split entries → probe each dir for executable
    PathScan-->>CmdRes: candidate list
    deactivate PathScan

    alt candidates found
        CmdRes->>CmdRes: pick first candidate, compare with executablePath (direct + realpath)
        alt match
            CmdRes-->>Core: { status: "managed" }
        else mismatch
            CmdRes-->>Core: { status: "shadowed", path: candidatePath }
        end
    else no candidates
        CmdRes-->>Core: { status: "missing" }
    end
    deactivate CmdRes

    Core->>Output: writeSelfUpdatePathNoteIfNeeded(..., commandResolution)
    activate Output
    alt commandResolution.status == "shadowed"
        Output->>Output: emit selfUpdate.pathShadowedNote { path, directory }
    end
    Output-->>CLI: note printed
    deactivate Output

    Core-->>CLI: operation result (includes commandResolution)
    deactivate Core
    CLI-->>User: finish (may include shadowing warning)
    deactivate CLI
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title does not follow the required format '(): '. It uses a prefix pattern '[codex]' instead of the specified conventional commit format. Rewrite the title to follow the format '(): ', for example: 'feat(self-update): improve PATH shadowing handling' or 'fix(self-update): detect and report PATH shadowing issues'.
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly relates to the changeset, explaining the root cause, objectives, and validation approach for the self-update PATH shadowing detection feature.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch codex/self-update-path-shadowing

Review rate limit: 3/5 reviews remaining, refill in 15 minutes and 33 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@alwaysmavs alwaysmavs force-pushed the codex/self-update-path-shadowing branch from bb6ce01 to 2d9c512 Compare May 2, 2026 12:14
@alwaysmavs alwaysmavs marked this pull request as ready for review May 2, 2026 12:17
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
contrib/npm/oo.test.ts (1)

36-40: ⚡ Quick win

Use path helpers instead of hard-coded POSIX separators in test input.

Line 38 hardcodes / separators. Build this input via node:path helpers to keep the test platform-agnostic.

♻️ Suggested change
+import { join } from "node:path";
 import { createRequire } from "node:module";

 // ...

     test("detects npm from the installed oo path under npm-global", () => {
         expect(wrapperModule.detectPackageManagerFromOoPath([
-            "/Users/demo/Library/Application Support/QClaw/npm-global/bin/oo",
+            join(
+                "Users",
+                "demo",
+                "Library",
+                "Application Support",
+                "QClaw",
+                "npm-global",
+                "bin",
+                "oo",
+            ),
         ])).toBe("npm");
     });

As per coding guidelines, "Never assume POSIX path separators in code, tests, snapshots, or assertions; use node:path helpers (join(), resolve(), relative()) for path construction".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contrib/npm/oo.test.ts` around lines 36 - 40, Replace the hard-coded POSIX
path string in the test that calls wrapperModule.detectPackageManagerFromOoPath
with a platform-agnostic path built using node:path helpers (e.g., import { join
} from "path" and use join("Users","demo","Library","Application
Support","QClaw","npm-global","bin","oo") or equivalent) so the input uses
path.join rather than "/" separators; update the test expectation unchanged but
construct the input array via the join call passed to
detectPackageManagerFromOoPath.
src/application/self-update/command-path.ts (1)

24-40: ⚡ Quick win

Parallelize independent PATH existence checks

The checks per directory are independent but executed sequentially. Switching to Promise.all preserves order while reducing I/O latency on long PATH values.

Suggested patch
-    const candidates: CommandPathCandidate[] = [];
-
-    for (const directoryPath of splitPathEntries(pathValue, options.platform)) {
-        const candidatePath = await resolveFirstPathCandidatePath({
-            directoryPath,
-            executableNames: options.executableNames,
-            pathExists: options.pathExists ?? defaultPathExists,
-            pathModule,
-        });
-
-        if (candidatePath === undefined) {
-            continue;
-        }
-
-        candidates.push({
-            directoryPath,
-            path: candidatePath,
-        });
-    }
-
-    return candidates;
+    const pathExists = options.pathExists ?? defaultPathExists;
+    const directories = splitPathEntries(pathValue, options.platform);
+    const resolvedCandidates = await Promise.all(
+        directories.map(async (directoryPath) => {
+            const candidatePath = await resolveFirstPathCandidatePath({
+                directoryPath,
+                executableNames: options.executableNames,
+                pathExists,
+                pathModule,
+            });
+
+            if (candidatePath === undefined) {
+                return undefined;
+            }
+
+            return {
+                directoryPath,
+                path: candidatePath,
+            } satisfies CommandPathCandidate;
+        }),
+    );
+
+    return resolvedCandidates.filter(
+        (candidate): candidate is CommandPathCandidate => candidate !== undefined,
+    );
 }

As per coding guidelines, "Use Promise.all() for independent async operations instead of sequential await."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/application/self-update/command-path.ts` around lines 24 - 40, The loop
over splitPathEntries is performing independent async checks sequentially;
change it to run resolveFirstPathCandidatePath for each directory in parallel
via Promise.all while preserving order: map splitPathEntries(...) to an array of
promises calling resolveFirstPathCandidatePath({ directoryPath, executableNames:
options.executableNames, pathExists: options.pathExists ?? defaultPathExists,
pathModule }), await Promise.all on that array, then iterate the results in
order and for each non-undefined result push { directoryPath, path:
candidatePath } into candidates. Ensure you still use the same symbols:
splitPathEntries, resolveFirstPathCandidatePath,
options.pathExists/defaultPathExists and candidates.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/application/self-update/command-path.ts`:
- Around line 58-61: The current split/filter pipeline drops empty PATH segments
(pathValue.split(...).map(...).filter(Boolean)), which loses POSIX-empty entries
that represent the current directory; instead, preserve empty entries by
removing filter(Boolean) and normalize them to '.' on non-Windows platforms.
Update the code around pathValue and readPathModule(platform) so that after
splitting and trimming you map empty strings to '.' when not on Windows (use
platform detection consistent with readPathModule(platform) or an isWindows
flag), and do not filter out falsy entries; this ensures empty segments remain
as valid path entries for the function that computes shadowing/managed/missing.

---

Nitpick comments:
In `@contrib/npm/oo.test.ts`:
- Around line 36-40: Replace the hard-coded POSIX path string in the test that
calls wrapperModule.detectPackageManagerFromOoPath with a platform-agnostic path
built using node:path helpers (e.g., import { join } from "path" and use
join("Users","demo","Library","Application
Support","QClaw","npm-global","bin","oo") or equivalent) so the input uses
path.join rather than "/" separators; update the test expectation unchanged but
construct the input array via the join call passed to
detectPackageManagerFromOoPath.

In `@src/application/self-update/command-path.ts`:
- Around line 24-40: The loop over splitPathEntries is performing independent
async checks sequentially; change it to run resolveFirstPathCandidatePath for
each directory in parallel via Promise.all while preserving order: map
splitPathEntries(...) to an array of promises calling
resolveFirstPathCandidatePath({ directoryPath, executableNames:
options.executableNames, pathExists: options.pathExists ?? defaultPathExists,
pathModule }), await Promise.all on that array, then iterate the results in
order and for each non-undefined result push { directoryPath, path:
candidatePath } into candidates. Ensure you still use the same symbols:
splitPathEntries, resolveFirstPathCandidatePath,
options.pathExists/defaultPathExists and candidates.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 10f45e02-5fc2-41a3-9502-f6ce1581754d

📥 Commits

Reviewing files that changed from the base of the PR and between a417d75 and 2d9c512.

📒 Files selected for processing (20)
  • contrib/npm/oo.cjs
  • contrib/npm/oo.test.ts
  • docs/commands.md
  • docs/commands.zh-CN.md
  • src/application/commands/install.ts
  • src/application/commands/self-update-output.test.ts
  • src/application/commands/self-update-output.ts
  • src/application/commands/self-update.cli.test.ts
  • src/application/commands/update.ts
  • src/application/contracts/self-update.ts
  • src/application/self-update/command-path.ts
  • src/application/self-update/command-resolution.test.ts
  • src/application/self-update/command-resolution.ts
  • src/application/self-update/core.ts
  • src/application/self-update/installation.test.ts
  • src/application/self-update/installation.ts
  • src/application/self-update/legacy-installation.test.ts
  • src/application/self-update/legacy-installation.ts
  • src/application/self-update/path-comparison.ts
  • src/i18n/catalog.ts

Comment thread src/application/self-update/command-path.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/application/self-update/command-path.test.ts (2)

6-13: ⚡ Quick win

Extract repeated stub/setup patterns into local test helpers.

The resolveCommandPathCandidates call shape and pathExists stub logic are repeated across all three tests. A local factory/helper will reduce duplication and make new PATH cases easier to add.

Proposed refactor
 import { describe, expect, test } from "bun:test";
 import { resolveCommandPathCandidates } from "./command-path.ts";

+function createPathExists(availablePaths: readonly string[]) {
+    return async (candidatePath: string) =>
+        availablePaths.includes(candidatePath);
+}
+
 describe("resolveCommandPathCandidates", () => {
@@
             env: {
                 PATH: "",
             },
             executableNames: ["oo"],
-            pathExists: async path => path === "oo",
+            pathExists: createPathExists(["oo"]),
             platform: "linux",
         });
@@
             env: {
                 PATH: ":/managed/bin",
             },
             executableNames: ["oo"],
-            pathExists: async path => path === "oo" || path === "/managed/bin/oo",
+            pathExists: createPathExists(["oo", "/managed/bin/oo"]),
             platform: "linux",
         });
@@
             env: {
                 Path: ";C:\\managed\\bin",
             },
             executableNames: ["oo.exe"],
-            pathExists: async path => (
-                path === "oo.exe" || path === "C:\\managed\\bin\\oo.exe"
-            ),
+            pathExists: createPathExists(["oo.exe", "C:\\managed\\bin\\oo.exe"]),
             platform: "win32",
         });

As per coding guidelines, "Extract repeated setup (mocks, stubs, setup objects) into local factory functions in test files; avoid copy-pasting test setup".

Also applies to: 24-31, 46-55

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/application/self-update/command-path.test.ts` around lines 6 - 13, The
tests repeatedly call resolveCommandPathCandidates with the same pattern of
env/PATH, executableNames and a pathExists stub; extract a local test helper
(e.g., makeResolveArgs or createResolveStub) that builds the argument object and
provides a configurable pathExists implementation so each test can pass PATH and
executableNames concisely; update each test to call
resolveCommandPathCandidates(helper({ PATH: "", executableNames: ["oo"],
pathExistsMatches: p => p === "oo", platform: "linux" })) and adjust other cases
to reuse the helper rather than duplicating the stub logic, keeping references
to resolveCommandPathCandidates and pathExists for clarity.

29-30: ⚡ Quick win

Build expected paths with node:path helpers instead of hardcoded separators.

Lines 29, 40, 52, and 64 hardcode platform separators in assertions/stubs. Prefer path.posix.join / path.win32.join for portable, guideline-compliant tests.

Proposed refactor
+import { posix, win32 } from "node:path";
 import { describe, expect, test } from "bun:test";
 import { resolveCommandPathCandidates } from "./command-path.ts";

 describe("resolveCommandPathCandidates", () => {
+    const posixManagedDirectory = posix.join("/", "managed", "bin");
+    const posixManagedExecutable = posix.join(posixManagedDirectory, "oo");
+    const windowsManagedDirectory = win32.join("C:\\", "managed", "bin");
+    const windowsManagedExecutable = win32.join(
+        windowsManagedDirectory,
+        "oo.exe",
+    );
+
     test("treats an empty POSIX PATH value as the current-directory entry", async () => {
         const candidates = await resolveCommandPathCandidates({
@@
     test("preserves POSIX empty PATH segments as current-directory entries", async () => {
         const candidates = await resolveCommandPathCandidates({
             env: {
-                PATH: ":/managed/bin",
+                PATH: `:${posixManagedDirectory}`,
             },
             executableNames: ["oo"],
-            pathExists: async path => path === "oo" || path === "/managed/bin/oo",
+            pathExists: async path =>
+                path === "oo" || path === posixManagedExecutable,
             platform: "linux",
         });
@@
             {
-                directoryPath: "/managed/bin",
-                path: "/managed/bin/oo",
+                directoryPath: posixManagedDirectory,
+                path: posixManagedExecutable,
             },
         ]);
     });
@@
     test("preserves Windows empty PATH segments without rewriting them", async () => {
         const candidates = await resolveCommandPathCandidates({
             env: {
-                Path: ";C:\\managed\\bin",
+                Path: `;${windowsManagedDirectory}`,
             },
             executableNames: ["oo.exe"],
             pathExists: async path => (
-                path === "oo.exe" || path === "C:\\managed\\bin\\oo.exe"
+                path === "oo.exe" || path === windowsManagedExecutable
             ),
             platform: "win32",
         });
@@
             {
-                directoryPath: "C:\\managed\\bin",
-                path: "C:\\managed\\bin\\oo.exe",
+                directoryPath: windowsManagedDirectory,
+                path: windowsManagedExecutable,
             },
         ]);
     });
 });

As per coding guidelines, "Never assume POSIX path separators in code, tests, snapshots, or assertions; use node:path helpers (join(), resolve(), relative()) for path construction".

Also applies to: 40-40, 52-53, 64-64

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/application/self-update/command-path.test.ts` around lines 29 - 30,
Replace hardcoded path strings in the test stubs/assertions with node:path join
helpers: in the pathExists stub (the async path => path === "oo" || path ===
"/managed/bin/oo") and the other occurrences, build expected paths with
path.posix.join(...) when platform is "linux"/POSIX and path.win32.join(...)
when platform is "win32"; update the platform values in those test cases
accordingly and use the same join calls in any assertions so tests no longer
assume literal '/' separators.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/application/self-update/command-path.test.ts`:
- Around line 6-13: The tests repeatedly call resolveCommandPathCandidates with
the same pattern of env/PATH, executableNames and a pathExists stub; extract a
local test helper (e.g., makeResolveArgs or createResolveStub) that builds the
argument object and provides a configurable pathExists implementation so each
test can pass PATH and executableNames concisely; update each test to call
resolveCommandPathCandidates(helper({ PATH: "", executableNames: ["oo"],
pathExistsMatches: p => p === "oo", platform: "linux" })) and adjust other cases
to reuse the helper rather than duplicating the stub logic, keeping references
to resolveCommandPathCandidates and pathExists for clarity.
- Around line 29-30: Replace hardcoded path strings in the test stubs/assertions
with node:path join helpers: in the pathExists stub (the async path => path ===
"oo" || path === "/managed/bin/oo") and the other occurrences, build expected
paths with path.posix.join(...) when platform is "linux"/POSIX and
path.win32.join(...) when platform is "win32"; update the platform values in
those test cases accordingly and use the same join calls in any assertions so
tests no longer assume literal '/' separators.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 556cf8ad-75e7-4108-b86d-90bdf6dcc0d5

📥 Commits

Reviewing files that changed from the base of the PR and between 2d9c512 and 9223564.

📒 Files selected for processing (2)
  • src/application/self-update/command-path.test.ts
  • src/application/self-update/command-path.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/application/self-update/command-path.ts

@BlackHole1 BlackHole1 marked this pull request as draft May 4, 2026 15:02
BlackHole1 added a commit that referenced this pull request May 5, 2026
…137)

After install or update, the CLI now checks whether PATH still resolves
oo to another executable before the managed directory and prints a
shadowing note so users notice their shell will keep running the old
binary. Set OO_HIDE_PATH_SHADOWING_WARNING to a truthy value to suppress
the note when keeping another oo earlier on PATH is intentional.

Also harden the legacy npm cleanup: detect installs under npm-global and
npm_global path segments, and pass the inferred global prefix to npm
uninstall so cleanup works for non-default prefixes.

close: #135

---------

Signed-off-by: Kevin Cui <bh@bugs.cc>
@BlackHole1 BlackHole1 deleted the codex/self-update-path-shadowing branch May 8, 2026 14:59
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