From bc88a56d33d028c04e81bceb80759cfad236754e Mon Sep 17 00:00:00 2001 From: Christopher Date: Fri, 17 Apr 2026 00:13:05 +0000 Subject: [PATCH 1/2] feat(cli): self update preserves install scope (local vs global) Detect whether agentv was invoked from a local project dependency (process.argv[1] contains node_modules) versus a global install, and run the package manager install command with matching scope: - Global (default): `npm install -g agentv@latest` / `bun add -g agentv@latest` - Local: `npm install agentv@latest` / `bun add agentv@latest` The `self update` command surfaces the detected scope in its console output so users can see where the update is being applied. Closes #1127 --- apps/cli/src/commands/self/index.ts | 15 ++++++--- apps/cli/src/self-update.ts | 51 ++++++++++++++++++++++++----- apps/cli/test/self-update.test.ts | 35 +++++++++++++++++++- 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/commands/self/index.ts b/apps/cli/src/commands/self/index.ts index 649bd754b..91ba76082 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)' : 'global'; + 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 e2720496f..94fc7601f 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,19 @@ export function detectPackageManager(): 'bun' | 'npm' { return detectPackageManagerFromPath(process.argv[1] ?? ''); } +/** + * Detect whether agentv was invoked from a local project install. + * A path containing `node_modules` indicates a local dependency; anything + * else (system binary, `.bun/bin`, `.nvm/.../bin`) is treated as global. + */ +export function detectInstallScopeFromPath(scriptPath: string): 'local' | 'global' { + return scriptPath.includes('node_modules') ? '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 +101,52 @@ export function fetchLatestVersion(): Promise { }); } -function getInstallArgs(pm: 'bun' | 'npm', versionSpec: string): string[] { +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 +158,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 +168,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 55af7fc34..484f432eb 100644 --- a/apps/cli/test/self-update.test.ts +++ b/apps/cli/test/self-update.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from 'bun:test'; -import { detectPackageManagerFromPath } from '../src/commands/self/index.js'; +import { + detectInstallScopeFromPath, + detectPackageManagerFromPath, +} from '../src/commands/self/index.js'; describe('detectPackageManagerFromPath', () => { test('detects bun when path contains .bun', () => { @@ -20,3 +23,33 @@ 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('defaults to global for empty string', () => { + expect(detectInstallScopeFromPath('')).toBe('global'); + }); +}); From 6793914e69f546d2f5c32f4db483963a257def8c Mon Sep 17 00:00:00 2001 From: Christopher Date: Fri, 17 Apr 2026 00:24:45 +0000 Subject: [PATCH 2/2] test(cli): tighten node_modules path check and cover getInstallArgs Address review feedback: - Match `/node_modules/` (POSIX) or `\node_modules\` (Windows) as an actual path segment so paths that merely embed the substring (e.g. `/opt/my_node_modules_tool/`) aren't misclassified as local. - Export `getInstallArgs` and assert the `-g` flag is present for global and absent for local, for both npm and bun. - Add a Windows path case for `detectInstallScopeFromPath`. - Reword the scope label in the console log to avoid nested parens. --- apps/cli/src/commands/self/index.ts | 2 +- apps/cli/src/self-update.ts | 13 +++++++--- apps/cli/test/self-update.test.ts | 40 +++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/commands/self/index.ts b/apps/cli/src/commands/self/index.ts index 91ba76082..8ab7ad2a6 100644 --- a/apps/cli/src/commands/self/index.ts +++ b/apps/cli/src/commands/self/index.ts @@ -46,7 +46,7 @@ const updateCommand = command({ console.log(`Update available: ${currentVersion} → ${latestVersion}`); } const scope = detectInstallScope(); - const scopeLabel = scope === 'local' ? 'local (project)' : 'global'; + const scopeLabel = scope === 'local' ? 'local project install' : 'global install'; console.log(`Updating agentv using ${pm} (${scopeLabel})...\n`); const result = await performSelfUpdate({ pm, currentVersion, scope }); diff --git a/apps/cli/src/self-update.ts b/apps/cli/src/self-update.ts index 94fc7601f..2da2bf169 100644 --- a/apps/cli/src/self-update.ts +++ b/apps/cli/src/self-update.ts @@ -42,11 +42,16 @@ export function detectPackageManager(): 'bun' | 'npm' { /** * Detect whether agentv was invoked from a local project install. - * A path containing `node_modules` indicates a local dependency; anything - * else (system binary, `.bun/bin`, `.nvm/.../bin`) is treated as global. + * 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' { - return scriptPath.includes('node_modules') ? 'local' : 'global'; + const hasSegment = + scriptPath.includes('/node_modules/') || scriptPath.includes('\\node_modules\\'); + return hasSegment ? 'local' : 'global'; } export function detectInstallScope(): 'local' | 'global' { @@ -101,7 +106,7 @@ export function fetchLatestVersion(): Promise { }); } -function getInstallArgs( +export function getInstallArgs( pm: 'bun' | 'npm', versionSpec: string, scope: 'local' | 'global', diff --git a/apps/cli/test/self-update.test.ts b/apps/cli/test/self-update.test.ts index 484f432eb..17620b059 100644 --- a/apps/cli/test/self-update.test.ts +++ b/apps/cli/test/self-update.test.ts @@ -3,6 +3,7 @@ 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', () => { @@ -49,7 +50,46 @@ describe('detectInstallScopeFromPath', () => { ); }); + 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']); + }); +});