Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions apps/cli/src/commands/self/index.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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.');
Expand Down
56 changes: 47 additions & 9 deletions apps/cli/src/self-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
*/
Expand All @@ -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
Expand Down Expand Up @@ -83,37 +106,52 @@ export function fetchLatestVersion(): Promise<string | null> {
});
}

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
Expand All @@ -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')) {
Expand All @@ -135,6 +173,6 @@ export async function performSelfUpdate(options?: {
console.error(`Error: ${error.message}`);
}
}
return { success: false, currentVersion };
return { success: false, currentVersion, scope };
}
}
75 changes: 74 additions & 1 deletion apps/cli/test/self-update.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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']);
});
});
Loading