From cdb8b90c8bc33752739ddd246f1363c57087d3bc Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 26 May 2026 12:08:46 +0000 Subject: [PATCH] Show bundled plugins in plugin manager --- .../src/__tests__/plugin-routes-authz.test.ts | 14 ++ server/src/routes/plugins.ts | 185 +++++++++++++----- ui/src/api/plugins.ts | 8 +- ui/src/pages/PluginManager.tsx | 44 +++-- 4 files changed, 176 insertions(+), 75 deletions(-) diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 272e20c3575..9447bc93928 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -139,6 +139,20 @@ describe.sequential("plugin install and upgrade authz", () => { vi.clearAllMocks(); }); + it("lists bundled monorepo plugin packages", async () => { + const { app } = await createApp(boardActor()); + + const res = await request(app).get("/api/plugins/examples"); + + expect(res.status).toBe(200); + const packageNames = res.body.map((plugin: { packageName: string }) => plugin.packageName); + expect(packageNames).toContain("@paperclipai/plugin-workspace-diff"); + expect(packageNames).toContain("@paperclipai/plugin-llm-wiki"); + expect(packageNames).toContain("@paperclipai/plugin-modal"); + expect(packageNames).toContain("@paperclipai/plugin-authoring-smoke-example"); + expect(packageNames).not.toContain("@paperclipai/plugin-sdk"); + }, 20_000); + it("rejects plugin installation for non-admin board users", async () => { const { app, loader } = await createApp({ type: "board", diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index d572c90d6fa..9f48a0dc5f5 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -18,7 +18,7 @@ * @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification */ -import { existsSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; @@ -114,7 +114,7 @@ interface PluginInstallRequest { isLocalPath?: boolean; } -interface AvailablePluginExample { +interface AvailableBundledPlugin { packageName: string; pluginKey: string; displayName: string; @@ -151,55 +151,136 @@ const PLUGIN_SCOPED_API_RESPONSE_HEADER_ALLOWLIST = new Set([ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, "../../.."); -const BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [ - { - packageName: "@paperclipai/plugin-workspace-diff", - pluginKey: "paperclip.workspace-diff", - displayName: "Workspace Changes", - description: "First-party workspace Changes tab backed by plugin-local Git diff computation.", - localPath: "packages/plugins/plugin-workspace-diff", - tag: "first-party", - }, - { - packageName: "@paperclipai/plugin-hello-world-example", - pluginKey: "paperclip.hello-world-example", - displayName: "Hello World Widget (Example)", - description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.", - localPath: "packages/plugins/examples/plugin-hello-world-example", - tag: "example", - }, - { - packageName: "@paperclipai/plugin-file-browser-example", - pluginKey: "paperclip-file-browser-example", - displayName: "File Browser (Example)", - description: "Example plugin that adds a Files link in project navigation plus a project detail file browser.", - localPath: "packages/plugins/examples/plugin-file-browser-example", - tag: "example", - }, - { - packageName: "@paperclipai/plugin-kitchen-sink-example", - pluginKey: "paperclip-kitchen-sink-example", - displayName: "Kitchen Sink (Example)", - description: "Reference plugin that demonstrates the current Paperclip plugin API surface, bridge flows, UI extension surfaces, jobs, webhooks, tools, streams, and trusted local workspace/process demos.", - localPath: "packages/plugins/examples/plugin-kitchen-sink-example", - tag: "example", - }, - { - packageName: "@paperclipai/plugin-orchestration-smoke-example", - pluginKey: "paperclipai.plugin-orchestration-smoke-example", - displayName: "Orchestration Smoke (Example)", - description: "Acceptance fixture for scoped plugin routes, restricted database namespaces, issue orchestration, documents, wakeups, summaries, and UI status surfaces.", - localPath: "packages/plugins/examples/plugin-orchestration-smoke-example", - tag: "example", - }, -]; +function titleCasePluginName(packageName: string): string { + const localName = packageName.split("/").pop() ?? packageName; + return localName + .replace(/^paperclip-plugin-/, "") + .replace(/^plugin-/, "") + .split("-") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} -function listBundledPluginExamples(): AvailablePluginExample[] { - return BUNDLED_PLUGIN_EXAMPLES.flatMap((plugin) => { - const absoluteLocalPath = path.resolve(REPO_ROOT, plugin.localPath); - if (!existsSync(absoluteLocalPath)) return []; - return [{ ...plugin, localPath: absoluteLocalPath }]; - }); +function readJsonFile(filePath: string): Record | null { + try { + return JSON.parse(readFileSync(filePath, "utf8")) as Record; + } catch { + return null; + } +} + +function findPackageJsonFiles(root: string, maxDepth = 4): string[] { + if (!existsSync(root)) return []; + + const packageJsonFiles: string[] = []; + const walk = (dir: string, depth: number) => { + if (depth > maxDepth) return; + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === "dist") continue; + const entryPath = path.join(dir, entry.name); + + if (entry.isFile() && entry.name === "package.json") { + packageJsonFiles.push(entryPath); + } else if (entry.isDirectory()) { + walk(entryPath, depth + 1); + } + } + }; + + walk(root, 0); + return packageJsonFiles; +} + +function manifestSourcePath(packageRoot: string, pkgJson: Record): string | null { + const paperclipPlugin = pkgJson.paperclipPlugin; + if ( + !paperclipPlugin + || typeof paperclipPlugin !== "object" + || Array.isArray(paperclipPlugin) + ) { + return null; + } + + const manifestPath = (paperclipPlugin as Record).manifest; + if (typeof manifestPath !== "string") return null; + + const sourcePath = manifestPath + .replace(/^\.\/dist\//, "./src/") + .replace(/\.js$/, ".ts"); + return path.resolve(packageRoot, sourcePath); +} + +function firstStringLiteral(source: string, key: string): string | null { + const match = source.match( + new RegExp(`${key}:\\s*(?:"([^"]*)"|'([^']*)'|\`([^\`]*)\`)`, "s"), + ); + return match?.[1] ?? match?.[2] ?? match?.[3] ?? null; +} + +function bundledPluginMetadata( + packageRoot: string, + pkgJson: Record, +): { pluginKey?: string; displayName?: string; description?: string } { + const sourcePath = manifestSourcePath(packageRoot, pkgJson); + if (!sourcePath || !existsSync(sourcePath)) return {}; + + try { + const source = readFileSync(sourcePath, "utf8"); + const pluginId = source + .match(/(?:export\s+)?const\s+PLUGIN_ID\s*=\s*(?:"([^"]*)"|'([^']*)'|`([^`]*)`)/) + ?.slice(1) + .find(Boolean) + ?? firstStringLiteral(source, "id") + ?? null; + return { + pluginKey: pluginId ?? undefined, + displayName: firstStringLiteral(source, "displayName") ?? undefined, + description: firstStringLiteral(source, "description") ?? undefined, + }; + } catch { + return {}; + } +} + +function listBundledPlugins(): AvailableBundledPlugin[] { + const pluginRoot = path.resolve(REPO_ROOT, "packages/plugins"); + return findPackageJsonFiles(pluginRoot) + .flatMap((packageJsonPath): AvailableBundledPlugin[] => { + const packageRoot = path.dirname(packageJsonPath); + const pkgJson = readJsonFile(packageJsonPath); + const paperclipPlugin = pkgJson?.paperclipPlugin; + if ( + !pkgJson + || !paperclipPlugin + || typeof paperclipPlugin !== "object" + || Array.isArray(paperclipPlugin) + ) { + return []; + } + + const packageName = pkgJson.name; + if (typeof packageName !== "string" || packageName.length === 0) return []; + + const metadata = bundledPluginMetadata(packageRoot, pkgJson); + const tag = packageRoot.includes(`${path.sep}examples${path.sep}`) ? "example" : "first-party"; + return [ + { + packageName, + pluginKey: metadata.pluginKey ?? packageName, + displayName: metadata.displayName ?? titleCasePluginName(packageName), + description: metadata.description + ?? `Bundled Paperclip plugin from ${path.relative(REPO_ROOT, packageRoot)}.`, + localPath: packageRoot, + tag, + }, + ]; + }) + .sort((left, right) => { + if (left.tag !== right.tag) return left.tag === "first-party" ? -1 : 1; + return left.displayName.localeCompare(right.displayName); + }); } /** @@ -677,12 +758,12 @@ export function pluginRoutes( /** * GET /api/plugins/examples * - * Return first-party example plugins bundled in this repo, if present. + * Return plugin packages bundled in this repo, if present. * These can be installed through the normal local-path install flow. */ router.get("/plugins/examples", async (req, res) => { assertBoardOrgAccess(req); - res.json(listBundledPluginExamples()); + res.json(listBundledPlugins()); }); // IMPORTANT: Static routes must come before parameterized routes diff --git a/ui/src/api/plugins.ts b/ui/src/api/plugins.ts index 9df0b937563..8f4a548612d 100644 --- a/ui/src/api/plugins.ts +++ b/ui/src/api/plugins.ts @@ -132,7 +132,7 @@ export interface PluginDashboardData { checkedAt: string; } -export interface AvailablePluginExample { +export interface AvailableBundledPlugin { packageName: string; pluginKey: string; displayName: string; @@ -215,10 +215,10 @@ export const pluginsApi = { api.get(`/plugins${status ? `?status=${status}` : ""}`), /** - * List bundled example plugins available from the current repo checkout. + * List bundled plugin packages available from the current repo checkout. */ - listExamples: () => - api.get("/plugins/examples"), + listBundled: () => + api.get("/plugins/examples"), /** * Fetch a single plugin record by its UUID or plugin key. diff --git a/ui/src/pages/PluginManager.tsx b/ui/src/pages/PluginManager.tsx index ce73e25ca84..d03c1f95771 100644 --- a/ui/src/pages/PluginManager.tsx +++ b/ui/src/pages/PluginManager.tsx @@ -85,9 +85,9 @@ export function PluginManager() { queryFn: () => pluginsApi.list(), }); - const examplesQuery = useQuery({ + const bundledQuery = useQuery({ queryKey: queryKeys.plugins.examples, - queryFn: () => pluginsApi.listExamples(), + queryFn: () => pluginsApi.listBundled(), }); const invalidatePluginQueries = () => { @@ -144,9 +144,9 @@ export function PluginManager() { }); const installedPlugins = plugins ?? []; - const examples = examplesQuery.data ?? []; + const bundledPlugins = bundledQuery.data ?? []; const installedByPackageName = new Map(installedPlugins.map((plugin) => [plugin.packageName, plugin])); - const examplePackageNames = new Set(examples.map((example) => example.packageName)); + const bundledByPackageName = new Map(bundledPlugins.map((plugin) => [plugin.packageName, plugin])); const errorSummaryByPluginId = useMemo( () => new Map( @@ -223,30 +223,32 @@ export function PluginManager() { Bundled - {examplesQuery.isLoading ? ( + {bundledQuery.isLoading ? (
Loading bundled plugins...
- ) : examplesQuery.error ? ( + ) : bundledQuery.error ? (
Failed to load bundled plugins.
- ) : examples.length === 0 ? ( + ) : bundledPlugins.length === 0 ? (
No bundled plugins were found in this checkout.
) : (
    - {examples.map((example) => { - const installedPlugin = installedByPackageName.get(example.packageName); + {bundledPlugins.map((bundledPlugin) => { + const installedPlugin = installedByPackageName.get(bundledPlugin.packageName); const installPending = installMutation.isPending && installMutation.variables?.isLocalPath && - installMutation.variables.packageName === example.localPath; + installMutation.variables.packageName === bundledPlugin.localPath; return ( -
  • +
  • - {example.displayName} - {example.tag === "first-party" ? "First-party" : "Example"} + {bundledPlugin.displayName} + + {bundledPlugin.tag === "first-party" ? "First-party" : "Example"} + {installedPlugin ? ( Not installed )}
    -

    {example.description}

    -

    {example.packageName}

    +

    {bundledPlugin.description}

    +

    {bundledPlugin.packageName}

    {installedPlugin ? ( @@ -286,12 +288,12 @@ export function PluginManager() { disabled={installPending || installMutation.isPending} onClick={() => installMutation.mutate({ - packageName: example.localPath, + packageName: bundledPlugin.localPath, isLocalPath: true, }) } > - {installPending ? "Installing..." : "Install Example"} + {installPending ? "Installing..." : "Install"} )}
    @@ -333,8 +335,12 @@ export function PluginManager() { > {plugin.manifestJson.displayName ?? plugin.packageName} - {examplePackageNames.has(plugin.packageName) && ( - Example + {bundledByPackageName.has(plugin.packageName) && ( + + {bundledByPackageName.get(plugin.packageName)?.tag === "first-party" + ? "First-party" + : "Example"} + )}