Skip to content

Commit 12ae900

Browse files
authored
fix: handle null/empty custom modes files to prevent 'Cannot read properties of null' error (#5526)
* fix: handle null/empty custom modes files to prevent 'Cannot read properties of null' error - Fix SimpleInstaller to ensure existingData is always an object after yaml.parse - Fix CustomModesManager.parseYamlSafely to return empty object instead of null - Ensure customModes array is always initialized in both install and remove operations - Add tests for empty/null file handling scenarios - Update existing tests to match correct behavior * fix: handle null/undefined settings in updateModesInFile and loadModesFromFile - Ensure settings object exists before accessing customModes property - Initialize customModes as empty array if undefined - Prevent 'Cannot read properties of null' error during mode import - Add proper validation in loadModesFromFile before schema check
1 parent c6412b4 commit 12ae900

File tree

3 files changed

+145
-22
lines changed

3 files changed

+145
-22
lines changed

src/core/config/CustomModesManager.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,9 @@ export class CustomModesManager {
148148
cleanedContent = this.cleanInvisibleCharacters(cleanedContent)
149149

150150
try {
151-
return yaml.parse(cleanedContent)
151+
const parsed = yaml.parse(cleanedContent)
152+
// Ensure we never return null or undefined
153+
return parsed ?? {}
152154
} catch (yamlError) {
153155
// For .roomodes files, try JSON as fallback
154156
if (filePath.endsWith(ROOMODES_FILENAME)) {
@@ -180,6 +182,12 @@ export class CustomModesManager {
180182
try {
181183
const content = await fs.readFile(filePath, "utf-8")
182184
const settings = this.parseYamlSafely(content, filePath)
185+
186+
// Ensure settings has customModes property
187+
if (!settings || typeof settings !== "object" || !settings.customModes) {
188+
return []
189+
}
190+
183191
const result = customModesSettingsSchema.safeParse(settings)
184192

185193
if (!result.success) {
@@ -458,7 +466,15 @@ export class CustomModesManager {
458466
settings = { customModes: [] }
459467
}
460468

461-
settings.customModes = operation(settings.customModes || [])
469+
// Ensure settings is an object and has customModes property
470+
if (!settings || typeof settings !== "object") {
471+
settings = { customModes: [] }
472+
}
473+
if (!settings.customModes) {
474+
settings.customModes = []
475+
}
476+
477+
settings.customModes = operation(settings.customModes)
462478
await fs.writeFile(filePath, yaml.stringify(settings, { lineWidth: 0 }), "utf-8")
463479
}
464480

src/services/marketplace/SimpleInstaller.ts

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ export class SimpleInstaller {
4747
let existingData: any = { customModes: [] }
4848
try {
4949
const existing = await fs.readFile(filePath, "utf-8")
50-
existingData = yaml.parse(existing) || { customModes: [] }
50+
const parsed = yaml.parse(existing)
51+
// Ensure we have a valid object with customModes array
52+
existingData = parsed && typeof parsed === "object" ? parsed : { customModes: [] }
5153
} catch (error: any) {
5254
if (error.code === "ENOENT") {
5355
// File doesn't exist, use default structure - this is fine
@@ -253,7 +255,9 @@ export class SimpleInstaller {
253255
let existingData: any
254256

255257
try {
256-
existingData = yaml.parse(existing)
258+
const parsed = yaml.parse(existing)
259+
// Ensure we have a valid object
260+
existingData = parsed && typeof parsed === "object" ? parsed : {}
257261
} catch (parseError) {
258262
// If we can't parse the file, we can't safely remove a mode
259263
const fileName = target === "project" ? ".roomodes" : "custom-modes.yaml"
@@ -263,27 +267,30 @@ export class SimpleInstaller {
263267
)
264268
}
265269

266-
if (existingData?.customModes) {
267-
// Parse the item content to get the slug
268-
let content: string
269-
if (Array.isArray(item.content)) {
270-
// Array of McpInstallationMethod objects - use first method
271-
content = item.content[0].content
272-
} else {
273-
content = item.content
274-
}
275-
const modeData = yaml.parse(content || "")
276-
277-
if (!modeData.slug) {
278-
return // Nothing to remove if no slug
279-
}
270+
// Ensure customModes array exists
271+
if (!existingData.customModes) {
272+
existingData.customModes = []
273+
}
280274

281-
// Remove mode with matching slug
282-
existingData.customModes = existingData.customModes.filter((mode: any) => mode.slug !== modeData.slug)
275+
// Parse the item content to get the slug
276+
let content: string
277+
if (Array.isArray(item.content)) {
278+
// Array of McpInstallationMethod objects - use first method
279+
content = item.content[0].content
280+
} else {
281+
content = item.content
282+
}
283+
const modeData = yaml.parse(content || "")
283284

284-
// Always write back the file, even if empty
285-
await fs.writeFile(filePath, yaml.stringify(existingData, { lineWidth: 0 }), "utf-8")
285+
if (!modeData.slug) {
286+
return // Nothing to remove if no slug
286287
}
288+
289+
// Remove mode with matching slug
290+
existingData.customModes = existingData.customModes.filter((mode: any) => mode.slug !== modeData.slug)
291+
292+
// Always write back the file, even if empty
293+
await fs.writeFile(filePath, yaml.stringify(existingData, { lineWidth: 0 }), "utf-8")
287294
} catch (error: any) {
288295
if (error.code === "ENOENT") {
289296
// File doesn't exist, nothing to remove

src/services/marketplace/__tests__/SimpleInstaller.spec.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,59 @@ describe("SimpleInstaller", () => {
8989
expect(writtenData.customModes.find((m: any) => m.slug === "test")).toBeDefined()
9090
})
9191

92+
it("should handle empty .roomodes file", async () => {
93+
// Empty file content
94+
mockFs.readFile.mockResolvedValueOnce("")
95+
mockFs.writeFile.mockResolvedValueOnce(undefined as any)
96+
97+
const result = await installer.installItem(mockModeItem, { target: "project" })
98+
99+
expect(result.filePath).toBe(path.join("/test/workspace", ".roomodes"))
100+
expect(mockFs.writeFile).toHaveBeenCalled()
101+
102+
// Verify the written content contains the new mode
103+
const writtenContent = mockFs.writeFile.mock.calls[0][1] as string
104+
const writtenData = yaml.parse(writtenContent)
105+
expect(writtenData.customModes).toHaveLength(1)
106+
expect(writtenData.customModes[0].slug).toBe("test")
107+
})
108+
109+
it("should handle .roomodes file with null content", async () => {
110+
// File exists but yaml.parse returns null
111+
mockFs.readFile.mockResolvedValueOnce("---\n")
112+
mockFs.writeFile.mockResolvedValueOnce(undefined as any)
113+
114+
const result = await installer.installItem(mockModeItem, { target: "project" })
115+
116+
expect(result.filePath).toBe(path.join("/test/workspace", ".roomodes"))
117+
expect(mockFs.writeFile).toHaveBeenCalled()
118+
119+
// Verify the written content contains the new mode
120+
const writtenContent = mockFs.writeFile.mock.calls[0][1] as string
121+
const writtenData = yaml.parse(writtenContent)
122+
expect(writtenData.customModes).toHaveLength(1)
123+
expect(writtenData.customModes[0].slug).toBe("test")
124+
})
125+
126+
it("should handle .roomodes file without customModes property", async () => {
127+
// File has valid YAML but no customModes property
128+
const contentWithoutCustomModes = yaml.stringify({ someOtherProperty: "value" })
129+
mockFs.readFile.mockResolvedValueOnce(contentWithoutCustomModes)
130+
mockFs.writeFile.mockResolvedValueOnce(undefined as any)
131+
132+
const result = await installer.installItem(mockModeItem, { target: "project" })
133+
134+
expect(result.filePath).toBe(path.join("/test/workspace", ".roomodes"))
135+
expect(mockFs.writeFile).toHaveBeenCalled()
136+
137+
// Verify the written content contains the new mode and preserves other properties
138+
const writtenContent = mockFs.writeFile.mock.calls[0][1] as string
139+
const writtenData = yaml.parse(writtenContent)
140+
expect(writtenData.customModes).toHaveLength(1)
141+
expect(writtenData.customModes[0].slug).toBe("test")
142+
expect(writtenData.someOtherProperty).toBe("value")
143+
})
144+
92145
it("should throw error when .roomodes contains invalid YAML", async () => {
93146
const invalidYaml = "invalid: yaml: content: {"
94147

@@ -224,5 +277,52 @@ describe("SimpleInstaller", () => {
224277

225278
expect(mockFs.writeFile).not.toHaveBeenCalled()
226279
})
280+
281+
it("should handle empty .roomodes file during removal", async () => {
282+
// Empty file content
283+
mockFs.readFile.mockResolvedValueOnce("")
284+
mockFs.writeFile.mockResolvedValueOnce(undefined as any)
285+
286+
// Should not throw
287+
await installer.removeItem(mockModeItem, { target: "project" })
288+
289+
// Should write back a valid structure with empty customModes
290+
expect(mockFs.writeFile).toHaveBeenCalled()
291+
const writtenContent = mockFs.writeFile.mock.calls[0][1] as string
292+
const writtenData = yaml.parse(writtenContent)
293+
expect(writtenData.customModes).toEqual([])
294+
})
295+
296+
it("should handle .roomodes file with null content during removal", async () => {
297+
// File exists but yaml.parse returns null
298+
mockFs.readFile.mockResolvedValueOnce("---\n")
299+
mockFs.writeFile.mockResolvedValueOnce(undefined as any)
300+
301+
// Should not throw
302+
await installer.removeItem(mockModeItem, { target: "project" })
303+
304+
// Should write back a valid structure with empty customModes
305+
expect(mockFs.writeFile).toHaveBeenCalled()
306+
const writtenContent = mockFs.writeFile.mock.calls[0][1] as string
307+
const writtenData = yaml.parse(writtenContent)
308+
expect(writtenData.customModes).toEqual([])
309+
})
310+
311+
it("should handle .roomodes file without customModes property during removal", async () => {
312+
// File has valid YAML but no customModes property
313+
const contentWithoutCustomModes = yaml.stringify({ someOtherProperty: "value" })
314+
mockFs.readFile.mockResolvedValueOnce(contentWithoutCustomModes)
315+
mockFs.writeFile.mockResolvedValueOnce(undefined as any)
316+
317+
// Should not throw
318+
await installer.removeItem(mockModeItem, { target: "project" })
319+
320+
// Should write back the file with the same content (no modes to remove)
321+
expect(mockFs.writeFile).toHaveBeenCalled()
322+
const writtenContent = mockFs.writeFile.mock.calls[0][1] as string
323+
const writtenData = yaml.parse(writtenContent)
324+
expect(writtenData.customModes).toEqual([])
325+
expect(writtenData.someOtherProperty).toBe("value")
326+
})
227327
})
228328
})

0 commit comments

Comments
 (0)