fix(cli): routed npm self updates through npm

- Detected npm-owned global bin paths and Windows npm launcher shims before falling back to binary replacement.
- Added npm install argv construction that pins the registry and native package versions.
- Covered npm shim resolution and npm update arguments in update-cli tests.

Fixes #5053
This commit is contained in:
roboomp
2026-07-10 08:53:20 +00:00
parent 29deeef876
commit 3876f60d65
3 changed files with 91 additions and 11 deletions
+62 -11
View File
@@ -109,6 +109,19 @@ async function getBunGlobalBinDir(): Promise<string | undefined> {
}
}
async function getNpmGlobalBinDir(): Promise<string | undefined> {
if (!$which("npm")) return undefined;
try {
const result = await $`npm prefix -g`.quiet().nothrow();
if (result.exitCode !== 0) return undefined;
const prefix = result.text().trim();
if (prefix.length === 0) return undefined;
return process.platform === "win32" ? prefix : path.join(prefix, "bin");
} catch {
return undefined;
}
}
async function getHomebrewFormulaPrefix(): Promise<string | undefined> {
if (!$which("brew")) return undefined;
for (const formula of [HOMEBREW_FORMULA, APP_NAME]) {
@@ -189,26 +202,36 @@ function isPathInDirectory(filePath: string, directoryPath: string): boolean {
return isPathInDirectoryLexical(resolvedFile, dirReal);
}
type UpdateMethod = "brew" | "mise" | "bun" | "binary";
type UpdateMethod = "brew" | "mise" | "bun" | "npm" | "binary";
interface UpdateMethodResolutionOptions {
homebrewPrefix?: string;
miseBinDirs?: readonly string[];
miseDataDir?: string;
npmBinDir?: string;
}
type UpdateTarget = { method: "brew" } | { method: "mise" } | { method: "bun" } | { method: "binary"; path: string };
type UpdateTarget =
| { method: "brew" }
| { method: "mise" }
| { method: "bun" }
| { method: "npm" }
| { method: "binary"; path: string };
function resolveUpdateMethod(
ompPath: string,
bunBinDir: string | undefined,
options: UpdateMethodResolutionOptions = {},
): UpdateMethod {
const { homebrewPrefix, miseBinDirs = [], miseDataDir } = options;
const { homebrewPrefix, miseBinDirs = [], miseDataDir, npmBinDir } = options;
const launcherExtension = path.extname(ompPath).toLowerCase();
const isWindowsScriptLauncher =
launcherExtension === ".cmd" || launcherExtension === ".ps1" || launcherExtension === ".bat";
if (homebrewPrefix && isPathInDirectory(ompPath, path.join(homebrewPrefix, "bin"))) return "brew";
if (miseBinDirs.some(dir => isPathInDirectory(ompPath, dir))) return "mise";
if (miseDataDir && isPathInDirectory(ompPath, path.join(miseDataDir, "shims"))) return "mise";
if (bunBinDir && isPathInDirectory(ompPath, bunBinDir)) return "bun";
if ((npmBinDir && isPathInDirectory(ompPath, npmBinDir)) || isWindowsScriptLauncher) return "npm";
return "binary";
}
@@ -221,6 +244,7 @@ export function resolveUpdateMethodForTest(
}
async function resolveUpdateTarget(): Promise<UpdateTarget> {
const bunBinDir = await getBunGlobalBinDir();
const npmBinDir = await getNpmGlobalBinDir();
const homebrewPrefix = await getHomebrewFormulaPrefix();
const miseAvailable = $which("mise") !== undefined;
const miseBinDirs = miseAvailable ? await getMiseBinDirs() : [];
@@ -228,7 +252,7 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
const ompPath = resolveOmpPath();
if (ompPath) {
const method = resolveUpdateMethod(ompPath, bunBinDir, { homebrewPrefix, miseBinDirs, miseDataDir });
const method = resolveUpdateMethod(ompPath, bunBinDir, { homebrewPrefix, miseBinDirs, miseDataDir, npmBinDir });
if (method === "binary") return { method, path: ompPath };
return { method };
}
@@ -715,6 +739,14 @@ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions):
}
}
function buildVersionedPackageInstallArgs(expectedVersion: string, nativeTag: string): string[] {
const args = [`${PACKAGE}@${expectedVersion}`, `${NATIVES_PACKAGE}@${expectedVersion}`];
if (SUPPORTED_NATIVE_TAGS.has(nativeTag)) {
args.push(`${NATIVES_PACKAGE}-${nativeTag}@${expectedVersion}`);
}
return args;
}
/**
* Build the bun argv used to globally install a specific omp version.
*
@@ -746,17 +778,23 @@ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions):
* See #1824.
*/
export function buildBunInstallArgs(expectedVersion: string, nativeTag: string = currentNativeTag()): string[] {
const args = [
return [
"install",
"-g",
"--no-cache",
`--registry=${NPM_REGISTRY}`,
`${PACKAGE}@${expectedVersion}`,
`${NATIVES_PACKAGE}@${expectedVersion}`,
...buildVersionedPackageInstallArgs(expectedVersion, nativeTag),
];
}
/** Build the npm argv used to update npm-managed global installs. */
export function buildNpmInstallArgs(expectedVersion: string, nativeTag: string = currentNativeTag()): string[] {
const args = [
"install",
"-g",
`--registry=${NPM_REGISTRY}`,
...buildVersionedPackageInstallArgs(expectedVersion, nativeTag),
];
if (SUPPORTED_NATIVE_TAGS.has(nativeTag)) {
args.push(`${NATIVES_PACKAGE}-${nativeTag}@${expectedVersion}`);
}
return args;
}
@@ -773,7 +811,7 @@ export function buildMiseForceInstallArgs(expectedVersion: string): string[] {
}
/**
* Update via bun package manager.
* Update via package manager.
*/
async function updateViaBun(expectedVersion: string): Promise<void> {
console.log(chalk.dim("Updating via bun..."));
@@ -794,6 +832,17 @@ async function updateViaBun(expectedVersion: string): Promise<void> {
}
}
async function updateViaNpm(expectedVersion: string): Promise<void> {
console.log(chalk.dim("Updating via npm..."));
const args = buildNpmInstallArgs(expectedVersion);
const result = await $`npm ${args}`.nothrow();
if (result.exitCode !== 0) {
throw new Error(`npm install failed with exit code ${result.exitCode}`);
}
await printVerification(expectedVersion);
}
async function updateViaHomebrew(expectedVersion: string, force: boolean): Promise<void> {
console.log(chalk.dim("Updating Homebrew formulae..."));
const update = await $`brew update`.nothrow();
@@ -920,6 +969,8 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
await updateViaMise(release.version, opts.force);
} else if (target.method === "bun") {
await updateViaBun(release.version);
} else if (target.method === "npm") {
await updateViaNpm(release.version);
} else {
await updateViaBinaryAt(target.path, release.version);
}