fix: detect package manager for updates instead of hardcoding npm (#858)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-06 18:01:21 -08:00
committed by GitHub
parent 867185d115
commit 81be412e14
2 changed files with 234 additions and 26 deletions

View File

@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
// We need to test the internal functions, so we'll recreate them here
// In a real scenario, we'd export these for testing or use dependency injection
import {
checkForUpdate,
detectPackageManager,
} from "../../updater/auto-update";
describe("auto-update ENOTEMPTY handling", () => {
let testDir: string;
@@ -138,3 +139,138 @@ npm error ENOTEMPTY: directory not empty`;
});
});
});
describe("detectPackageManager", () => {
let originalArgv1: string;
let originalEnv: string | undefined;
beforeEach(() => {
originalArgv1 = process.argv[1] || "";
originalEnv = process.env.LETTA_PACKAGE_MANAGER;
delete process.env.LETTA_PACKAGE_MANAGER;
});
afterEach(() => {
process.argv[1] = originalArgv1;
if (originalEnv !== undefined) {
process.env.LETTA_PACKAGE_MANAGER = originalEnv;
} else {
delete process.env.LETTA_PACKAGE_MANAGER;
}
});
test("detects bun from path containing /.bun/", () => {
process.argv[1] =
"/Users/test/.bun/install/global/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("bun");
});
test("detects pnpm from path containing /.pnpm/", () => {
process.argv[1] =
"/Users/test/.local/share/pnpm/global/5/.pnpm/@letta-ai+letta-code@0.14.11/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("pnpm");
});
test("detects pnpm from path containing /pnpm/", () => {
process.argv[1] =
"/Users/test/.local/share/pnpm/global/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("pnpm");
});
test("defaults to npm for standard nvm path", () => {
process.argv[1] =
"/Users/test/.nvm/versions/node/v20.10.0/lib/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("npm");
});
test("defaults to npm for standard npm global path", () => {
process.argv[1] =
"/usr/local/lib/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("npm");
});
test("detects bun from Windows-style path", () => {
process.argv[1] =
"C:\\Users\\test\\.bun\\install\\global\\node_modules\\@letta-ai\\letta-code\\dist\\index.js";
expect(detectPackageManager()).toBe("bun");
});
test("LETTA_PACKAGE_MANAGER override returns specified PM", () => {
process.env.LETTA_PACKAGE_MANAGER = "bun";
// Even with an npm-style path, env var wins
process.argv[1] =
"/usr/local/lib/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("bun");
});
test("invalid LETTA_PACKAGE_MANAGER falls back to path detection", () => {
process.env.LETTA_PACKAGE_MANAGER = "invalid";
process.argv[1] =
"/Users/test/.bun/install/global/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("bun");
});
test("invalid LETTA_PACKAGE_MANAGER with npm path falls back to npm", () => {
process.env.LETTA_PACKAGE_MANAGER = "yarn";
process.argv[1] =
"/usr/local/lib/node_modules/@letta-ai/letta-code/dist/index.js";
expect(detectPackageManager()).toBe("npm");
});
});
describe("checkForUpdate with fetch", () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
test("returns updateAvailable when registry version differs", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ version: "99.0.0" }), { status: 200 }),
),
) as unknown as typeof fetch;
const result = await checkForUpdate();
expect(result.updateAvailable).toBe(true);
expect(result.latestVersion).toBe("99.0.0");
expect(result.checkFailed).toBeUndefined();
});
test("returns checkFailed on non-2xx response", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response("Not Found", { status: 404 })),
) as unknown as typeof fetch;
const result = await checkForUpdate();
expect(result.updateAvailable).toBe(false);
expect(result.checkFailed).toBe(true);
});
test("returns checkFailed on malformed JSON (no version field)", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ name: "test" }), { status: 200 }),
),
) as unknown as typeof fetch;
const result = await checkForUpdate();
expect(result.updateAvailable).toBe(false);
expect(result.checkFailed).toBe(true);
});
test("returns checkFailed on network error", async () => {
globalThis.fetch = mock(() =>
Promise.reject(new Error("fetch failed")),
) as unknown as typeof fetch;
const result = await checkForUpdate();
expect(result.updateAvailable).toBe(false);
expect(result.checkFailed).toBe(true);
});
});

View File

@@ -19,6 +19,56 @@ interface UpdateCheckResult {
updateAvailable: boolean;
latestVersion?: string;
currentVersion: string;
/** True when the version check itself failed (network error, registry down, etc.) */
checkFailed?: boolean;
}
// Supported package managers for global install/update
export type PackageManager = "npm" | "bun" | "pnpm";
const INSTALL_CMD: Record<PackageManager, string> = {
npm: "npm install -g @letta-ai/letta-code@latest",
bun: "bun add -g @letta-ai/letta-code@latest",
pnpm: "pnpm add -g @letta-ai/letta-code@latest",
};
const VALID_PACKAGE_MANAGERS = new Set<string>(Object.keys(INSTALL_CMD));
/**
* Detect which package manager was used to install this binary.
* Checks LETTA_PACKAGE_MANAGER env var first, then inspects the resolved binary path.
*/
export function detectPackageManager(): PackageManager {
const envOverride = process.env.LETTA_PACKAGE_MANAGER;
if (envOverride) {
if (VALID_PACKAGE_MANAGERS.has(envOverride)) {
debugLog("Package manager from LETTA_PACKAGE_MANAGER:", envOverride);
return envOverride as PackageManager;
}
debugLog(
`Invalid LETTA_PACKAGE_MANAGER="${envOverride}", falling back to path detection`,
);
}
const argv = process.argv[1] || "";
let resolvedPath = argv;
try {
resolvedPath = realpathSync(argv);
} catch {
// If realpath fails, use original path
}
if (/[/\\]\.bun[/\\]/.test(resolvedPath)) {
debugLog("Detected package manager from path: bun");
return "bun";
}
if (/[/\\]\.?pnpm[/\\]/.test(resolvedPath)) {
debugLog("Detected package manager from path: pnpm");
return "pnpm";
}
debugLog("Detected package manager from path: npm (default)");
return "npm";
}
function isAutoUpdateEnabled(): boolean {
@@ -46,7 +96,7 @@ function isRunningLocally(): boolean {
return !resolvedPath.includes("node_modules");
}
async function checkForUpdate(): Promise<UpdateCheckResult> {
export async function checkForUpdate(): Promise<UpdateCheckResult> {
const currentVersion = getVersion();
debugLog("Current version:", currentVersion);
@@ -58,13 +108,20 @@ async function checkForUpdate(): Promise<UpdateCheckResult> {
}
try {
debugLog("Checking npm for latest version...");
const { stdout } = await execAsync(
"npm view @letta-ai/letta-code version",
{ timeout: 5000 },
debugLog("Checking registry for latest version...");
const res = await fetch(
"https://registry.npmjs.org/@letta-ai/letta-code/latest",
{ signal: AbortSignal.timeout(5000) },
);
const latestVersion = stdout.trim();
debugLog("Latest version from npm:", latestVersion);
if (!res.ok) {
throw new Error(`Registry returned ${res.status}`);
}
const data = (await res.json()) as { version?: string };
if (typeof data.version !== "string") {
throw new Error("Unexpected registry response shape");
}
const latestVersion = data.version;
debugLog("Latest version from registry:", latestVersion);
if (latestVersion !== currentVersion) {
debugLog("Update available!");
@@ -77,6 +134,11 @@ async function checkForUpdate(): Promise<UpdateCheckResult> {
debugLog("Already on latest version");
} catch (error) {
debugLog("Failed to check for updates:", error);
return {
updateAvailable: false,
currentVersion,
checkFailed: true,
};
}
return {
@@ -123,32 +185,36 @@ async function performUpdate(): Promise<{
error?: string;
enotemptyFailed?: boolean;
}> {
// Pre-emptively clean up orphaned directories to prevent ENOTEMPTY errors
const globalPath = await getNpmGlobalPath();
if (globalPath) {
debugLog("Pre-cleaning orphaned directories in:", globalPath);
await cleanupOrphanedDirs(globalPath);
const pm = detectPackageManager();
const installCmd = INSTALL_CMD[pm];
debugLog("Detected package manager:", pm);
debugLog("Install command:", installCmd);
// ENOTEMPTY orphan cleanup is npm-specific (npm's temp rename behavior)
let globalPath: string | null = null;
if (pm === "npm") {
globalPath = await getNpmGlobalPath();
if (globalPath) {
debugLog("Pre-cleaning orphaned directories in:", globalPath);
await cleanupOrphanedDirs(globalPath);
}
}
try {
debugLog("Running npm install -g @letta-ai/letta-code@latest...");
await execAsync("npm install -g @letta-ai/letta-code@latest", {
timeout: 60000,
});
debugLog(`Running ${installCmd}...`);
await execAsync(installCmd, { timeout: 60000 });
debugLog("Update completed successfully");
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
// If ENOTEMPTY still occurred (race condition or new orphans), try cleanup + retry once
if (errorMsg.includes("ENOTEMPTY") && globalPath) {
// ENOTEMPTY retry is npm-specific
if (pm === "npm" && errorMsg.includes("ENOTEMPTY") && globalPath) {
debugLog("ENOTEMPTY detected, attempting cleanup and retry...");
await cleanupOrphanedDirs(globalPath);
try {
await execAsync("npm install -g @letta-ai/letta-code@latest", {
timeout: 60000,
});
await execAsync(installCmd, { timeout: 60000 });
debugLog("Update succeeded after cleanup retry");
return { success: true };
} catch (retryError) {
@@ -156,7 +222,6 @@ async function performUpdate(): Promise<{
retryError instanceof Error ? retryError.message : String(retryError);
debugLog("Update failed after retry:", retryMsg);
// If it's still ENOTEMPTY after retry, flag it for user notification
if (retryMsg.includes("ENOTEMPTY")) {
return {
success: false,
@@ -219,6 +284,13 @@ export async function manualUpdate(): Promise<{
const result = await checkForUpdate();
if (result.checkFailed) {
return {
success: false,
message: "Could not check for updates (network error). Try again later.",
};
}
if (!result.updateAvailable) {
return {
success: true,