diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index 9f29185eba..b4bcfa62d6 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -148,7 +148,9 @@ export class CustomModesManager { cleanedContent = this.cleanInvisibleCharacters(cleanedContent) try { - return yaml.parse(cleanedContent) + const parsed = yaml.parse(cleanedContent) + // Ensure we never return null or undefined + return parsed ?? {} } catch (yamlError) { // For .roomodes files, try JSON as fallback if (filePath.endsWith(ROOMODES_FILENAME)) { @@ -180,6 +182,12 @@ export class CustomModesManager { try { const content = await fs.readFile(filePath, "utf-8") const settings = this.parseYamlSafely(content, filePath) + + // Ensure settings has customModes property + if (!settings || typeof settings !== "object" || !settings.customModes) { + return [] + } + const result = customModesSettingsSchema.safeParse(settings) if (!result.success) { @@ -458,7 +466,15 @@ export class CustomModesManager { settings = { customModes: [] } } - settings.customModes = operation(settings.customModes || []) + // Ensure settings is an object and has customModes property + if (!settings || typeof settings !== "object") { + settings = { customModes: [] } + } + if (!settings.customModes) { + settings.customModes = [] + } + + settings.customModes = operation(settings.customModes) await fs.writeFile(filePath, yaml.stringify(settings, { lineWidth: 0 }), "utf-8") } diff --git a/src/services/marketplace/SimpleInstaller.ts b/src/services/marketplace/SimpleInstaller.ts index 91f5bcad26..2274b65343 100644 --- a/src/services/marketplace/SimpleInstaller.ts +++ b/src/services/marketplace/SimpleInstaller.ts @@ -47,7 +47,9 @@ export class SimpleInstaller { let existingData: any = { customModes: [] } try { const existing = await fs.readFile(filePath, "utf-8") - existingData = yaml.parse(existing) || { customModes: [] } + const parsed = yaml.parse(existing) + // Ensure we have a valid object with customModes array + existingData = parsed && typeof parsed === "object" ? parsed : { customModes: [] } } catch (error: any) { if (error.code === "ENOENT") { // File doesn't exist, use default structure - this is fine @@ -253,7 +255,9 @@ export class SimpleInstaller { let existingData: any try { - existingData = yaml.parse(existing) + const parsed = yaml.parse(existing) + // Ensure we have a valid object + existingData = parsed && typeof parsed === "object" ? parsed : {} } catch (parseError) { // If we can't parse the file, we can't safely remove a mode const fileName = target === "project" ? ".roomodes" : "custom-modes.yaml" @@ -263,27 +267,30 @@ export class SimpleInstaller { ) } - if (existingData?.customModes) { - // Parse the item content to get the slug - let content: string - if (Array.isArray(item.content)) { - // Array of McpInstallationMethod objects - use first method - content = item.content[0].content - } else { - content = item.content - } - const modeData = yaml.parse(content || "") - - if (!modeData.slug) { - return // Nothing to remove if no slug - } - - // Remove mode with matching slug - existingData.customModes = existingData.customModes.filter((mode: any) => mode.slug !== modeData.slug) - - // Always write back the file, even if empty - await fs.writeFile(filePath, yaml.stringify(existingData, { lineWidth: 0 }), "utf-8") + // Ensure customModes array exists + if (!existingData.customModes) { + existingData.customModes = [] } + + // Parse the item content to get the slug + let content: string + if (Array.isArray(item.content)) { + // Array of McpInstallationMethod objects - use first method + content = item.content[0].content + } else { + content = item.content + } + const modeData = yaml.parse(content || "") + + if (!modeData.slug) { + return // Nothing to remove if no slug + } + + // Remove mode with matching slug + existingData.customModes = existingData.customModes.filter((mode: any) => mode.slug !== modeData.slug) + + // Always write back the file, even if empty + await fs.writeFile(filePath, yaml.stringify(existingData, { lineWidth: 0 }), "utf-8") } catch (error: any) { if (error.code === "ENOENT") { // File doesn't exist, nothing to remove diff --git a/src/services/marketplace/__tests__/SimpleInstaller.spec.ts b/src/services/marketplace/__tests__/SimpleInstaller.spec.ts index 4934d0a6bc..546eb16f9a 100644 --- a/src/services/marketplace/__tests__/SimpleInstaller.spec.ts +++ b/src/services/marketplace/__tests__/SimpleInstaller.spec.ts @@ -89,6 +89,59 @@ describe("SimpleInstaller", () => { expect(writtenData.customModes.find((m: any) => m.slug === "test")).toBeDefined() }) + it("should handle empty .roomodes file", async () => { + // Empty file content + mockFs.readFile.mockResolvedValueOnce("") + mockFs.writeFile.mockResolvedValueOnce(undefined as any) + + const result = await installer.installItem(mockModeItem, { target: "project" }) + + expect(result.filePath).toBe(path.join("/test/workspace", ".roomodes")) + expect(mockFs.writeFile).toHaveBeenCalled() + + // Verify the written content contains the new mode + const writtenContent = mockFs.writeFile.mock.calls[0][1] as string + const writtenData = yaml.parse(writtenContent) + expect(writtenData.customModes).toHaveLength(1) + expect(writtenData.customModes[0].slug).toBe("test") + }) + + it("should handle .roomodes file with null content", async () => { + // File exists but yaml.parse returns null + mockFs.readFile.mockResolvedValueOnce("---\n") + mockFs.writeFile.mockResolvedValueOnce(undefined as any) + + const result = await installer.installItem(mockModeItem, { target: "project" }) + + expect(result.filePath).toBe(path.join("/test/workspace", ".roomodes")) + expect(mockFs.writeFile).toHaveBeenCalled() + + // Verify the written content contains the new mode + const writtenContent = mockFs.writeFile.mock.calls[0][1] as string + const writtenData = yaml.parse(writtenContent) + expect(writtenData.customModes).toHaveLength(1) + expect(writtenData.customModes[0].slug).toBe("test") + }) + + it("should handle .roomodes file without customModes property", async () => { + // File has valid YAML but no customModes property + const contentWithoutCustomModes = yaml.stringify({ someOtherProperty: "value" }) + mockFs.readFile.mockResolvedValueOnce(contentWithoutCustomModes) + mockFs.writeFile.mockResolvedValueOnce(undefined as any) + + const result = await installer.installItem(mockModeItem, { target: "project" }) + + expect(result.filePath).toBe(path.join("/test/workspace", ".roomodes")) + expect(mockFs.writeFile).toHaveBeenCalled() + + // Verify the written content contains the new mode and preserves other properties + const writtenContent = mockFs.writeFile.mock.calls[0][1] as string + const writtenData = yaml.parse(writtenContent) + expect(writtenData.customModes).toHaveLength(1) + expect(writtenData.customModes[0].slug).toBe("test") + expect(writtenData.someOtherProperty).toBe("value") + }) + it("should throw error when .roomodes contains invalid YAML", async () => { const invalidYaml = "invalid: yaml: content: {" @@ -224,5 +277,52 @@ describe("SimpleInstaller", () => { expect(mockFs.writeFile).not.toHaveBeenCalled() }) + + it("should handle empty .roomodes file during removal", async () => { + // Empty file content + mockFs.readFile.mockResolvedValueOnce("") + mockFs.writeFile.mockResolvedValueOnce(undefined as any) + + // Should not throw + await installer.removeItem(mockModeItem, { target: "project" }) + + // Should write back a valid structure with empty customModes + expect(mockFs.writeFile).toHaveBeenCalled() + const writtenContent = mockFs.writeFile.mock.calls[0][1] as string + const writtenData = yaml.parse(writtenContent) + expect(writtenData.customModes).toEqual([]) + }) + + it("should handle .roomodes file with null content during removal", async () => { + // File exists but yaml.parse returns null + mockFs.readFile.mockResolvedValueOnce("---\n") + mockFs.writeFile.mockResolvedValueOnce(undefined as any) + + // Should not throw + await installer.removeItem(mockModeItem, { target: "project" }) + + // Should write back a valid structure with empty customModes + expect(mockFs.writeFile).toHaveBeenCalled() + const writtenContent = mockFs.writeFile.mock.calls[0][1] as string + const writtenData = yaml.parse(writtenContent) + expect(writtenData.customModes).toEqual([]) + }) + + it("should handle .roomodes file without customModes property during removal", async () => { + // File has valid YAML but no customModes property + const contentWithoutCustomModes = yaml.stringify({ someOtherProperty: "value" }) + mockFs.readFile.mockResolvedValueOnce(contentWithoutCustomModes) + mockFs.writeFile.mockResolvedValueOnce(undefined as any) + + // Should not throw + await installer.removeItem(mockModeItem, { target: "project" }) + + // Should write back the file with the same content (no modes to remove) + expect(mockFs.writeFile).toHaveBeenCalled() + const writtenContent = mockFs.writeFile.mock.calls[0][1] as string + const writtenData = yaml.parse(writtenContent) + expect(writtenData.customModes).toEqual([]) + expect(writtenData.someOtherProperty).toBe("value") + }) }) })