Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/core/config/CustomModesManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
47 changes: 27 additions & 20 deletions src/services/marketplace/SimpleInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
}
// Ensure customModes array exists
if (!existingData.customModes) {
existingData.customModes = []
}

// Remove mode with matching slug
existingData.customModes = existingData.customModes.filter((mode: any) => mode.slug !== modeData.slug)
// 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 || "")

// Always write back the file, even if empty
await fs.writeFile(filePath, yaml.stringify(existingData, { lineWidth: 0 }), "utf-8")
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
Expand Down
100 changes: 100 additions & 0 deletions src/services/marketplace/__tests__/SimpleInstaller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {"

Expand Down Expand Up @@ -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")
})
})
})
Loading