Skip to content

Commit a5fce30

Browse files
authored
feat(cli): self update preserves install scope (local vs global) (#1132)
* 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 * 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.
1 parent ef22041 commit a5fce30

3 files changed

Lines changed: 132 additions & 14 deletions

File tree

apps/cli/src/commands/self/index.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { command, flag, subcommands } from 'cmd-ts';
22
import packageJson from '../../../package.json' with { type: 'json' };
3-
import { detectPackageManager, fetchLatestVersion, performSelfUpdate } from '../../self-update.js';
3+
import {
4+
detectInstallScope,
5+
detectPackageManager,
6+
fetchLatestVersion,
7+
performSelfUpdate,
8+
} from '../../self-update.js';
49

510
// Re-export for existing tests
6-
export { detectPackageManagerFromPath } from '../../self-update.js';
11+
export { detectInstallScopeFromPath, detectPackageManagerFromPath } from '../../self-update.js';
712

813
const updateCommand = command({
914
name: 'update',
@@ -40,9 +45,11 @@ const updateCommand = command({
4045
if (latestVersion) {
4146
console.log(`Update available: ${currentVersion}${latestVersion}`);
4247
}
43-
console.log(`Updating agentv using ${pm}...\n`);
48+
const scope = detectInstallScope();
49+
const scopeLabel = scope === 'local' ? 'local project install' : 'global install';
50+
console.log(`Updating agentv using ${pm} (${scopeLabel})...\n`);
4451

45-
const result = await performSelfUpdate({ pm, currentVersion });
52+
const result = await performSelfUpdate({ pm, currentVersion, scope });
4653

4754
if (!result.success) {
4855
console.error('\nUpdate failed.');

apps/cli/src/self-update.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
*
1212
* When called from `agentv self update` (no range), it installs `@latest`.
1313
*
14+
* Install scope detection: if `process.argv[1]` contains `node_modules`,
15+
* agentv was invoked from a local project dependency (e.g. `npx agentv` or
16+
* `node_modules/.bin/agentv`); update the local dep instead of the global
17+
* install. Otherwise, update globally (default).
18+
*
1419
* To add a new package manager: add a case to `detectPackageManagerFromPath()`
1520
* and a corresponding install-args entry in `getInstallArgs()`.
1621
*/
@@ -35,6 +40,24 @@ export function detectPackageManager(): 'bun' | 'npm' {
3540
return detectPackageManagerFromPath(process.argv[1] ?? '');
3641
}
3742

43+
/**
44+
* Detect whether agentv was invoked from a local project install.
45+
* A path containing a `node_modules` segment indicates a local dependency;
46+
* anything else (system binary, `.bun/bin`, `.nvm/.../bin`) is treated as
47+
* global. Matches both POSIX and Windows path separators so a directory
48+
* that merely embeds the substring (e.g., `/opt/my_node_modules_tool/`)
49+
* isn't misclassified.
50+
*/
51+
export function detectInstallScopeFromPath(scriptPath: string): 'local' | 'global' {
52+
const hasSegment =
53+
scriptPath.includes('/node_modules/') || scriptPath.includes('\\node_modules\\');
54+
return hasSegment ? 'local' : 'global';
55+
}
56+
57+
export function detectInstallScope(): 'local' | 'global' {
58+
return detectInstallScopeFromPath(process.argv[1] ?? '');
59+
}
60+
3861
function runCommand(cmd: string, args: string[]): Promise<{ exitCode: number; stdout: string }> {
3962
return new Promise((resolve, reject) => {
4063
// No shell: true — args are passed directly to execvp, avoiding shell
@@ -83,37 +106,52 @@ export function fetchLatestVersion(): Promise<string | null> {
83106
});
84107
}
85108

86-
function getInstallArgs(pm: 'bun' | 'npm', versionSpec: string): string[] {
109+
export function getInstallArgs(
110+
pm: 'bun' | 'npm',
111+
versionSpec: string,
112+
scope: 'local' | 'global',
113+
): string[] {
87114
const pkg = `agentv@${versionSpec}`;
88-
return pm === 'npm' ? ['install', '-g', pkg] : ['add', '-g', pkg];
115+
const baseCmd = pm === 'npm' ? 'install' : 'add';
116+
return scope === 'global' ? [baseCmd, '-g', pkg] : [baseCmd, pkg];
89117
}
90118

91119
/**
92-
* Run the self-update flow: install agentv globally using the detected
93-
* (or specified) package manager.
120+
* Run the self-update flow: install agentv using the detected (or specified)
121+
* package manager, scoped to the detected install location (global by default,
122+
* local when invoked from a project's `node_modules`).
94123
*
95124
* @param options.pm - Force a specific package manager
96125
* @param options.currentVersion - Current installed version (for display)
97126
* @param options.versionRange - Semver range from config (e.g., ">=4.1.0").
98127
* When provided, used as the npm/bun version specifier so the update
99128
* stays within the project's constraints. When omitted, installs `@latest`.
129+
* @param options.scope - Force local or global install. Defaults to
130+
* auto-detection based on `process.argv[1]`.
100131
*/
101132
export async function performSelfUpdate(options?: {
102133
pm?: 'bun' | 'npm';
103134
currentVersion?: string;
104135
versionRange?: string;
105-
}): Promise<{ success: boolean; currentVersion: string; newVersion?: string }> {
136+
scope?: 'local' | 'global';
137+
}): Promise<{
138+
success: boolean;
139+
currentVersion: string;
140+
newVersion?: string;
141+
scope: 'local' | 'global';
142+
}> {
106143
const pm = options?.pm ?? detectPackageManager();
107144
const currentVersion = options?.currentVersion ?? 'unknown';
108145
const versionSpec = options?.versionRange ?? 'latest';
146+
const scope = options?.scope ?? detectInstallScope();
109147

110-
const args = getInstallArgs(pm, versionSpec);
148+
const args = getInstallArgs(pm, versionSpec, scope);
111149

112150
try {
113151
const result = await runCommand(pm, args);
114152

115153
if (result.exitCode !== 0) {
116-
return { success: false, currentVersion };
154+
return { success: false, currentVersion, scope };
117155
}
118156

119157
// Best-effort version check after update
@@ -125,7 +163,7 @@ export async function performSelfUpdate(options?: {
125163
// Ignore - version check is best-effort
126164
}
127165

128-
return { success: true, currentVersion, newVersion };
166+
return { success: true, currentVersion, newVersion, scope };
129167
} catch (error) {
130168
if (error instanceof Error) {
131169
if (error.message.includes('ENOENT') || error.message.includes('not found')) {
@@ -135,6 +173,6 @@ export async function performSelfUpdate(options?: {
135173
console.error(`Error: ${error.message}`);
136174
}
137175
}
138-
return { success: false, currentVersion };
176+
return { success: false, currentVersion, scope };
139177
}
140178
}

apps/cli/test/self-update.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, test } from 'bun:test';
2-
import { detectPackageManagerFromPath } from '../src/commands/self/index.js';
2+
import {
3+
detectInstallScopeFromPath,
4+
detectPackageManagerFromPath,
5+
} from '../src/commands/self/index.js';
6+
import { getInstallArgs } from '../src/self-update.js';
37

48
describe('detectPackageManagerFromPath', () => {
59
test('detects bun when path contains .bun', () => {
@@ -20,3 +24,72 @@ describe('detectPackageManagerFromPath', () => {
2024
expect(detectPackageManagerFromPath('')).toBe('npm');
2125
});
2226
});
27+
28+
describe('detectInstallScopeFromPath', () => {
29+
test('detects local for project node_modules path', () => {
30+
expect(detectInstallScopeFromPath('/home/user/proj/node_modules/.bin/agentv')).toBe('local');
31+
});
32+
33+
test('detects local for nested npx cache path', () => {
34+
expect(
35+
detectInstallScopeFromPath('/home/user/.npm/_npx/abc123/node_modules/agentv/dist/cli.js'),
36+
).toBe('local');
37+
});
38+
39+
test('detects global for system bin path', () => {
40+
expect(detectInstallScopeFromPath('/usr/local/bin/agentv')).toBe('global');
41+
});
42+
43+
test('detects global for bun global bin path', () => {
44+
expect(detectInstallScopeFromPath('/home/user/.bun/bin/agentv')).toBe('global');
45+
});
46+
47+
test('detects global for nvm-managed path without node_modules', () => {
48+
expect(detectInstallScopeFromPath('/home/user/.nvm/versions/node/v20/bin/agentv')).toBe(
49+
'global',
50+
);
51+
});
52+
53+
test('detects local for Windows node_modules path', () => {
54+
expect(detectInstallScopeFromPath('C:\\Users\\dev\\proj\\node_modules\\.bin\\agentv.cmd')).toBe(
55+
'local',
56+
);
57+
});
58+
59+
test('treats unrelated directory containing node_modules substring as global', () => {
60+
// A path with the substring but no actual `node_modules` path segment
61+
// (e.g. a third-party tool installed under /opt/my_node_modules_tool/)
62+
// must not be misclassified as local.
63+
expect(detectInstallScopeFromPath('/opt/my_node_modules_tool/bin/agentv')).toBe('global');
64+
});
65+
66+
test('defaults to global for empty string', () => {
67+
expect(detectInstallScopeFromPath('')).toBe('global');
68+
});
69+
});
70+
71+
describe('getInstallArgs', () => {
72+
test('global npm uses -g flag', () => {
73+
expect(getInstallArgs('npm', 'latest', 'global')).toEqual(['install', '-g', 'agentv@latest']);
74+
});
75+
76+
test('local npm drops -g flag', () => {
77+
const args = getInstallArgs('npm', 'latest', 'local');
78+
expect(args).toEqual(['install', 'agentv@latest']);
79+
expect(args).not.toContain('-g');
80+
});
81+
82+
test('global bun uses -g flag', () => {
83+
expect(getInstallArgs('bun', 'latest', 'global')).toEqual(['add', '-g', 'agentv@latest']);
84+
});
85+
86+
test('local bun drops -g flag', () => {
87+
const args = getInstallArgs('bun', 'latest', 'local');
88+
expect(args).toEqual(['add', 'agentv@latest']);
89+
expect(args).not.toContain('-g');
90+
});
91+
92+
test('forwards a semver range as the version spec', () => {
93+
expect(getInstallArgs('npm', '>=4.1.0', 'local')).toEqual(['install', 'agentv@>=4.1.0']);
94+
});
95+
});

0 commit comments

Comments
 (0)