fix: detect package manager for updates instead of hardcoding npm (#858)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user