From 888e40c8d8d614947702d5bcc4cb9a8bb3ef9f08 Mon Sep 17 00:00:00 2001 From: Christopher Date: Mon, 4 May 2026 13:36:51 +1000 Subject: [PATCH 1/3] fix(cli): detect global npm self-update installs --- apps/cli/src/self-update.ts | 45 ++++++++++++++++++++++++------- apps/cli/test/self-update.test.ts | 18 +++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/self-update.ts b/apps/cli/src/self-update.ts index 4ac06fe3..c225e051 100644 --- a/apps/cli/src/self-update.ts +++ b/apps/cli/src/self-update.ts @@ -49,16 +49,43 @@ export function detectPackageManager(): 'bun' | 'npm' { /** * Detect whether agentv was invoked from a local project install. - * A path containing a `node_modules` segment indicates a local dependency; - * anything else (system binary, `.bun/bin`, `.nvm/.../bin`) is treated as - * global. Matches both POSIX and Windows path separators so a directory - * that merely embeds the substring (e.g., `/opt/my_node_modules_tool/`) - * isn't misclassified. + * npm global installs can also live under a `node_modules` segment, so + * the path alone is not enough. We treat `npx` cache paths as local and + * otherwise require the package root to be the current project or one of + * its ancestors before classifying it as local. */ -export function detectInstallScopeFromPath(scriptPath: string): 'local' | 'global' { - const hasSegment = - scriptPath.includes('/node_modules/') || scriptPath.includes('\\node_modules\\'); - return hasSegment ? 'local' : 'global'; +export function detectInstallScopeFromPath( + scriptPath: string, + cwd = process.cwd(), +): 'local' | 'global' { + const normalizedScriptPath = scriptPath.replace(/\\/g, '/'); + const normalizedCwd = cwd.replace(/\\/g, '/'); + + if (!normalizedScriptPath.includes('/node_modules/')) { + return 'global'; + } + + if (normalizedScriptPath.includes('/.npm/_npx/') || normalizedScriptPath.includes('/npm-cache/_npx/')) { + return 'local'; + } + + const packageRoot = normalizedScriptPath.split('/node_modules/')[0]; + if (!packageRoot) { + return 'global'; + } + + const scriptPathComparable = process.platform === 'win32' + ? normalizedScriptPath.toLowerCase() + : normalizedScriptPath; + const cwdComparable = process.platform === 'win32' ? normalizedCwd.toLowerCase() : normalizedCwd; + const packageRootComparable = process.platform === 'win32' ? packageRoot.toLowerCase() : packageRoot; + + const projectOwnsPackage = + cwdComparable === packageRootComparable || + cwdComparable.startsWith(`${packageRootComparable}/`) || + packageRootComparable.startsWith(`${cwdComparable}/`); + + return projectOwnsPackage ? 'local' : 'global'; } export function detectInstallScope(): 'local' | 'global' { diff --git a/apps/cli/test/self-update.test.ts b/apps/cli/test/self-update.test.ts index 9b79ad3e..1771fd92 100644 --- a/apps/cli/test/self-update.test.ts +++ b/apps/cli/test/self-update.test.ts @@ -56,6 +56,24 @@ describe('detectInstallScopeFromPath', () => { ); }); + test('detects global for Windows npm global install path', () => { + expect( + detectInstallScopeFromPath( + 'C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\agentv\\dist\\cli.js', + 'C:\\Users\\dev\\work\\repo', + ), + ).toBe('global'); + }); + + test('detects local when cwd is inside the project using the dependency', () => { + expect( + detectInstallScopeFromPath( + 'C:\\Users\\dev\\repo\\node_modules\\agentv\\dist\\cli.js', + 'C:\\Users\\dev\\repo\\packages\\cli', + ), + ).toBe('local'); + }); + test('treats unrelated directory containing node_modules substring as global', () => { // A path with the substring but no actual `node_modules` path segment // (e.g. a third-party tool installed under /opt/my_node_modules_tool/) From a0696d83383a590eb98930c3db03ebbdeedd7eb3 Mon Sep 17 00:00:00 2001 From: Christopher Date: Mon, 4 May 2026 13:44:22 +1000 Subject: [PATCH 2/3] test(cli): align self-update scope assertions --- apps/cli/test/self-update.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/cli/test/self-update.test.ts b/apps/cli/test/self-update.test.ts index 1771fd92..02b9a93e 100644 --- a/apps/cli/test/self-update.test.ts +++ b/apps/cli/test/self-update.test.ts @@ -27,7 +27,12 @@ describe('detectPackageManagerFromPath', () => { describe('detectInstallScopeFromPath', () => { test('detects local for project node_modules path', () => { - expect(detectInstallScopeFromPath('/home/user/proj/node_modules/.bin/agentv')).toBe('local'); + expect( + detectInstallScopeFromPath( + '/home/user/proj/node_modules/.bin/agentv', + '/home/user/proj', + ), + ).toBe('local'); }); test('detects local for nested npx cache path', () => { @@ -51,9 +56,12 @@ describe('detectInstallScopeFromPath', () => { }); test('detects local for Windows node_modules path', () => { - expect(detectInstallScopeFromPath('C:\\Users\\dev\\proj\\node_modules\\.bin\\agentv.cmd')).toBe( - 'local', - ); + expect( + detectInstallScopeFromPath( + 'C:\\Users\\dev\\proj\\node_modules\\.bin\\agentv.cmd', + 'C:\\Users\\dev\\proj', + ), + ).toBe('local'); }); test('detects global for Windows npm global install path', () => { From 0a23d521fe9c04c8f377a2a3ab0db6b4719dbe12 Mon Sep 17 00:00:00 2001 From: Christopher Date: Mon, 4 May 2026 13:55:38 +1000 Subject: [PATCH 3/3] fix(cli): tighten self-update scope heuristic --- apps/cli/src/self-update.ts | 7 +++---- apps/cli/test/self-update.test.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/self-update.ts b/apps/cli/src/self-update.ts index c225e051..2951fe0b 100644 --- a/apps/cli/src/self-update.ts +++ b/apps/cli/src/self-update.ts @@ -51,8 +51,8 @@ export function detectPackageManager(): 'bun' | 'npm' { * Detect whether agentv was invoked from a local project install. * npm global installs can also live under a `node_modules` segment, so * the path alone is not enough. We treat `npx` cache paths as local and - * otherwise require the package root to be the current project or one of - * its ancestors before classifying it as local. + * otherwise require the current working directory to be inside the package + * root before classifying it as local. */ export function detectInstallScopeFromPath( scriptPath: string, @@ -82,8 +82,7 @@ export function detectInstallScopeFromPath( const projectOwnsPackage = cwdComparable === packageRootComparable || - cwdComparable.startsWith(`${packageRootComparable}/`) || - packageRootComparable.startsWith(`${cwdComparable}/`); + cwdComparable.startsWith(`${packageRootComparable}/`); return projectOwnsPackage ? 'local' : 'global'; } diff --git a/apps/cli/test/self-update.test.ts b/apps/cli/test/self-update.test.ts index 02b9a93e..00a4e825 100644 --- a/apps/cli/test/self-update.test.ts +++ b/apps/cli/test/self-update.test.ts @@ -73,6 +73,15 @@ describe('detectInstallScopeFromPath', () => { ).toBe('global'); }); + test('detects global for Windows npm global install path from home directory', () => { + expect( + detectInstallScopeFromPath( + 'C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\agentv\\dist\\cli.js', + 'C:\\Users\\dev', + ), + ).toBe('global'); + }); + test('detects local when cwd is inside the project using the dependency', () => { expect( detectInstallScopeFromPath(