diff --git a/src/index.ts b/src/index.ts index e5a43e3..73f6732 100755 --- a/src/index.ts +++ b/src/index.ts @@ -364,9 +364,21 @@ async function main(): Promise { // Check for updates on startup (non-blocking) const { checkAndAutoUpdate } = await import("./updater/auto-update"); - checkAndAutoUpdate().catch(() => { - // Silently ignore update failures - }); + checkAndAutoUpdate() + .then((result) => { + // Surface ENOTEMPTY failures so users know how to fix + if (result?.enotemptyFailed) { + console.error( + "\nAuto-update failed due to filesystem issue (ENOTEMPTY).", + ); + console.error( + "Fix: rm -rf $(npm prefix -g)/lib/node_modules/@letta-ai/letta-code && npm i -g @letta-ai/letta-code\n", + ); + } + }) + .catch(() => { + // Silently ignore other update failures (network timeouts, etc.) + }); // Clean up old overflow files (non-blocking, 24h retention) const { cleanupOldOverflowFiles } = await import("./tools/impl/overflow"); diff --git a/src/tests/updater/auto-update.test.ts b/src/tests/updater/auto-update.test.ts new file mode 100644 index 0000000..6794447 --- /dev/null +++ b/src/tests/updater/auto-update.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, 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 + +describe("auto-update ENOTEMPTY handling", () => { + let testDir: string; + + beforeEach(() => { + // Create a temp directory for testing + testDir = fs.mkdtempSync(path.join(os.tmpdir(), "letta-test-")); + }); + + afterEach(() => { + // Clean up + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe("cleanupOrphanedDirs logic", () => { + test("removes directories starting with .letta-code-", async () => { + // Create test directories + const lettaAiDir = path.join(testDir, "lib/node_modules/@letta-ai"); + fs.mkdirSync(lettaAiDir, { recursive: true }); + + // Create orphaned temp dirs (should be removed) + const orphan1 = path.join(lettaAiDir, ".letta-code-abc123"); + const orphan2 = path.join(lettaAiDir, ".letta-code-xyz789"); + fs.mkdirSync(orphan1); + fs.mkdirSync(orphan2); + + // Create legitimate dirs (should NOT be removed) + const legitimate = path.join(lettaAiDir, "letta-code"); + const otherPackage = path.join(lettaAiDir, "other-package"); + fs.mkdirSync(legitimate); + fs.mkdirSync(otherPackage); + + // Simulate cleanup logic + const { readdir, rm } = await import("node:fs/promises"); + const entries = await readdir(lettaAiDir); + for (const entry of entries) { + if (entry.startsWith(".letta-code-")) { + await rm(path.join(lettaAiDir, entry), { + recursive: true, + force: true, + }); + } + } + + // Verify + expect(fs.existsSync(orphan1)).toBe(false); + expect(fs.existsSync(orphan2)).toBe(false); + expect(fs.existsSync(legitimate)).toBe(true); + expect(fs.existsSync(otherPackage)).toBe(true); + }); + + test("handles non-existent directory gracefully", async () => { + const nonExistent = path.join(testDir, "does/not/exist"); + const { readdir } = await import("node:fs/promises"); + + // This should not throw + let error: NodeJS.ErrnoException | null = null; + try { + await readdir(nonExistent); + } catch (e) { + error = e as NodeJS.ErrnoException; + } + + expect(error).not.toBeNull(); + expect(error?.code).toBe("ENOENT"); + }); + + test("handles empty directory", async () => { + const emptyDir = path.join(testDir, "empty"); + fs.mkdirSync(emptyDir, { recursive: true }); + + const { readdir } = await import("node:fs/promises"); + const entries = await readdir(emptyDir); + + expect(entries).toEqual([]); + }); + }); + + describe("ENOTEMPTY error detection", () => { + test("detects ENOTEMPTY in npm error message", () => { + const npmError = `npm error code ENOTEMPTY +npm error syscall rename +npm error path /Users/user/.npm-global/lib/node_modules/@letta-ai/letta-code +npm error dest /Users/user/.npm-global/lib/node_modules/@letta-ai/.letta-code-lnWEqMep +npm error errno -66 +npm error ENOTEMPTY: directory not empty`; + + expect(npmError.includes("ENOTEMPTY")).toBe(true); + }); + + test("detects ENOTEMPTY in error.message", () => { + const error = new Error( + "Command failed: npm install -g @letta-ai/letta-code@latest\nnpm error ENOTEMPTY: directory not empty", + ); + + expect(error.message.includes("ENOTEMPTY")).toBe(true); + }); + + test("does not false-positive on other errors", () => { + const networkError = "npm error ETIMEDOUT: network timeout"; + const permissionError = "npm error EACCES: permission denied"; + + expect(networkError.includes("ENOTEMPTY")).toBe(false); + expect(permissionError.includes("ENOTEMPTY")).toBe(false); + }); + }); + + describe("npm global path detection", () => { + test("path structure for cleanup is correct", () => { + // Test that the path we construct is valid + const globalPrefix = "/Users/test/.npm-global"; + const lettaAiDir = path.join(globalPrefix, "lib/node_modules/@letta-ai"); + + expect(lettaAiDir).toBe( + "/Users/test/.npm-global/lib/node_modules/@letta-ai", + ); + }); + + test("path structure works on Windows-style paths", () => { + // Windows uses different separators but path.join handles it + const globalPrefix = "C:\\Users\\test\\AppData\\Roaming\\npm"; + const lettaAiDir = path.join(globalPrefix, "lib/node_modules/@letta-ai"); + + // path.join normalizes separators for the current platform + expect(lettaAiDir).toContain("lib"); + expect(lettaAiDir).toContain("node_modules"); + expect(lettaAiDir).toContain("@letta-ai"); + }); + }); +}); diff --git a/src/updater/auto-update.ts b/src/updater/auto-update.ts index ad73c6f..a5a0a76 100644 --- a/src/updater/auto-update.ts +++ b/src/updater/auto-update.ts @@ -1,5 +1,7 @@ import { exec } from "node:child_process"; import { realpathSync } from "node:fs"; +import { readdir, rm } from "node:fs/promises"; +import { join } from "node:path"; import { promisify } from "node:util"; import { getVersion } from "../version"; @@ -83,7 +85,51 @@ async function checkForUpdate(): Promise { }; } -async function performUpdate(): Promise<{ success: boolean; error?: string }> { +/** + * Get the npm global prefix path (e.g., /Users/name/.npm-global or ~/.nvm/versions/node/v20/lib) + */ +async function getNpmGlobalPath(): Promise { + try { + const { stdout } = await execAsync("npm prefix -g", { timeout: 5000 }); + return stdout.trim(); + } catch { + return null; + } +} + +/** + * Clean up orphaned temp directories left by interrupted npm installs. + * These look like: .letta-code-lnWEqMep (npm's temp rename targets) + */ +async function cleanupOrphanedDirs(globalPath: string): Promise { + const lettaAiDir = join(globalPath, "lib/node_modules/@letta-ai"); + try { + const entries = await readdir(lettaAiDir); + for (const entry of entries) { + // Match orphaned temp dirs like .letta-code-lnWEqMep + if (entry.startsWith(".letta-code-")) { + const orphanPath = join(lettaAiDir, entry); + debugLog("Cleaning orphaned temp directory:", orphanPath); + await rm(orphanPath, { recursive: true, force: true }); + } + } + } catch { + // Directory might not exist or not readable, ignore + } +} + +async function performUpdate(): Promise<{ + success: boolean; + 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); + } + try { debugLog("Running npm install -g @letta-ai/letta-code@latest..."); await execAsync("npm install -g @letta-ai/letta-code@latest", { @@ -92,15 +138,49 @@ async function performUpdate(): Promise<{ success: boolean; error?: string }> { 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) { + debugLog("ENOTEMPTY detected, attempting cleanup and retry..."); + await cleanupOrphanedDirs(globalPath); + + try { + await execAsync("npm install -g @letta-ai/letta-code@latest", { + timeout: 60000, + }); + debugLog("Update succeeded after cleanup retry"); + return { success: true }; + } catch (retryError) { + const retryMsg = + 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, + error: retryMsg, + enotemptyFailed: true, + }; + } + return { success: false, error: retryMsg }; + } + } + debugLog("Update failed:", error); - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; + return { success: false, error: errorMsg }; } } -export async function checkAndAutoUpdate() { +export interface AutoUpdateResult { + /** Whether an ENOTEMPTY error persisted after cleanup and retry */ + enotemptyFailed?: boolean; +} + +export async function checkAndAutoUpdate(): Promise< + AutoUpdateResult | undefined +> { debugLog("Auto-update check starting..."); debugLog("isAutoUpdateEnabled:", isAutoUpdateEnabled()); const runningLocally = isRunningLocally(); @@ -119,7 +199,10 @@ export async function checkAndAutoUpdate() { const result = await checkForUpdate(); if (result.updateAvailable) { - await performUpdate(); + const updateResult = await performUpdate(); + if (updateResult.enotemptyFailed) { + return { enotemptyFailed: true }; + } } }