diff --git a/src/plugins/bundled-plugin-naming.test.ts b/src/plugins/bundled-plugin-naming.test.ts index 563d412a412..de5be7b9b5c 100644 --- a/src/plugins/bundled-plugin-naming.test.ts +++ b/src/plugins/bundled-plugin-naming.test.ts @@ -1,6 +1,7 @@ +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; type PluginManifestShape = { id?: unknown; @@ -54,10 +55,100 @@ function normalizeText(value: unknown): string | undefined { return trimmed || undefined; } +function listBundledPluginDirs(): string[] { + const externalDirs = listExternalBundledPluginDirs(); + if (externalDirs) { + return externalDirs; + } + return fs.readdirSync(EXTENSIONS_ROOT).toSorted(); +} + +function listExternalBundledPluginDirs(): string[] | null { + const files = + listGitPluginMetadataFiles() ?? + listFindPluginMetadataFiles(); + if (!files) { + return null; + } + + const metadataByDir = new Map>(); + for (const file of files) { + const match = /^extensions\/([^/]+)\/(openclaw\.plugin\.json|package\.json)$/u.exec(file); + if (!match) { + continue; + } + const [, dirName, fileName] = match; + const metadataFiles = metadataByDir.get(dirName) ?? new Set(); + metadataFiles.add(fileName); + metadataByDir.set(dirName, metadataFiles); + } + + return [...metadataByDir.entries()] + .filter(([, metadataFiles]) => + metadataFiles.has("package.json") && metadataFiles.has("openclaw.plugin.json"), + ) + .map(([dirName]) => dirName) + .toSorted(); +} + +function listGitPluginMetadataFiles(): string[] | null { + const result = spawnSync( + "git", + ["ls-files", "--", "extensions/*/package.json", "extensions/*/openclaw.plugin.json"], + { + cwd: process.cwd(), + encoding: "utf8", + maxBuffer: 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }, + ); + if (result.status !== 0) { + return null; + } + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .toSorted(); +} + +function listFindPluginMetadataFiles(): string[] | null { + const result = spawnSync( + "find", + [ + EXTENSIONS_ROOT, + "-maxdepth", + "2", + "-type", + "f", + "(", + "-name", + "package.json", + "-o", + "-name", + "openclaw.plugin.json", + ")", + ], + { + cwd: process.cwd(), + encoding: "utf8", + maxBuffer: 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }, + ); + if (result.status !== 0) { + return null; + } + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((file) => path.relative(process.cwd(), file).split(path.sep).join("/")) + .toSorted(); +} + function readBundledPluginRecords(): BundledPluginRecord[] { - return fs - .readdirSync(EXTENSIONS_ROOT) - .toSorted() + return listBundledPluginDirs() .flatMap((dirName) => { const rootDir = path.join(EXTENSIONS_ROOT, dirName); const packagePath = path.join(rootDir, "package.json"); @@ -108,6 +199,19 @@ function expectNoBundledPluginNamingMismatches(params: { } describe("bundled plugin naming guardrails", () => { + it("lists bundled plugin metadata without scanning extension directories in-process", () => { + const readDir = vi.spyOn(fs, "readdirSync"); + try { + const records = readBundledPluginRecords(); + + expect(records.length).toBeGreaterThan(0); + expect(records.every((record) => record.dirName.length > 0)).toBe(true); + expect(readDir).not.toHaveBeenCalled(); + } finally { + readDir.mockRestore(); + } + }); + it.each([ { name: "keeps bundled workspace package names anchored to the plugin id",