From 81be412e14061c8ffaf52bbc0d3b4e8758ef2fbf Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Fri, 6 Feb 2026 18:01:21 -0800 Subject: [PATCH] fix: detect package manager for updates instead of hardcoding npm (#858) Co-authored-by: Letta --- src/tests/updater/auto-update.test.ts | 144 +++++++++++++++++++++++++- src/updater/auto-update.ts | 116 +++++++++++++++++---- 2 files changed, 234 insertions(+), 26 deletions(-) diff --git a/src/tests/updater/auto-update.test.ts b/src/tests/updater/auto-update.test.ts index 7c20a6e..fdfa3f5 100644 --- a/src/tests/updater/auto-update.test.ts +++ b/src/tests/updater/auto-update.test.ts @@ -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); + }); +}); diff --git a/src/updater/auto-update.ts b/src/updater/auto-update.ts index a5a0a76..fdd4712 100644 --- a/src/updater/auto-update.ts +++ b/src/updater/auto-update.ts @@ -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 = { + 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(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 { +export async function checkForUpdate(): Promise { const currentVersion = getVersion(); debugLog("Current version:", currentVersion); @@ -58,13 +108,20 @@ async function checkForUpdate(): Promise { } 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 { 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,