mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
Show bundled plugins in plugin manager
This commit is contained in:
1 parent
9aea3e3d35
commit
cdb8b90c8b
4 files changed
+176
-75
No files matched your search
@@ -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",
|
||||
|
||||
+133
-52
@@ -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<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
||||
} 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, unknown>): string | null {
|
||||
const paperclipPlugin = pkgJson.paperclipPlugin;
|
||||
if (
|
||||
!paperclipPlugin
|
||||
|| typeof paperclipPlugin !== "object"
|
||||
|| Array.isArray(paperclipPlugin)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const manifestPath = (paperclipPlugin as Record<string, unknown>).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<string, unknown>,
|
||||
): { 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
|
||||
|
||||
@@ -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<PluginRecord[]>(`/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<AvailablePluginExample[]>("/plugins/examples"),
|
||||
listBundled: () =>
|
||||
api.get<AvailableBundledPlugin[]>("/plugins/examples"),
|
||||
|
||||
/**
|
||||
* Fetch a single plugin record by its UUID or plugin key.
|
||||
|
||||
@@ -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() {
|
||||
<Badge variant="outline">Bundled</Badge>
|
||||
</div>
|
||||
|
||||
{examplesQuery.isLoading ? (
|
||||
{bundledQuery.isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading bundled plugins...</div>
|
||||
) : examplesQuery.error ? (
|
||||
) : bundledQuery.error ? (
|
||||
<div className="text-sm text-destructive">Failed to load bundled plugins.</div>
|
||||
) : examples.length === 0 ? (
|
||||
) : bundledPlugins.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
|
||||
No bundled plugins were found in this checkout.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border bg-card">
|
||||
{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 (
|
||||
<li key={example.packageName}>
|
||||
<li key={bundledPlugin.packageName}>
|
||||
<div className="flex items-center gap-4 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{example.displayName}</span>
|
||||
<Badge variant="outline">{example.tag === "first-party" ? "First-party" : "Example"}</Badge>
|
||||
<span className="font-medium">{bundledPlugin.displayName}</span>
|
||||
<Badge variant="outline">
|
||||
{bundledPlugin.tag === "first-party" ? "First-party" : "Example"}
|
||||
</Badge>
|
||||
{installedPlugin ? (
|
||||
<Badge
|
||||
variant={installedPlugin.status === "ready" ? "default" : "secondary"}
|
||||
@@ -258,8 +260,8 @@ export function PluginManager() {
|
||||
<Badge variant="secondary">Not installed</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{example.description}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{example.packageName}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{bundledPlugin.description}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{bundledPlugin.packageName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{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"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -333,8 +335,12 @@ export function PluginManager() {
|
||||
>
|
||||
{plugin.manifestJson.displayName ?? plugin.packageName}
|
||||
</Link>
|
||||
{examplePackageNames.has(plugin.packageName) && (
|
||||
<Badge variant="outline">Example</Badge>
|
||||
{bundledByPackageName.has(plugin.packageName) && (
|
||||
<Badge variant="outline">
|
||||
{bundledByPackageName.get(plugin.packageName)?.tag === "first-party"
|
||||
? "First-party"
|
||||
: "Example"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
Reference in new issue
Block a user