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
60 changes: 58 additions & 2 deletions apps/cli/src/self-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,19 @@
* `node_modules/.bin/agentv`); update the local dep instead of the global
* install. Otherwise, update globally (default).
*
* Package-manager command resolution prefers runtime-adjacent executables
* (for example Node's bundled npm-cli.js or the current Bun executable)
* before falling back to PATH. This keeps self-update working in shells
* where `agentv` is reachable but `npm`/`bun` are not on PATH.
*
* To add a new package manager: add a case to `detectPackageManagerFromPath()`
* and a corresponding install-args entry in `getInstallArgs()`.
* and a corresponding install-args / resolver entry below.
*/

import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { get } from 'node:https';
import { basename, dirname, join, win32 } from 'node:path';

const NPM_REGISTRY_URL = 'https://registry.npmjs.org/agentv/latest';

Expand Down Expand Up @@ -116,6 +123,54 @@ export function getInstallArgs(
return scope === 'global' ? [baseCmd, '-g', pkg] : [baseCmd, pkg];
}

function findBundledNpmCli(
execPath: string,
platform: NodeJS.Platform,
exists: (path: string) => boolean,
): string | undefined {
const pathApi = platform === 'win32' ? win32 : { dirname, join };
const execDir = pathApi.dirname(execPath);
const candidates =
platform === 'win32'
? [pathApi.join(execDir, 'node_modules', 'npm', 'bin', 'npm-cli.js')]
: [
pathApi.join(execDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
pathApi.join(execDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
];

return candidates.find((candidate) => exists(candidate));
}

export function resolvePackageManagerCommand(
pm: 'bun' | 'npm',
args: string[],
options?: {
execPath?: string;
platform?: NodeJS.Platform;
exists?: (path: string) => boolean;
},
): { cmd: string; args: string[] } {
const execPath = options?.execPath ?? process.execPath;
const platform = options?.platform ?? process.platform;
const exists = options?.exists ?? existsSync;
const pathApi = platform === 'win32' ? win32 : { basename };

if (pm === 'bun') {
const runtimeName = pathApi.basename(execPath).toLowerCase();
if ((runtimeName === 'bun' || runtimeName === 'bun.exe') && exists(execPath)) {
return { cmd: execPath, args };
}
return { cmd: 'bun', args };
}

const npmCliPath = findBundledNpmCli(execPath, platform, exists);
if (npmCliPath) {
return { cmd: execPath, args: [npmCliPath, ...args] };
}

return { cmd: 'npm', args };
}

/**
* Run the self-update flow: install agentv using the detected (or specified)
* package manager, scoped to the detected install location (global by default,
Expand Down Expand Up @@ -146,9 +201,10 @@ export async function performSelfUpdate(options?: {
const scope = options?.scope ?? detectInstallScope();

const args = getInstallArgs(pm, versionSpec, scope);
const command = resolvePackageManagerCommand(pm, args);

try {
const result = await runCommand(pm, args);
const result = await runCommand(command.cmd, command.args);

if (result.exitCode !== 0) {
return { success: false, currentVersion, scope };
Expand Down
49 changes: 48 additions & 1 deletion apps/cli/test/self-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
detectInstallScopeFromPath,
detectPackageManagerFromPath,
} from '../src/commands/self/index.js';
import { getInstallArgs } from '../src/self-update.js';
import { getInstallArgs, resolvePackageManagerCommand } from '../src/self-update.js';

describe('detectPackageManagerFromPath', () => {
test('detects bun when path contains .bun', () => {
Expand Down Expand Up @@ -93,3 +93,50 @@ describe('getInstallArgs', () => {
expect(getInstallArgs('npm', '>=4.1.0', 'local')).toEqual(['install', 'agentv@>=4.1.0']);
});
});

describe('resolvePackageManagerCommand', () => {
test('resolves npm via npm-cli.js next to process.execPath on Windows', () => {
const execPath = 'C:\\Program Files\\nodejs\\node.exe';
const npmCliPath = 'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js';

expect(
resolvePackageManagerCommand('npm', ['install', '-g', 'agentv@latest'], {
execPath,
platform: 'win32',
exists: (candidate) => candidate === npmCliPath,
}),
).toEqual({
cmd: execPath,
args: [npmCliPath, 'install', '-g', 'agentv@latest'],
});
});

test('resolves npm via bundled npm-cli.js near node on unix-like installs', () => {
const execPath = '/home/user/.nvm/versions/node/v20.19.0/bin/node';
const npmCliPath = '/home/user/.nvm/versions/node/v20.19.0/lib/node_modules/npm/bin/npm-cli.js';

expect(
resolvePackageManagerCommand('npm', ['install', '-g', 'agentv@latest'], {
execPath,
platform: 'linux',
exists: (candidate) => candidate === npmCliPath,
}),
).toEqual({
cmd: execPath,
args: [npmCliPath, 'install', '-g', 'agentv@latest'],
});
});

test('falls back to PATH when no runtime-adjacent npm installation is found', () => {
expect(
resolvePackageManagerCommand('npm', ['install', '-g', 'agentv@latest'], {
execPath: '/usr/bin/node',
platform: 'linux',
exists: () => false,
}),
).toEqual({
cmd: 'npm',
args: ['install', '-g', 'agentv@latest'],
});
});
});
4 changes: 2 additions & 2 deletions scripts/check-grader-scores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
* 4. Run this script to verify.
*/

import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { globSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { parse as parseYaml } from 'yaml';

// ---------------------------------------------------------------------------
Expand Down
Loading