diff --git a/doc/plugins/LOCAL_PLUGIN_DEVELOPMENT.md b/doc/plugins/LOCAL_PLUGIN_DEVELOPMENT.md index 3bf7b009..715baaeb 100644 --- a/doc/plugins/LOCAL_PLUGIN_DEVELOPMENT.md +++ b/doc/plugins/LOCAL_PLUGIN_DEVELOPMENT.md @@ -116,7 +116,7 @@ What that means in practice: - **Without `pnpm dev`:** the watcher only fires on `dist/*` changes. If you stop the watch build, source edits do not reach Paperclip. Restart `pnpm dev` (or run `pnpm build` once) before expecting changes. - **`node_modules`, `.git`, `.paperclip-sdk`, and other dotfolders are ignored.** Adding a dependency requires the new code to actually be imported and rebuilt before the worker sees it. -The server never compiles plugin source for you. The package's own build scripts own that step. +The package's own build scripts still own compilation. Paperclip does not compile arbitrary local-path plugins for you. The exceptions are bundled plugins inside the Paperclip repo under `packages/plugins/`: workspace packages auto-build once with `pnpm --filter build`, and standalone sandbox-provider packages under `packages/plugins/sandbox-providers/` first bootstrap package-local dependencies with `pnpm install --ignore-workspace ...` and then run `pnpm build` in place. Set `PAPERCLIP_DISABLE_PLUGIN_AUTOBUILD=1` in the server environment to disable those fallbacks. ## Local path plugins vs npm packages @@ -136,7 +136,7 @@ When you are done iterating locally, publish the package and reinstall the npm-p ## Troubleshooting -- **`Plugin install returned no plugin record` or `error` status.** Run `paperclipai plugin inspect ` for the last error. The most common causes are (1) the plugin has not built yet — run `pnpm dev` or `pnpm build` first, (2) the `paperclipPlugin` entries in `package.json` point at files that do not exist on disk, or (3) the manifest failed validation. The Paperclip server log has the full validation error. +- **`Plugin install returned no plugin record` or `error` status.** Run `paperclipai plugin inspect ` for the last error. The most common causes are (1) the plugin has not built yet — run `pnpm dev` or `pnpm build` first, (2) the `paperclipPlugin` entries in `package.json` point at files that do not exist on disk, or (3) the manifest failed validation. Bundled repo plugins may auto-build once during install, but external local-path plugins still require you to build them yourself. The Paperclip server log has the full validation error. - **Edits do not seem to reload.** Confirm `pnpm dev` is still running and writing to `dist/`. If you renamed entry files, update the `paperclipPlugin.manifest` / `paperclipPlugin.worker` / `paperclipPlugin.ui` fields in `package.json` so the watcher targets them. - **Worker restarts but UI is stale.** Hard-reload the page. If you want HMR, run `pnpm dev:ui` and set `devUiUrl` in your manifest to `http://127.0.0.1:4177` during development. - **Path arguments fail on Windows.** Quote paths that contain spaces, and prefer absolute paths over `~`-prefixed paths in non-bash shells. diff --git a/server/src/__tests__/plugin-install-autobuild.test.ts b/server/src/__tests__/plugin-install-autobuild.test.ts new file mode 100644 index 00000000..732ddff3 --- /dev/null +++ b/server/src/__tests__/plugin-install-autobuild.test.ts @@ -0,0 +1,402 @@ +import express from "express"; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { createDb, plugins } from "@paperclipai/db"; +import { + ensureLocalPluginBuilt, + pluginLoader, + REPO_ROOT, +} from "../services/plugin-loader.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const mockLifecycle = vi.hoisted(() => ({ + load: vi.fn(), + upgrade: vi.fn(), + unload: vi.fn(), + enable: vi.fn(), + disable: vi.fn(), +})); + +vi.mock("../services/plugin-lifecycle.js", () => ({ + pluginLifecycleManager: () => mockLifecycle, +})); + +vi.mock("../services/activity-log.js", () => ({ + logActivity: vi.fn(), +})); + +vi.mock("../services/live-events.js", () => ({ + publishGlobalLiveEvent: vi.fn(), +})); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping plugin install auto-build tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +type FixturePlugin = { + packageName: string; + pluginKey: string; + packageRoot: string; + distDir: string; +}; + +const repoPluginRoot = path.join(REPO_ROOT, "packages", "plugins"); +const standaloneRepoPluginRoot = path.join(repoPluginRoot, "sandbox-providers"); + +async function createBundledPluginFixture( + nameSuffix: string, + options: { rootDir?: string; buildDistImmediately?: boolean } = {}, +): Promise { + const slug = `plugin-autobuild-${nameSuffix}-${randomUUID().slice(0, 8)}`; + const packageName = `@paperclipai/${slug}`; + const pluginKey = `paperclip.${slug.replace(/^plugin-/, "").replace(/-/g, "_")}`; + const packageRoot = path.join(options.rootDir ?? repoPluginRoot, slug); + const distDir = path.join(packageRoot, "dist"); + const isStandaloneFixture = (options.rootDir ?? repoPluginRoot) === standaloneRepoPluginRoot; + const postinstallScript = isStandaloneFixture + ? `node ${path.relative(packageRoot, path.join(REPO_ROOT, "scripts", "link-plugin-dev-sdk.mjs"))}` + : null; + + await mkdir(path.join(packageRoot, "scripts"), { recursive: true }); + await writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ + name: packageName, + version: "0.1.0", + private: true, + type: "module", + scripts: { + ...(postinstallScript ? { postinstall: postinstallScript } : {}), + build: "node ./scripts/build.mjs", + }, + paperclipPlugin: { + manifest: "./dist/manifest.js", + worker: "./dist/worker.js", + ui: "./dist/ui/", + }, + }, null, 2), + "utf8", + ); + + const manifest = { + id: pluginKey, + apiVersion: 1, + version: "0.1.0", + displayName: "Autobuild Fixture", + description: "Bundled plugin fixture for install-time auto-build coverage.", + author: "Paperclip", + categories: ["automation"], + capabilities: ["companies.read"], + entrypoints: { + worker: "./dist/worker.js", + }, + }; + + await writeFile( + path.join(packageRoot, "scripts", "build.mjs"), + [ + "import { mkdir, writeFile } from \"node:fs/promises\";", + "import path from \"node:path\";", + "import { fileURLToPath } from \"node:url\";", + "", + "const scriptDir = path.dirname(fileURLToPath(import.meta.url));", + "const packageRoot = path.resolve(scriptDir, \"..\");", + "const distDir = path.join(packageRoot, \"dist\");", + "const uiDir = path.join(distDir, \"ui\");", + `const manifest = ${JSON.stringify(manifest, null, 2)};`, + "", + "await mkdir(uiDir, { recursive: true });", + "await writeFile(path.join(distDir, \"manifest.js\"), `export default ${JSON.stringify(manifest, null, 2)};\\n`, \"utf8\");", + "await writeFile(path.join(distDir, \"worker.js\"), \"export {};\\n\", \"utf8\");", + "await writeFile(path.join(uiDir, \"index.js\"), \"export default {};\\n\", \"utf8\");", + ].join("\n"), + "utf8", + ); + + if (options.buildDistImmediately) { + await mkdir(path.join(distDir, "ui"), { recursive: true }); + await writeFile(path.join(distDir, "manifest.js"), `export default ${JSON.stringify(manifest, null, 2)};\n`, "utf8"); + await writeFile(path.join(distDir, "worker.js"), "export {};\n", "utf8"); + await writeFile(path.join(distDir, "ui", "index.js"), "export default {};\n", "utf8"); + } + + return { packageName, pluginKey, packageRoot, distDir }; +} + +async function createInstallApp(db: ReturnType) { + const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/plugins.js"), + import("../middleware/index.js"), + ]); + + const loader = pluginLoader(db, { + enableLocalFilesystem: false, + enableNpmDiscovery: false, + }); + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + companyIds: [], + } as typeof req.actor; + next(); + }); + app.use("/api", pluginRoutes(db as never, loader as never, {} as never, undefined, {} as never, {} as never)); + app.use(errorHandler); + return app; +} + +describe("ensureLocalPluginBuilt", () => { + const cleanupPaths = new Set(); + + afterEach(async () => { + for (const cleanupPath of cleanupPaths) { + await rm(cleanupPath, { recursive: true, force: true }); + } + cleanupPaths.clear(); + }); + + it("skips auto-build for local plugin paths outside the repo", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-outside-")); + const packageRoot = path.join(tempRoot, "plugin-outside"); + cleanupPaths.add(path.dirname(packageRoot)); + await mkdir(packageRoot, { recursive: true }); + + const execStub = vi.fn().mockResolvedValue({ stdout: "", stderr: "" }); + await ensureLocalPluginBuilt( + packageRoot, + { + name: "@paperclipai/plugin-outside", + paperclipPlugin: { + manifest: "./dist/manifest.js", + worker: "./dist/worker.js", + }, + }, + { execFileAsyncImpl: execStub }, + ); + + expect(execStub).not.toHaveBeenCalled(); + }); + + it("skips auto-build when PAPERCLIP_DISABLE_PLUGIN_AUTOBUILD=1", async () => { + const fixture = await createBundledPluginFixture("skip"); + cleanupPaths.add(fixture.packageRoot); + + const execStub = vi.fn().mockResolvedValue({ stdout: "", stderr: "" }); + await ensureLocalPluginBuilt( + fixture.packageRoot, + JSON.parse(await readFile(path.join(fixture.packageRoot, "package.json"), "utf8")) as Record, + { + processEnv: { PAPERCLIP_DISABLE_PLUGIN_AUTOBUILD: "1" }, + execFileAsyncImpl: execStub, + }, + ); + + expect(execStub).not.toHaveBeenCalled(); + }); + + it("bootstraps standalone bundled plugins before building them", async () => { + const fixture = await createBundledPluginFixture("standalone", { rootDir: standaloneRepoPluginRoot }); + cleanupPaths.add(fixture.packageRoot); + + const execStub = vi.fn(async (_file: string, args: readonly string[]) => { + if (args.join(" ") === "install --ignore-workspace --no-lockfile") { + await mkdir(path.join(fixture.packageRoot, "node_modules", "@paperclipai", "plugin-sdk"), { recursive: true }); + } + if (args.join(" ") === "build") { + await mkdir(path.join(fixture.distDir, "ui"), { recursive: true }); + await writeFile(path.join(fixture.distDir, "manifest.js"), "export default {};\n", "utf8"); + await writeFile(path.join(fixture.distDir, "worker.js"), "export {};\n", "utf8"); + await writeFile(path.join(fixture.distDir, "ui", "index.js"), "export default {};\n", "utf8"); + } + return { stdout: "", stderr: "" }; + }); + await ensureLocalPluginBuilt( + fixture.packageRoot, + JSON.parse(await readFile(path.join(fixture.packageRoot, "package.json"), "utf8")) as Record, + { execFileAsyncImpl: execStub }, + ); + + expect(execStub).toHaveBeenCalledTimes(2); + expect(execStub).toHaveBeenNthCalledWith( + 1, + "pnpm", + ["install", "--ignore-workspace", "--no-lockfile"], + { cwd: fixture.packageRoot, timeout: 120_000 }, + ); + expect(execStub).toHaveBeenNthCalledWith( + 2, + "pnpm", + ["build"], + { cwd: fixture.packageRoot, timeout: 120_000 }, + ); + }); + + it("bootstraps standalone bundled plugin runtime dependencies when dist already exists", async () => { + const fixture = await createBundledPluginFixture("standalone-runtime", { + rootDir: standaloneRepoPluginRoot, + buildDistImmediately: true, + }); + cleanupPaths.add(fixture.packageRoot); + + const execStub = vi.fn(async () => { + await mkdir(path.join(fixture.packageRoot, "node_modules", "@paperclipai", "plugin-sdk"), { recursive: true }); + return { stdout: "", stderr: "" }; + }); + await ensureLocalPluginBuilt( + fixture.packageRoot, + JSON.parse(await readFile(path.join(fixture.packageRoot, "package.json"), "utf8")) as Record, + { execFileAsyncImpl: execStub }, + ); + + expect(execStub).toHaveBeenCalledTimes(1); + expect(execStub).toHaveBeenNthCalledWith( + 1, + "pnpm", + ["install", "--ignore-workspace", "--no-lockfile"], + { cwd: fixture.packageRoot, timeout: 120_000 }, + ); + }); +}); + +describeEmbeddedPostgres("plugin install auto-build route", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const cleanupPaths = new Set(); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-autobuild-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + vi.clearAllMocks(); + await db.delete(plugins); + for (const cleanupPath of cleanupPaths) { + await rm(cleanupPath, { recursive: true, force: true }); + } + cleanupPaths.clear(); + delete process.env["PAPERCLIP_DISABLE_PLUGIN_AUTOBUILD"]; + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("auto-builds bundled local plugins during POST /api/plugins/install when dist is missing", async () => { + const fixture = await createBundledPluginFixture("success"); + cleanupPaths.add(fixture.packageRoot); + const app = await createInstallApp(db); + + expect(existsSync(path.join(fixture.distDir, "manifest.js"))).toBe(false); + + const res = await request(app) + .post("/api/plugins/install") + .send({ packageName: fixture.packageRoot, isLocalPath: true }); + + expect(res.status).toBe(200); + expect(res.body.packageName).toBe(fixture.packageName); + expect(res.body.pluginKey).toBe(fixture.pluginKey); + expect(existsSync(path.join(fixture.distDir, "manifest.js"))).toBe(true); + expect(existsSync(path.join(fixture.distDir, "worker.js"))).toBe(true); + expect(existsSync(path.join(fixture.distDir, "ui", "index.js"))).toBe(true); + expect(mockLifecycle.load).toHaveBeenCalledTimes(1); + }, 60_000); + + it("auto-builds standalone bundled local plugins outside the root pnpm workspace", async () => { + const fixture = await createBundledPluginFixture("standalone-success", { rootDir: standaloneRepoPluginRoot }); + cleanupPaths.add(fixture.packageRoot); + const app = await createInstallApp(db); + + expect(existsSync(path.join(fixture.distDir, "manifest.js"))).toBe(false); + expect(existsSync(path.join(fixture.packageRoot, "node_modules"))).toBe(false); + + const res = await request(app) + .post("/api/plugins/install") + .send({ packageName: fixture.packageRoot, isLocalPath: true }); + + expect(res.status).toBe(200); + expect(res.body.packageName).toBe(fixture.packageName); + expect(res.body.pluginKey).toBe(fixture.pluginKey); + expect(existsSync(path.join(fixture.distDir, "manifest.js"))).toBe(true); + expect(existsSync(path.join(fixture.distDir, "worker.js"))).toBe(true); + expect(existsSync(path.join(fixture.distDir, "ui", "index.js"))).toBe(true); + expect(existsSync(path.join(fixture.packageRoot, "node_modules", "@paperclipai", "plugin-sdk"))).toBe(true); + expect(mockLifecycle.load).toHaveBeenCalledTimes(1); + }, 60_000); + + it("bootstraps standalone bundled local plugin runtime dependencies when dist already exists", async () => { + const fixture = await createBundledPluginFixture("standalone-runtime-success", { + rootDir: standaloneRepoPluginRoot, + buildDistImmediately: true, + }); + cleanupPaths.add(fixture.packageRoot); + const app = await createInstallApp(db); + + expect(existsSync(path.join(fixture.distDir, "manifest.js"))).toBe(true); + expect(existsSync(path.join(fixture.packageRoot, "node_modules", "@paperclipai", "plugin-sdk"))).toBe(false); + + const res = await request(app) + .post("/api/plugins/install") + .send({ packageName: fixture.packageRoot, isLocalPath: true }); + + expect(res.status).toBe(200); + expect(res.body.packageName).toBe(fixture.packageName); + expect(res.body.pluginKey).toBe(fixture.pluginKey); + expect(existsSync(path.join(fixture.packageRoot, "node_modules", "@paperclipai", "plugin-sdk"))).toBe(true); + expect(mockLifecycle.load).toHaveBeenCalledTimes(1); + }, 60_000); + + it("returns the manual build command when auto-build is disabled and dist is missing", async () => { + process.env["PAPERCLIP_DISABLE_PLUGIN_AUTOBUILD"] = "1"; + const fixture = await createBundledPluginFixture("disabled"); + cleanupPaths.add(fixture.packageRoot); + const app = await createInstallApp(db); + + const res = await request(app) + .post("/api/plugins/install") + .send({ packageName: fixture.packageRoot, isLocalPath: true }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("does not appear to be a Paperclip plugin (no manifest found)"); + expect(res.body.error).toContain(`pnpm --filter ${fixture.packageName} build`); + expect(existsSync(path.join(fixture.distDir, "manifest.js"))).toBe(false); + expect(mockLifecycle.load).not.toHaveBeenCalled(); + }, 20_000); + + it("returns the standalone bootstrap command when auto-build is disabled for sandbox-provider plugins", async () => { + process.env["PAPERCLIP_DISABLE_PLUGIN_AUTOBUILD"] = "1"; + const fixture = await createBundledPluginFixture("standalone-disabled", { rootDir: standaloneRepoPluginRoot }); + cleanupPaths.add(fixture.packageRoot); + const app = await createInstallApp(db); + + const res = await request(app) + .post("/api/plugins/install") + .send({ packageName: fixture.packageRoot, isLocalPath: true }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("does not appear to be a Paperclip plugin (no manifest found)"); + expect(res.body.error).toContain(path.relative(REPO_ROOT, fixture.packageRoot)); + expect(res.body.error).toContain("pnpm install --ignore-workspace --no-lockfile && pnpm build"); + expect(existsSync(path.join(fixture.distDir, "manifest.js"))).toBe(false); + expect(mockLifecycle.load).not.toHaveBeenCalled(); + }, 20_000); +}); diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 460d1e45..2f74a1aa 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -147,7 +147,7 @@ describe.sequential("plugin install and upgrade authz", () => { expect(res.status).toBe(200); const packageNames = res.body.map((plugin: { packageName: string }) => plugin.packageName); const byPackageName = new Map( - res.body.map((plugin: { packageName: string; experimental: boolean }) => [plugin.packageName, plugin]), + res.body.map((plugin: { packageName: string; experimental: boolean; hasBuiltEntrypoints: boolean }) => [plugin.packageName, plugin]), ); expect(packageNames).toContain("@paperclipai/plugin-workspace-diff"); expect(packageNames).toContain("@paperclipai/plugin-llm-wiki"); @@ -158,6 +158,7 @@ describe.sequential("plugin install and upgrade authz", () => { expect(byPackageName.get("@paperclipai/plugin-llm-wiki")?.experimental).toBe(true); expect(byPackageName.get("@paperclipai/plugin-modal")?.experimental).toBe(true); expect(byPackageName.get("@paperclipai/plugin-authoring-smoke-example")?.experimental).toBe(false); + expect(typeof byPackageName.get("@paperclipai/plugin-workspace-diff")?.hasBuiltEntrypoints).toBe("boolean"); }, 20_000); it("rejects plugin installation for non-admin board users", async () => { diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 1c01bbba..ed0f71a1 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -47,7 +47,12 @@ import { } from "@paperclipai/shared"; import { pluginRegistryService } from "../services/plugin-registry.js"; import { pluginLifecycleManager } from "../services/plugin-lifecycle.js"; -import { getPluginUiContributionMetadata, pluginLoader } from "../services/plugin-loader.js"; +import { + getPluginUiContributionMetadata, + listMissingDeclaredPluginEntrypoints, + pluginLoader, + REPO_ROOT, +} from "../services/plugin-loader.js"; import { logActivity } from "../services/activity-log.js"; import { publishGlobalLiveEvent } from "../services/live-events.js"; import { issueService } from "../services/issues.js"; @@ -124,6 +129,7 @@ interface AvailableBundledPlugin { localPath: string; tag: "example" | "first-party"; experimental: boolean; + hasBuiltEntrypoints: boolean; } /** Response body for GET /api/plugins/:pluginId/health */ @@ -152,13 +158,24 @@ const PLUGIN_SCOPED_API_RESPONSE_HEADER_ALLOWLIST = new Set([ ]); const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(__dirname, "../../.."); const EXPERIMENTAL_BUNDLED_PLUGIN_PACKAGE_NAMES = new Set([ "@paperclipai/plugin-llm-wiki", "@paperclipai/plugin-modal", "@paperclipai/plugin-workspace-diff", ]); -let bundledPluginsCache: Promise | null = null; +/** + * Cached bundled-plugin discovery. Static metadata (name, key, display, paths) + * is expensive to compute and never changes at runtime, so it is cached. The + * `hasBuiltEntrypoints` flag is filesystem state that flips when a plugin is + * auto-built on install, so it is recomputed per request in `listBundledPlugins` + * rather than cached. + */ +type DiscoveredBundledPlugin = { + entry: Omit; + packageRoot: string; + pkgJson: Record; +}; +let bundledPluginsCache: Promise | null = null; function titleCasePluginName(packageName: string): string { const localName = packageName.split("/").pop() ?? packageName; @@ -266,9 +283,9 @@ function isExperimentalBundledPlugin(packageRoot: string, packageName: string): ); } -async function discoverBundledPlugins(): Promise { +async function discoverBundledPlugins(): Promise { const pluginRoot = path.resolve(REPO_ROOT, "packages/plugins"); - const bundledPlugins: AvailableBundledPlugin[] = []; + const bundledPlugins: DiscoveredBundledPlugin[] = []; for (const packageJsonPath of await findPackageJsonFiles(pluginRoot)) { const packageRoot = path.dirname(packageJsonPath); const pkgJson = await readJsonFile(packageJsonPath); @@ -288,20 +305,24 @@ async function discoverBundledPlugins(): Promise { const metadata = await bundledPluginMetadata(packageRoot, pkgJson); const tag = packageRoot.includes(`${path.sep}examples${path.sep}`) ? "example" : "first-party"; bundledPlugins.push({ - 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, - experimental: isExperimentalBundledPlugin(packageRoot, packageName), + entry: { + 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, + experimental: isExperimentalBundledPlugin(packageRoot, packageName), + }, + packageRoot, + pkgJson, }); } return bundledPlugins.sort((left, right) => { - if (left.tag !== right.tag) return left.tag === "first-party" ? -1 : 1; - return left.displayName.localeCompare(right.displayName); + if (left.entry.tag !== right.entry.tag) return left.entry.tag === "first-party" ? -1 : 1; + return left.entry.displayName.localeCompare(right.entry.displayName); }); } @@ -310,7 +331,13 @@ async function listBundledPlugins(): Promise { bundledPluginsCache = null; throw error; }); - return bundledPluginsCache; + const discovered = await bundledPluginsCache; + // Recompute the filesystem-dependent flag per request so a plugin auto-built + // during install is no longer reported as missing its entrypoints. + return discovered.map(({ entry, packageRoot, pkgJson }) => ({ + ...entry, + hasBuiltEntrypoints: listMissingDeclaredPluginEntrypoints(packageRoot, pkgJson).length === 0, + })); } /** diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index 0424f0b1..d074af04 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -52,6 +52,11 @@ import { pluginDatabaseService } from "./plugin-database.js"; const execFileAsync = promisify(execFile); const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const REPO_ROOT = path.resolve(__dirname, "../../.."); +export const BUNDLED_LOCAL_PLUGIN_ROOT = path.join(REPO_ROOT, "packages", "plugins"); +export const STANDALONE_BUNDLED_PLUGIN_ROOT = path.join(BUNDLED_LOCAL_PLUGIN_ROOT, "sandbox-providers"); +export const LOCAL_PLUGIN_AUTOBUILD_TIMEOUT_MS = 120_000; +const STANDALONE_BUNDLED_PLUGIN_SDK_PACKAGE = "@paperclipai/plugin-sdk"; // --------------------------------------------------------------------------- // Constants @@ -184,6 +189,19 @@ export interface PluginDiscoveryResult { sources: PluginSource[]; } +type PluginEntrypointKey = "manifest" | "worker" | "ui"; + +type PluginEntrypointPath = { + key: PluginEntrypointKey; + absolutePath: string; +}; + +type LocalPluginBuildCommand = { + file: string; + args: string[]; + cwd: string; +}; + function getDeclaredPageRoutePaths(manifest: PaperclipPluginManifestV1): string[] { return (manifest.ui?.slots ?? []) .filter((slot): slot is PluginUiSlotDeclaration => slot.type === "page" && typeof slot.routePath === "string" && slot.routePath.length > 0) @@ -595,6 +613,263 @@ async function readPackageJson( } } +function buildLocalPluginBuildCommand(pkgJson: Record): string | null { + const packageName = pkgJson["name"]; + if (typeof packageName !== "string" || packageName.trim().length === 0) return null; + return `pnpm --filter ${packageName} build`; +} + +function readPackageDependencyNames( + pkgJson: Record, + field: "dependencies" | "optionalDependencies", +): string[] { + const deps = pkgJson[field]; + if (deps === null || typeof deps !== "object" || Array.isArray(deps)) return []; + return Object.keys(deps as Record); +} + +function resolvePackageInstallPath(packageRoot: string, packageName: string): string { + return path.join(packageRoot, "node_modules", ...packageName.split("/")); +} + +function isPathWithin(root: string, target: string): boolean { + const relativePath = path.relative(root, target); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +export function isRepoBundledPluginPath( + packageRoot: string, + options: { repoRoot?: string } = {}, +): boolean { + const repoPluginsRoot = path.join(options.repoRoot ?? REPO_ROOT, "packages", "plugins"); + return isPathWithin(repoPluginsRoot, packageRoot); +} + +export function isStandaloneBundledPluginPath( + packageRoot: string, + options: { repoRoot?: string } = {}, +): boolean { + const repoRoot = options.repoRoot ?? REPO_ROOT; + return isPathWithin(path.join(repoRoot, "packages", "plugins", "sandbox-providers"), packageRoot); +} + +export function resolveDeclaredPluginEntrypoints( + packageRoot: string, + pkgJson: Record, +): PluginEntrypointPath[] { + const paperclipPlugin = pkgJson["paperclipPlugin"]; + if ( + paperclipPlugin === null + || typeof paperclipPlugin !== "object" + || Array.isArray(paperclipPlugin) + ) { + return []; + } + + const entrypoints: PluginEntrypointPath[] = []; + for (const key of ["manifest", "worker", "ui"] as const) { + const relativePath = (paperclipPlugin as Record)[key]; + if (typeof relativePath === "string" && relativePath.length > 0) { + entrypoints.push({ + key, + absolutePath: path.resolve(packageRoot, relativePath), + }); + } + } + + return entrypoints; +} + +export function listMissingDeclaredPluginEntrypoints( + packageRoot: string, + pkgJson: Record, +): PluginEntrypointPath[] { + return resolveDeclaredPluginEntrypoints(packageRoot, pkgJson).filter( + (entrypoint) => !existsSync(entrypoint.absolutePath), + ); +} + +function listMissingStandaloneBundledPluginRuntimeDependencies( + packageRoot: string, + pkgJson: Record, +): string[] { + // optionalDependencies are intentionally allowed to be absent (e.g. + // platform-specific packages), so they must not be treated as required here — + // otherwise post-build verification fails even when install/build succeeded. + const dependencyNames = new Set([ + STANDALONE_BUNDLED_PLUGIN_SDK_PACKAGE, + ...readPackageDependencyNames(pkgJson, "dependencies"), + ]); + + return [...dependencyNames].filter( + (packageName) => !existsSync(resolvePackageInstallPath(packageRoot, packageName)), + ); +} + +function formatLocalPluginManualBuildHint( + packageRoot: string, + pkgJson: Record, + options: { processEnv?: NodeJS.ProcessEnv; repoRoot?: string } = {}, +): string { + if (!isRepoBundledPluginPath(packageRoot, { repoRoot: options.repoRoot })) return ""; + + const manualBuildCommand = buildLocalPluginRecoveryCommand(packageRoot, pkgJson, { repoRoot: options.repoRoot }); + if (!manualBuildCommand) return ""; + + const autoBuildDisabled = (options.processEnv ?? process.env)["PAPERCLIP_DISABLE_PLUGIN_AUTOBUILD"] === "1" + ? " Auto-build is disabled by PAPERCLIP_DISABLE_PLUGIN_AUTOBUILD=1." + : ""; + + return `${autoBuildDisabled} Run \`${manualBuildCommand}\` from the repo root and retry.`; +} + +function buildStandaloneBundledPluginInstallArgs( + packageRoot: string, +): string[] { + const packageLockfilePath = path.join(packageRoot, "pnpm-lock.yaml"); + return existsSync(packageLockfilePath) + ? ["install", "--ignore-workspace", "--frozen-lockfile"] + : ["install", "--ignore-workspace", "--no-lockfile"]; +} + +function buildStandaloneBundledPluginInstallCommand( + packageRoot: string, +): string { + return `pnpm ${buildStandaloneBundledPluginInstallArgs(packageRoot).join(" ")}`; +} + +function buildLocalPluginRecoveryCommand( + packageRoot: string, + pkgJson: Record, + options: { repoRoot?: string } = {}, +): string | null { + if (isStandaloneBundledPluginPath(packageRoot, { repoRoot: options.repoRoot })) { + const repoRoot = options.repoRoot ?? REPO_ROOT; + const relativePath = path.relative(repoRoot, packageRoot) || "."; + const installCommand = buildStandaloneBundledPluginInstallCommand(packageRoot); + return `cd ${relativePath} && ${installCommand} && pnpm build`; + } + + return buildLocalPluginBuildCommand(pkgJson); +} + +function buildLocalPluginBuildCommands( + packageRoot: string, + pkgJson: Record, + options: { + repoRoot?: string; + needsBuild?: boolean; + needsStandaloneRuntimeBootstrap?: boolean; + } = {}, +): LocalPluginBuildCommand[] { + if (isStandaloneBundledPluginPath(packageRoot, { repoRoot: options.repoRoot })) { + const commands: LocalPluginBuildCommand[] = []; + const shouldInstallStandaloneRuntime = + options.needsStandaloneRuntimeBootstrap === true + || (!existsSync(path.join(packageRoot, "node_modules")) && options.needsBuild !== false); + + if (shouldInstallStandaloneRuntime) { + commands.push({ + file: "pnpm", + args: buildStandaloneBundledPluginInstallArgs(packageRoot), + cwd: packageRoot, + }); + } + + if (options.needsBuild !== false) { + commands.push({ + file: "pnpm", + args: ["build"], + cwd: packageRoot, + }); + } + return commands; + } + + const packageName = pkgJson["name"]; + if (typeof packageName !== "string" || packageName.trim().length === 0) return []; + return [{ + file: "pnpm", + args: ["--filter", packageName, "build"], + cwd: options.repoRoot ?? REPO_ROOT, + }]; +} + +export async function ensureLocalPluginBuilt( + packageRoot: string, + pkgJson: Record, + options: { + processEnv?: NodeJS.ProcessEnv; + repoRoot?: string; + execFileAsyncImpl?: ( + file: string, + args: readonly string[], + options: { cwd: string; timeout: number }, + ) => Promise<{ stdout: string; stderr: string }>; + } = {}, +): Promise { + const processEnv = options.processEnv ?? process.env; + if (processEnv["PAPERCLIP_DISABLE_PLUGIN_AUTOBUILD"] === "1") return; + if (!isRepoBundledPluginPath(packageRoot, { repoRoot: options.repoRoot })) return; + + const missingEntrypoints = listMissingDeclaredPluginEntrypoints(packageRoot, pkgJson); + const missingStandaloneRuntimeDeps = isStandaloneBundledPluginPath(packageRoot, { repoRoot: options.repoRoot }) + ? listMissingStandaloneBundledPluginRuntimeDependencies(packageRoot, pkgJson) + : []; + if (missingEntrypoints.length === 0 && missingStandaloneRuntimeDeps.length === 0) return; + + const packageName = pkgJson["name"]; + const manualBuildCommand = buildLocalPluginRecoveryCommand(packageRoot, pkgJson, { repoRoot: options.repoRoot }); + if (typeof packageName !== "string" || packageName.trim().length === 0 || !manualBuildCommand) return; + + const runExecFileAsync = options.execFileAsyncImpl ?? execFileAsync; + const buildCommands = buildLocalPluginBuildCommands(packageRoot, pkgJson, { + repoRoot: options.repoRoot, + needsBuild: missingEntrypoints.length > 0, + needsStandaloneRuntimeBootstrap: missingStandaloneRuntimeDeps.length > 0, + }); + + try { + for (const command of buildCommands) { + await runExecFileAsync( + command.file, + command.args, + { cwd: command.cwd, timeout: LOCAL_PLUGIN_AUTOBUILD_TIMEOUT_MS }, + ); + } + } catch (error) { + const stderr = typeof (error as { stderr?: unknown }).stderr === "string" + ? (error as { stderr: string }).stderr.trim() + : ""; + const timeoutMessage = (error as { killed?: unknown }).killed === true + ? ` after timing out at ${LOCAL_PLUGIN_AUTOBUILD_TIMEOUT_MS}ms` + : ""; + const stderrMessage = stderr.length > 0 ? ` stderr: ${stderr}` : ""; + throw new Error( + `Failed to auto-build bundled local plugin ${packageName}${timeoutMessage}. ` + + `Run \`${manualBuildCommand}\` from the repo root and retry.${stderrMessage}`, + ); + } + + const stillMissingEntrypoints = listMissingDeclaredPluginEntrypoints(packageRoot, pkgJson); + const stillMissingStandaloneRuntimeDeps = isStandaloneBundledPluginPath(packageRoot, { repoRoot: options.repoRoot }) + ? listMissingStandaloneBundledPluginRuntimeDependencies(packageRoot, pkgJson) + : []; + if (stillMissingEntrypoints.length > 0 || stillMissingStandaloneRuntimeDeps.length > 0) { + const missingDetails: string[] = []; + if (stillMissingEntrypoints.length > 0) { + missingDetails.push(`built entrypoints: ${stillMissingEntrypoints.map((entrypoint) => entrypoint.key).join(", ")}`); + } + if (stillMissingStandaloneRuntimeDeps.length > 0) { + missingDetails.push(`runtime dependencies: ${stillMissingStandaloneRuntimeDeps.join(", ")}`); + } + throw new Error( + `Bundled local plugin ${packageName} is still missing ${missingDetails.join("; ")} after auto-build. ` + + `Run \`${manualBuildCommand}\` from the repo root and retry.`, + ); + } +} + /** * Resolve the manifest entrypoint from a package.json and package root. * @@ -928,10 +1203,17 @@ export function pluginLoader( const pkgJson = await readPackageJson(resolvedPackagePath); if (!pkgJson) throw new Error(`Missing package.json at ${resolvedPackagePath}`); + if (localPath) { + await ensureLocalPluginBuilt(resolvedPackagePath, pkgJson); + } + const manifestPath = resolveManifestPath(resolvedPackagePath, pkgJson); if (!manifestPath || !existsSync(manifestPath)) { + const manualBuildHint = localPath + ? formatLocalPluginManualBuildHint(resolvedPackagePath, pkgJson) + : ""; throw new Error( - `Package ${resolvedPackageName} at ${resolvedPackagePath} does not appear to be a Paperclip plugin (no manifest found).`, + `Package ${resolvedPackageName} at ${resolvedPackagePath} does not appear to be a Paperclip plugin (no manifest found).${manualBuildHint}`, ); } diff --git a/ui/src/api/plugins.ts b/ui/src/api/plugins.ts index b0187560..0b32cfd8 100644 --- a/ui/src/api/plugins.ts +++ b/ui/src/api/plugins.ts @@ -140,6 +140,7 @@ export interface AvailableBundledPlugin { localPath: string; tag: "example" | "first-party"; experimental: boolean; + hasBuiltEntrypoints: boolean; } export interface PluginLocalFolderProblem { diff --git a/ui/src/pages/PluginManager.tsx b/ui/src/pages/PluginManager.tsx index 8f919e14..3a893186 100644 --- a/ui/src/pages/PluginManager.tsx +++ b/ui/src/pages/PluginManager.tsx @@ -173,6 +173,11 @@ export function PluginManager() { const bundledPlugins = bundledQuery.data ?? []; const installedByPackageName = new Map(installedPlugins.map((plugin) => [plugin.packageName, plugin])); const bundledByPackageName = new Map(bundledPlugins.map((plugin) => [plugin.packageName, plugin])); + // Scope the in-section banner to bundled (local-path) installs so an npm-dialog + // install failure does not surface its error in the bundled-plugins section. + const installErrorMessage = installMutation.variables?.isLocalPath + ? installMutation.error?.message ?? null + : null; const errorSummaryByPluginId = useMemo( () => new Map( @@ -249,6 +254,12 @@ export function PluginManager() { Bundled + {installErrorMessage && ( +
+ {installErrorMessage} +
+ )} + {bundledQuery.isLoading ? (
Loading bundled plugins...
) : bundledQuery.error ? ( @@ -293,6 +304,9 @@ export function PluginManager() {

{bundledPlugin.description}

{bundledPlugin.packageName}

+ {installPending && !bundledPlugin.hasBuiltEntrypoints && ( +

Building plugin...

+ )}
{installedPlugin ? (