Skip to content

fix(extmgr): harden npm reload handling#18

Merged
ayagmar merged 2 commits into
masterfrom
fix/issue-17-npm-prefix-reload
May 7, 2026
Merged

fix(extmgr): harden npm reload handling#18
ayagmar merged 2 commits into
masterfrom
fix/issue-17-npm-prefix-reload

Conversation

@ayagmar
Copy link
Copy Markdown
Owner

@ayagmar ayagmar commented May 7, 2026

Solves #17

Summary by CodeRabbit

  • Documentation

    • Added troubleshooting and configuration examples for npm prefix permission errors during global installs.
  • Bug Fixes

    • npm command resolution now respects configured Node version managers and per-project npm command settings.
    • Improved handling for alternative runtimes (e.g., Bun) when locating global package roots.
    • Improved UI reload flow: declines short-circuit, failures are caught and surfaced via an error notification.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds configurable per-cwd npm command/root resolution (including bun-aware root heuristics), integrates settings-derived commands into exec and package-root lookup with caching, updates README with npm prefix guidance, and improves confirmReload to catch and notify reload failures.

Changes

npm Command & Root Resolution Configuration

Layer / File(s) Summary
Type Definitions
src/utils/npm-exec.ts
ResolvedNpmCommand and ResolvedNpmRootCommand added; NpmCommandResolutionOptions gains npmCommand and cwd.
Core Resolution Functions
src/utils/npm-exec.ts
getConfiguredNpmBase validates overrides; getSettingsNpmCommand reads cached settings; resolveNpmCommand merges configured base with npmArgs.
Settings-Based Root Resolution
src/utils/npm-exec.ts
resolveConfiguredNpmCommand, resolveNpmRootCommand, and resolveConfiguredNpmRootCommand added; bun handled via bun pm bin -g and Bun global-dir heuristics.
Execution & Global Root Integration
src/utils/npm-exec.ts, src/packages/extensions.ts
execNpm resolves commands using settings for ctx.cwd; getGlobalNpmRoot caches by resolved command+args and executes with timeout; package-root lookup passes cwd.
Tests for npm Resolution
test/npm-exec.test.ts
Tests updated to assert configured npmCommand routing (e.g., mise wrapper) and bun-specific resolveNpmRootCommand behaviors.
Documentation
README.md
New "npm prefix permissions" subsection documenting EACCES causes and mitigation, plus example npmCommand settings for common version managers.

UI Reload Error Handling

Layer / File(s) Summary
Error Handling Implementation
src/utils/ui-helpers.ts
confirmReload imports error notifier, short-circuits on user decline, wraps ctx.reload() in try/catch, and reports failures via notifyError.
Tests for UI Error Handling
test/ui-helpers.test.ts
Adds tests asserting decline returns false without reload/notify, successful reload returns true, failing reload returns false and emits an error-level notify with formatted message.

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • ayagmar/pi-extmgr#3: Modifies global npm-root resolution and caching in src/packages/extensions.ts, closely related to these changes.
  • ayagmar/pi-extmgr#5: Also updates src/utils/npm-exec.ts for npm command resolution and settings integration.
  • ayagmar/pi-extmgr#6: Touches src/packages/extensions.ts to centralize npm root resolution, related at the code-path level.

Poem

🐰 A rabbit hops through code so neat,
With npm roots and commands replete,
Settings guide each wrapped command,
Reloads now fail with gentle hand,
Tests and docs make the whole thing sweet.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(extmgr): harden npm reload handling' accurately summarizes the main change—improving robustness of npm reload handling through configuration-aware command resolution and error handling.
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
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-17-npm-prefix-reload

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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: 2

🧹 Nitpick comments (3)
test/ui-helpers.test.ts (1)

6-28: ⚡ Quick win

Consider adding coverage for the !confirmed and success paths.

The new test covers reload rejection well, but two branches in confirmReload remain untested:

  • User declines (confirm resolves false) → should return false without calling reload.
  • Reload succeeds → should return true.

These are straightforward to add:

✅ Suggested additional test cases
+void test("confirmReload returns false when user declines", async () => {
+  let reloadCalled = false;
+  const ctx = {
+    hasUI: true,
+    ui: {
+      confirm: () => Promise.resolve(false),
+      notify: () => {},
+    },
+    reload: () => { reloadCalled = true; return Promise.resolve(); },
+  } as unknown as ExtensionCommandContext;
+
+  const reloaded = await confirmReload(ctx, "Package updated.");
+
+  assert.equal(reloaded, false);
+  assert.equal(reloadCalled, false);
+});
+
+void test("confirmReload returns true on successful reload", async () => {
+  const ctx = {
+    hasUI: true,
+    ui: {
+      confirm: () => Promise.resolve(true),
+      notify: () => {},
+    },
+    reload: () => Promise.resolve(),
+  } as unknown as ExtensionCommandContext;
+
+  const reloaded = await confirmReload(ctx, "Package updated.");
+
+  assert.equal(reloaded, true);
+});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/ui-helpers.test.ts` around lines 6 - 28, Add two unit tests for
confirmReload: one where ctx.ui.confirm resolves to false and ctx.reload should
not be called (assert returned value is false and no reload-related
notifications were emitted), and one where ctx.reload resolves successfully
(assert returned value is true and any success notification/behavior is as
expected). Use the same ExtensionCommandContext-shaped mock object pattern from
the existing test, stubbing ui.confirm, ui.notify, and reload to simulate each
branch, and verify confirmReload's return value and that notify/reload were
called or not accordingly.
src/utils/npm-exec.ts (2)

87-90: 💤 Low value

Bun global root derivation assumes default globalBinDir/globalDir relationship.

The path from bun pm bin -gpath.dirname(binDir)install/global/node_modules is correct for bun's defaults, but bun's globalDir and globalBinDir are independently configurable via bunfig.toml. If a user has customized these to non-sibling paths, the derived node_modules path will be incorrect and globally installed packages won't be found.

This is a narrow edge case, but worth noting in a code comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/npm-exec.ts` around lines 87 - 90, The current getRoot
implementation in src/utils/npm-exec.ts (getRoot) wrongly assumes bun's
globalBinDir and globalDir are siblings by computing
path.join(path.dirname(binDir), "install", "global", "node_modules"); update
this to not assume that relationship: first try to obtain bun's actual globalDir
via a dedicated bun command (or API) and use that to compute the node_modules
root, and if such a command isn't available fall back to the existing heuristic
but add a clear comment and a logged warning that this is a best-effort guess
when bun's globalDir is customized in bunfig.toml; reference the getRoot
function and the path.dirname(binDir) derivation so the code reviewer can locate
and replace the heuristic with the authoritative query/fallback+warning.

45-47: ⚡ Quick win

Consider caching SettingsManager to avoid recreating it on every npm invocation.

getSettingsNpmCommand creates a new SettingsManager instance on each call, which occurs inside execNpm for every npm operation. Compare this with catalog.ts, where SettingsManager.create is called once and cached in createDefaultPackageCatalog. If SettingsManager initialization involves any I/O or startup overhead, consider memoizing by cwd or caching the instance at the call site to improve performance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/npm-exec.ts` around lines 45 - 47, getSettingsNpmCommand currently
calls SettingsManager.create(cwd, getAgentDir()) on every invocation (used
inside execNpm), causing repeated construction overhead; change to memoize or
cache the SettingsManager instance by cwd (or at the call site) and return its
getNpmCommand(), e.g. store instances keyed by cwd when calling
SettingsManager.create so getSettingsNpmCommand reuses the cached
SettingsManager instead of recreating it each time (mirror the approach used in
createDefaultPackageCatalog).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Around line 23-28: Add concrete examples for the npmCommand setting in
README.md so users know how to configure Pi for different Node version managers;
include example JSONC entries for mise (e.g., using "mise exec node@22 -- npm"),
for bun (e.g., ["bun"]), and for nvm (e.g., using the npm wrapper on PATH as
["npm"]), reference the npmCommand key and show each example as a commented
JSONC line so readers can copy/paste.

In `@src/utils/npm-exec.ts`:
- Around line 83-92: The bun detection should not rely on configured?.command
=== "bun" because configured.command can be an absolute path or Windows-suffixed
name; update the condition in resolveNpmRootCommand to base the check on the
executable basename (e.g., const cmd = path.basename(configured.command || "");
const normalized = cmd.replace(/\.cmd$/i, ""); if (normalized === "bun") { ...
}) so that "/usr/local/bin/bun" and "bun.cmd" are recognized as bun and the
bun-specific args/getRoot branch (the object with args [...configured.args,
"pm","bin","-g"] and getRoot) is returned instead of falling through to generic
npm root handling.

---

Nitpick comments:
In `@src/utils/npm-exec.ts`:
- Around line 87-90: The current getRoot implementation in src/utils/npm-exec.ts
(getRoot) wrongly assumes bun's globalBinDir and globalDir are siblings by
computing path.join(path.dirname(binDir), "install", "global", "node_modules");
update this to not assume that relationship: first try to obtain bun's actual
globalDir via a dedicated bun command (or API) and use that to compute the
node_modules root, and if such a command isn't available fall back to the
existing heuristic but add a clear comment and a logged warning that this is a
best-effort guess when bun's globalDir is customized in bunfig.toml; reference
the getRoot function and the path.dirname(binDir) derivation so the code
reviewer can locate and replace the heuristic with the authoritative
query/fallback+warning.
- Around line 45-47: getSettingsNpmCommand currently calls
SettingsManager.create(cwd, getAgentDir()) on every invocation (used inside
execNpm), causing repeated construction overhead; change to memoize or cache the
SettingsManager instance by cwd (or at the call site) and return its
getNpmCommand(), e.g. store instances keyed by cwd when calling
SettingsManager.create so getSettingsNpmCommand reuses the cached
SettingsManager instead of recreating it each time (mirror the approach used in
createDefaultPackageCatalog).

In `@test/ui-helpers.test.ts`:
- Around line 6-28: Add two unit tests for confirmReload: one where
ctx.ui.confirm resolves to false and ctx.reload should not be called (assert
returned value is false and no reload-related notifications were emitted), and
one where ctx.reload resolves successfully (assert returned value is true and
any success notification/behavior is as expected). Use the same
ExtensionCommandContext-shaped mock object pattern from the existing test,
stubbing ui.confirm, ui.notify, and reload to simulate each branch, and verify
confirmReload's return value and that notify/reload were called or not
accordingly.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 11a54f5a-e9bf-41b8-a825-e3f847d59611

📥 Commits

Reviewing files that changed from the base of the PR and between 57f6ba6 and 5b5259e.

📒 Files selected for processing (6)
  • README.md
  • src/packages/extensions.ts
  • src/utils/npm-exec.ts
  • src/utils/ui-helpers.ts
  • test/npm-exec.test.ts
  • test/ui-helpers.test.ts

Comment thread README.md
Comment thread src/utils/npm-exec.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)
test/npm-exec.test.ts (2)

56-56: ⚡ Quick win

getRoot input is effectively a dummy — consider adding a test where the stdout matters.

Both bun tests call resolved.getRoot("/home/alice/.bun/bin\n") but the assertion only confirms the BUN_INSTALL_GLOBAL_DIR-derived path, meaning the stdout argument has no observable effect on the expected output. If the implementation has any path where the stdout fallback is used (e.g., when BUN_INSTALL_GLOBAL_DIR is unset), that branch is untested. A complementary test with BUN_INSTALL_GLOBAL_DIR unset would give confidence that the fallback behaves correctly.

Also applies to: 75-75

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/npm-exec.test.ts` at line 56, The current test always relies on
BUN_INSTALL_GLOBAL_DIR so getRoot("/home/alice/.bun/bin\n")'s stdout argument is
never exercised; add a complementary test case in test/npm-exec.test.ts that
unsets or temporarily clears BUN_INSTALL_GLOBAL_DIR, calls resolved.getRoot with
a representative stdout string (e.g., "/home/alice/.bun/bin\n") and asserts the
returned path matches the expected fallback derived from that stdout; ensure you
restore the environment afterwards and reference the resolved.getRoot function
in the new test to cover the alternate branch.

47-83: ⚡ Quick win

Consider extracting the shared Bun env-setup into a helper to reduce duplication.

Lines 47–64 and 66–83 are nearly identical: the only difference is the npmCommand value ("/usr/local/bin/bun" vs ``"bun.cmd"). The BUN_INSTALL_GLOBAL_DIR save/capture/restore block is copy-pasted in full, including the try/finally` teardown.

♻️ Suggested refactor
+function withBunGlobalDir<T>(globalDir: string, fn: () => T): T {
+  const old = process.env.BUN_INSTALL_GLOBAL_DIR;
+  process.env.BUN_INSTALL_GLOBAL_DIR = globalDir;
+  try {
+    return fn();
+  } finally {
+    if (old === undefined) delete process.env.BUN_INSTALL_GLOBAL_DIR;
+    else process.env.BUN_INSTALL_GLOBAL_DIR = old;
+  }
+}

 void test("resolveNpmRootCommand detects path-qualified bun commands", () => {
-  const oldGlobalDir = process.env.BUN_INSTALL_GLOBAL_DIR;
-  process.env.BUN_INSTALL_GLOBAL_DIR = "/opt/bun/global";
-
-  try {
+  withBunGlobalDir("/opt/bun/global", () => {
     const resolved = resolveNpmRootCommand({ npmCommand: ["/usr/local/bin/bun"] });
     assert.equal(resolved.command, "/usr/local/bin/bun");
     assert.deepEqual(resolved.args, ["pm", "bin", "-g"]);
     assert.equal(resolved.getRoot("/home/alice/.bun/bin\n"), "/opt/bun/global/node_modules");
-  } finally {
-    if (oldGlobalDir === undefined) {
-      delete process.env.BUN_INSTALL_GLOBAL_DIR;
-    } else {
-      process.env.BUN_INSTALL_GLOBAL_DIR = oldGlobalDir;
-    }
-  }
+  });
 });

 void test("resolveNpmRootCommand detects bun.cmd commands", () => {
-  const oldGlobalDir = process.env.BUN_INSTALL_GLOBAL_DIR;
-  process.env.BUN_INSTALL_GLOBAL_DIR = "/opt/bun/global";
-
-  try {
+  withBunGlobalDir("/opt/bun/global", () => {
     const resolved = resolveNpmRootCommand({ npmCommand: ["bun.cmd"] });
     assert.equal(resolved.command, "bun.cmd");
     assert.deepEqual(resolved.args, ["pm", "bin", "-g"]);
     assert.equal(resolved.getRoot("/home/alice/.bun/bin\n"), "/opt/bun/global/node_modules");
-  } finally {
-    if (oldGlobalDir === undefined) {
-      delete process.env.BUN_INSTALL_GLOBAL_DIR;
-    } else {
-      process.env.BUN_INSTALL_GLOBAL_DIR = oldGlobalDir;
-    }
-  }
+  });
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/npm-exec.test.ts` around lines 47 - 83, Extract the duplicated Bun env
setup/teardown into a small helper used by both tests ("resolveNpmRootCommand
detects path-qualified bun commands" and "resolveNpmRootCommand detects bun.cmd
commands"): create a helper (e.g., with a name like withBunGlobalDir or
runWithBunGlobalDir) that saves process.env.BUN_INSTALL_GLOBAL_DIR, sets it to
"/opt/bun/global", invokes a callback to run the test body (calling
resolveNpmRootCommand, assertions, etc.), and restores the original env in a
finally block; then replace the copy-pasted save/set/try/finally code in both
tests with a call to that helper while preserving the existing calls to
resolveNpmRootCommand and assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/npm-exec.test.ts`:
- Line 56: The current test always relies on BUN_INSTALL_GLOBAL_DIR so
getRoot("/home/alice/.bun/bin\n")'s stdout argument is never exercised; add a
complementary test case in test/npm-exec.test.ts that unsets or temporarily
clears BUN_INSTALL_GLOBAL_DIR, calls resolved.getRoot with a representative
stdout string (e.g., "/home/alice/.bun/bin\n") and asserts the returned path
matches the expected fallback derived from that stdout; ensure you restore the
environment afterwards and reference the resolved.getRoot function in the new
test to cover the alternate branch.
- Around line 47-83: Extract the duplicated Bun env setup/teardown into a small
helper used by both tests ("resolveNpmRootCommand detects path-qualified bun
commands" and "resolveNpmRootCommand detects bun.cmd commands"): create a helper
(e.g., with a name like withBunGlobalDir or runWithBunGlobalDir) that saves
process.env.BUN_INSTALL_GLOBAL_DIR, sets it to "/opt/bun/global", invokes a
callback to run the test body (calling resolveNpmRootCommand, assertions, etc.),
and restores the original env in a finally block; then replace the copy-pasted
save/set/try/finally code in both tests with a call to that helper while
preserving the existing calls to resolveNpmRootCommand and assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a568e0e3-2e6c-4df2-897f-469e5213f02e

📥 Commits

Reviewing files that changed from the base of the PR and between 5b5259e and c55b613.

📒 Files selected for processing (4)
  • README.md
  • src/utils/npm-exec.ts
  • test/npm-exec.test.ts
  • test/ui-helpers.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • README.md
  • test/ui-helpers.test.ts
  • src/utils/npm-exec.ts

@ayagmar ayagmar merged commit 497a174 into master May 7, 2026
2 checks passed
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.

1 participant