diff --git a/apps/cli/src/self-update.ts b/apps/cli/src/self-update.ts index 4ac06fe3..2951fe0b 100644 --- a/apps/cli/src/self-update.ts +++ b/apps/cli/src/self-update.ts @@ -49,16 +49,42 @@ 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 current working directory to be inside the package + * root 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}/`); + + 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..00a4e825 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,39 @@ 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', () => { + expect( + detectInstallScopeFromPath( + 'C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\agentv\\dist\\cli.js', + 'C:\\Users\\dev\\work\\repo', + ), + ).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( + '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', () => {