diff --git a/apps/cli/src/commands/self/index.ts b/apps/cli/src/commands/self/index.ts index 649bd754..8ab7ad2a 100644 --- a/apps/cli/src/commands/self/index.ts +++ b/apps/cli/src/commands/self/index.ts @@ -1,9 +1,14 @@ import { command, flag, subcommands } from 'cmd-ts'; import packageJson from '../../../package.json' with { type: 'json' }; -import { detectPackageManager, fetchLatestVersion, performSelfUpdate } from '../../self-update.js'; +import { + detectInstallScope, + detectPackageManager, + fetchLatestVersion, + performSelfUpdate, +} from '../../self-update.js'; // Re-export for existing tests -export { detectPackageManagerFromPath } from '../../self-update.js'; +export { detectInstallScopeFromPath, detectPackageManagerFromPath } from '../../self-update.js'; const updateCommand = command({ name: 'update', @@ -40,9 +45,11 @@ const updateCommand = command({ if (latestVersion) { console.log(`Update available: ${currentVersion} → ${latestVersion}`); } - console.log(`Updating agentv using ${pm}...\n`); + const scope = detectInstallScope(); + const scopeLabel = scope === 'local' ? 'local project install' : 'global install'; + console.log(`Updating agentv using ${pm} (${scopeLabel})...\n`); - const result = await performSelfUpdate({ pm, currentVersion }); + const result = await performSelfUpdate({ pm, currentVersion, scope }); if (!result.success) { console.error('\nUpdate failed.'); diff --git a/apps/cli/src/self-update.ts b/apps/cli/src/self-update.ts index e2720496..2da2bf16 100644 --- a/apps/cli/src/self-update.ts +++ b/apps/cli/src/self-update.ts @@ -11,6 +11,11 @@ * * When called from `agentv self update` (no range), it installs `@latest`. * + * Install scope detection: if `process.argv[1]` contains `node_modules`, + * agentv was invoked from a local project dependency (e.g. `npx agentv` or + * `node_modules/.bin/agentv`); update the local dep instead of the global + * install. Otherwise, update globally (default). + * * To add a new package manager: add a case to `detectPackageManagerFromPath()` * and a corresponding install-args entry in `getInstallArgs()`. */ @@ -35,6 +40,24 @@ export function detectPackageManager(): 'bun' | 'npm' { return detectPackageManagerFromPath(process.argv[1] ?? ''); } +/** + * 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. + */ +export function detectInstallScopeFromPath(scriptPath: string): 'local' | 'global' { + const hasSegment = + scriptPath.includes('/node_modules/') || scriptPath.includes('\\node_modules\\'); + return hasSegment ? 'local' : 'global'; +} + +export function detectInstallScope(): 'local' | 'global' { + return detectInstallScopeFromPath(process.argv[1] ?? ''); +} + function runCommand(cmd: string, args: string[]): Promise<{ exitCode: number; stdout: string }> { return new Promise((resolve, reject) => { // No shell: true — args are passed directly to execvp, avoiding shell @@ -83,37 +106,52 @@ export function fetchLatestVersion(): Promise { }); } -function getInstallArgs(pm: 'bun' | 'npm', versionSpec: string): string[] { +export function getInstallArgs( + pm: 'bun' | 'npm', + versionSpec: string, + scope: 'local' | 'global', +): string[] { const pkg = `agentv@${versionSpec}`; - return pm === 'npm' ? ['install', '-g', pkg] : ['add', '-g', pkg]; + const baseCmd = pm === 'npm' ? 'install' : 'add'; + return scope === 'global' ? [baseCmd, '-g', pkg] : [baseCmd, pkg]; } /** - * Run the self-update flow: install agentv globally using the detected - * (or specified) package manager. + * Run the self-update flow: install agentv using the detected (or specified) + * package manager, scoped to the detected install location (global by default, + * local when invoked from a project's `node_modules`). * * @param options.pm - Force a specific package manager * @param options.currentVersion - Current installed version (for display) * @param options.versionRange - Semver range from config (e.g., ">=4.1.0"). * When provided, used as the npm/bun version specifier so the update * stays within the project's constraints. When omitted, installs `@latest`. + * @param options.scope - Force local or global install. Defaults to + * auto-detection based on `process.argv[1]`. */ export async function performSelfUpdate(options?: { pm?: 'bun' | 'npm'; currentVersion?: string; versionRange?: string; -}): Promise<{ success: boolean; currentVersion: string; newVersion?: string }> { + scope?: 'local' | 'global'; +}): Promise<{ + success: boolean; + currentVersion: string; + newVersion?: string; + scope: 'local' | 'global'; +}> { const pm = options?.pm ?? detectPackageManager(); const currentVersion = options?.currentVersion ?? 'unknown'; const versionSpec = options?.versionRange ?? 'latest'; + const scope = options?.scope ?? detectInstallScope(); - const args = getInstallArgs(pm, versionSpec); + const args = getInstallArgs(pm, versionSpec, scope); try { const result = await runCommand(pm, args); if (result.exitCode !== 0) { - return { success: false, currentVersion }; + return { success: false, currentVersion, scope }; } // Best-effort version check after update @@ -125,7 +163,7 @@ export async function performSelfUpdate(options?: { // Ignore - version check is best-effort } - return { success: true, currentVersion, newVersion }; + return { success: true, currentVersion, newVersion, scope }; } catch (error) { if (error instanceof Error) { if (error.message.includes('ENOENT') || error.message.includes('not found')) { @@ -135,6 +173,6 @@ export async function performSelfUpdate(options?: { console.error(`Error: ${error.message}`); } } - return { success: false, currentVersion }; + return { success: false, currentVersion, scope }; } } diff --git a/apps/cli/test/self-update.test.ts b/apps/cli/test/self-update.test.ts index 55af7fc3..17620b05 100644 --- a/apps/cli/test/self-update.test.ts +++ b/apps/cli/test/self-update.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { detectPackageManagerFromPath } from '../src/commands/self/index.js'; +import { + detectInstallScopeFromPath, + detectPackageManagerFromPath, +} from '../src/commands/self/index.js'; +import { getInstallArgs } from '../src/self-update.js'; describe('detectPackageManagerFromPath', () => { test('detects bun when path contains .bun', () => { @@ -20,3 +24,72 @@ describe('detectPackageManagerFromPath', () => { expect(detectPackageManagerFromPath('')).toBe('npm'); }); }); + +describe('detectInstallScopeFromPath', () => { + test('detects local for project node_modules path', () => { + expect(detectInstallScopeFromPath('/home/user/proj/node_modules/.bin/agentv')).toBe('local'); + }); + + test('detects local for nested npx cache path', () => { + expect( + detectInstallScopeFromPath('/home/user/.npm/_npx/abc123/node_modules/agentv/dist/cli.js'), + ).toBe('local'); + }); + + test('detects global for system bin path', () => { + expect(detectInstallScopeFromPath('/usr/local/bin/agentv')).toBe('global'); + }); + + test('detects global for bun global bin path', () => { + expect(detectInstallScopeFromPath('/home/user/.bun/bin/agentv')).toBe('global'); + }); + + test('detects global for nvm-managed path without node_modules', () => { + expect(detectInstallScopeFromPath('/home/user/.nvm/versions/node/v20/bin/agentv')).toBe( + 'global', + ); + }); + + test('detects local for Windows node_modules path', () => { + expect(detectInstallScopeFromPath('C:\\Users\\dev\\proj\\node_modules\\.bin\\agentv.cmd')).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/) + // must not be misclassified as local. + expect(detectInstallScopeFromPath('/opt/my_node_modules_tool/bin/agentv')).toBe('global'); + }); + + test('defaults to global for empty string', () => { + expect(detectInstallScopeFromPath('')).toBe('global'); + }); +}); + +describe('getInstallArgs', () => { + test('global npm uses -g flag', () => { + expect(getInstallArgs('npm', 'latest', 'global')).toEqual(['install', '-g', 'agentv@latest']); + }); + + test('local npm drops -g flag', () => { + const args = getInstallArgs('npm', 'latest', 'local'); + expect(args).toEqual(['install', 'agentv@latest']); + expect(args).not.toContain('-g'); + }); + + test('global bun uses -g flag', () => { + expect(getInstallArgs('bun', 'latest', 'global')).toEqual(['add', '-g', 'agentv@latest']); + }); + + test('local bun drops -g flag', () => { + const args = getInstallArgs('bun', 'latest', 'local'); + expect(args).toEqual(['add', 'agentv@latest']); + expect(args).not.toContain('-g'); + }); + + test('forwards a semver range as the version spec', () => { + expect(getInstallArgs('npm', '>=4.1.0', 'local')).toEqual(['install', 'agentv@>=4.1.0']); + }); +});