From 95f68568abe6d4c1e52a74178f4d345ee9c69d1e Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 10 May 2026 15:20:08 +0200 Subject: [PATCH] Discover Pi Web plugins from Pi packages --- src/server/app.test.ts | 18 +++ src/server/app.ts | 3 +- src/server/piWebPluginService.test.ts | 99 +++++++++++++ src/server/piWebPluginService.ts | 202 ++++++++++++++++++++++---- 4 files changed, 294 insertions(+), 28 deletions(-) create mode 100644 src/server/piWebPluginService.test.ts diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 51a5971..6f4a981 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -19,6 +19,10 @@ beforeEach(async () => { app = await buildApp({ projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))), workspaces: new WorkspaceService(), + piWebPlugins: { + manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }), + readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined), + }, clientDist: false, logger: false, }); @@ -54,6 +58,20 @@ describe("buildApp", () => { expect(emptyListResponse.json()).toEqual([]); }); + it("serves the Pi Web plugin manifest and plugin assets", async () => { + const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" }); + expect(manifestResponse.statusCode).toBe(200); + expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }); + + const assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" }); + expect(assetResponse.statusCode).toBe(200); + expect(assetResponse.headers["content-type"]).toContain("application/javascript"); + expect(assetResponse.body).toBe("export default {};"); + + const missingResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/missing.js" }); + expect(missingResponse.statusCode).toBe(404); + }); + it("returns stable errors for invalid project requests", async () => { const addResponse = await app.inject({ method: "POST", diff --git a/src/server/app.ts b/src/server/app.ts index f60429e..9ecd352 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -18,6 +18,7 @@ import { PiWebPluginService } from "./piWebPluginService.js"; export interface AppDependencies { projects?: ProjectService; workspaces?: WorkspaceService; + piWebPlugins?: Pick; clientDist?: string | false; logger?: FastifyServerOptions["logger"]; } @@ -28,7 +29,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise piWebPlugins.manifest()); diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts new file mode 100644 index 0000000..c984cc6 --- /dev/null +++ b/src/server/piWebPluginService.test.ts @@ -0,0 +1,99 @@ +import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { PiWebPluginService, type PiPackageProvider } from "./piWebPluginService.js"; + +let tempDir: string; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-web-plugin-service-test-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +describe("PiWebPluginService", () => { + it("discovers local plugins and serves assets", async () => { + const pluginDir = join(tempDir, "plugins", "info"); + await writePlugin(pluginDir, { + packageJson: { piWeb: { id: "info", plugin: "pi-web-plugin.js" } }, + files: { "pi-web-plugin.js": "export default { id: 'info' };" }, + }); + + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + + await expect(service.manifest()).resolves.toEqual({ + plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local" })], + }); + const manifest = await service.manifest(); + expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/info\/pi-web-plugin\.js\?v=\d+$/u); + + const asset = await service.readAsset("info", "pi-web-plugin.js"); + expect(asset?.contentType).toBe("application/javascript; charset=utf-8"); + expect(asset?.content.toString("utf8")).toContain("export default"); + }); + + it("discovers Pi package plugins through an injected package provider", async () => { + const packageDir = join(tempDir, "pkg"); + await writePlugin(packageDir, { + packageJson: { pi: { piWeb: { plugins: [{ id: "review", module: "dist/review.js" }] } } }, + files: { "dist/review.js": "export default { id: 'review' };" }, + }); + const packageProvider: PiPackageProvider = { + listPackages: () => [{ source: "npm:@acme/review", scope: "user", installedPath: packageDir }], + getInstalledPath: () => undefined, + }; + + const service = new PiWebPluginService({ roots: [], packageProvider }); + + const manifest = await service.manifest(); + expect(manifest.plugins).toHaveLength(1); + expect(manifest.plugins[0]).toMatchObject({ id: "review", source: "npm:@acme/review", scope: "user" }); + expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u); + }); + + it("keeps duplicate plugin ids addressable", async () => { + await writePlugin(join(tempDir, "plugins", "one"), { + packageJson: { piWeb: { id: "duplicate", plugin: "pi-web-plugin.js" } }, + files: { "pi-web-plugin.js": "export default {};" }, + }); + await writePlugin(join(tempDir, "plugins", "two"), { + packageJson: { piWeb: { id: "duplicate", plugin: "pi-web-plugin.js" } }, + files: { "pi-web-plugin.js": "export default {};" }, + }); + + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + + const manifest = await service.manifest(); + expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate", "duplicate.2"]); + await expect(service.readAsset("duplicate.2", "pi-web-plugin.js")).resolves.toBeDefined(); + }); + + it("rejects unsafe plugin entries and asset traversal", async () => { + const pluginDir = join(tempDir, "plugins", "safe"); + await writePlugin(pluginDir, { + packageJson: { piWeb: { id: "safe", plugins: ["../escape.js", "pi-web-plugin.js"] } }, + files: { "pi-web-plugin.js": "export default {};" }, + }); + await writeFile(join(tempDir, "plugins", "escape.js"), "nope"); + + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + + const manifest = await service.manifest(); + expect(manifest.plugins).toHaveLength(1); + expect(manifest.plugins[0]?.module).toContain("pi-web-plugin.js"); + await expect(service.readAsset("safe", "../escape.js")).resolves.toBeUndefined(); + }); +}); + +async function writePlugin(root: string, options: { packageJson: unknown; files: Record }): Promise { + await mkdir(root, { recursive: true }); + await writeFile(join(root, "package.json"), `${JSON.stringify(options.packageJson, null, 2)}\n`); + for (const [path, content] of Object.entries(options.files)) { + const filePath = join(root, path); + await mkdir(join(filePath, ".."), { recursive: true }); + await writeFile(filePath, content); + } +} diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index f976565..3dba78d 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -1,31 +1,103 @@ import { existsSync } from "node:fs"; import { readdir, readFile, realpath, stat } from "node:fs/promises"; -import { dirname, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; import { piWebDataDir } from "../config.js"; const pluginIdPattern = /^[a-z][a-z0-9.-]*$/u; const defaultEntryFile = "pi-web-plugin.js"; export interface PiWebPluginManifest { - plugins: { id: string; module: string }[]; + plugins: { id: string; module: string; source: string; scope: PiWebPluginScope }[]; +} + +export type PiWebPluginScope = "bundled" | "local" | "user" | "project"; + +export interface ConfiguredPiPackage { + source: string; + scope: "user" | "project"; + installedPath?: string; +} + +export interface PiPackageProvider { + listPackages(): ConfiguredPiPackage[]; + getInstalledPath(source: string, scope: "user" | "project"): string | undefined; } interface PluginRecord { id: string; root: string; entryFile: string; + version: string; + source: string; + scope: PiWebPluginScope; +} + +interface PiWebPluginServiceOptions { + roots?: LocalPluginRoot[]; + cwd?: string; + agentDir?: string; + packageProvider?: PiPackageProvider | false; +} + +interface LocalPluginRoot { + path: string; + source: string; + scope: PiWebPluginScope; +} + +interface PiWebPackageConfig { + id?: string; + plugins: PiWebPluginEntry[]; +} + +interface PiWebPluginEntry { + id?: string; + path: string; +} + +type ArraylessPluginRecord = Omit; + +export class DefaultPiPackageProvider implements PiPackageProvider { + private readonly packageManager: DefaultPackageManager; + + constructor(cwd = process.cwd(), agentDir = getAgentDir()) { + this.packageManager = new DefaultPackageManager({ + cwd, + agentDir, + settingsManager: SettingsManager.create(cwd, agentDir), + }); + } + + listPackages(): ConfiguredPiPackage[] { + return this.packageManager.listConfiguredPackages(); + } + + getInstalledPath(source: string, scope: "user" | "project"): string | undefined { + return this.packageManager.getInstalledPath(source, scope); + } } export class PiWebPluginService { - constructor(private readonly roots = defaultPluginRoots()) {} + private readonly roots: LocalPluginRoot[]; + private readonly packageProvider: PiPackageProvider | undefined; + + constructor(options: PiWebPluginServiceOptions = {}) { + const cwd = options.cwd ?? process.cwd(); + const agentDir = options.agentDir ?? getAgentDir(); + this.roots = options.roots ?? defaultPluginRoots(); + this.packageProvider = options.packageProvider === false ? undefined : options.packageProvider ?? new DefaultPiPackageProvider(cwd, agentDir); + } async manifest(): Promise { const plugins = await this.discoverPlugins(); return { plugins: plugins.map((plugin) => ({ id: plugin.id, - module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}`, + module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`, + source: plugin.source, + scope: plugin.scope, })), }; } @@ -50,47 +122,85 @@ export class PiWebPluginService { private async discoverPlugins(): Promise { const records = new Map(); - for (const root of this.roots) { - for (const plugin of await discoverRoot(root)) { - if (!records.has(plugin.id)) records.set(plugin.id, plugin); - } + for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin); + if (this.packageProvider !== undefined) { + for (const plugin of await this.discoverPiPackagePlugins(this.packageProvider)) addUnique(records, plugin); } return [...records.values()].sort((left, right) => left.id.localeCompare(right.id)); } + + private async discoverLocalPlugins(): Promise { + const plugins: PluginRecord[] = []; + for (const root of this.roots) plugins.push(...await discoverLocalRoot(root)); + return plugins; + } + + private async discoverPiPackagePlugins(packageProvider: PiPackageProvider): Promise { + const plugins: PluginRecord[] = []; + for (const configuredPackage of packageProvider.listPackages()) { + const root = configuredPackage.installedPath ?? packageProvider.getInstalledPath(configuredPackage.source, configuredPackage.scope); + if (root === undefined) continue; + plugins.push(...await discoverPackageRoot(root, configuredPackage)); + } + return plugins; + } } -function defaultPluginRoots(): string[] { +function defaultPluginRoots(): LocalPluginRoot[] { const moduleDir = dirname(fileURLToPath(import.meta.url)); return [ - join(moduleDir, "..", "..", "pi-web-plugins"), - join(piWebDataDir(), "plugins"), + { path: join(moduleDir, "..", "..", "pi-web-plugins"), source: "bundled", scope: "bundled" }, + { path: join(piWebDataDir(), "plugins"), source: "local", scope: "local" }, ]; } -async function discoverRoot(root: string): Promise { - if (!existsSync(root)) return []; - const entries = await readdir(root, { withFileTypes: true }).catch(() => []); +async function discoverLocalRoot(root: LocalPluginRoot): Promise { + if (!existsSync(root.path)) return []; + const entries = await readdir(root.path, { withFileTypes: true }).catch(() => []); const plugins: PluginRecord[] = []; for (const entry of entries) { if (!entry.isDirectory() || !pluginIdPattern.test(entry.name)) continue; - const plugin = await discoverPlugin(join(root, entry.name), entry.name); - if (plugin !== undefined) plugins.push(plugin); + plugins.push(...await discoverLocalPlugin(join(root.path, entry.name), entry.name, root)); } return plugins; } -async function discoverPlugin(root: string, fallbackId: string): Promise { - const metadata = await readPluginPackage(root); - const id = metadata?.id ?? fallbackId; - const entryFile = metadata?.entryFile ?? defaultEntryFile; - if (!pluginIdPattern.test(id) || entryFile.includes("..") || entryFile.startsWith("/")) return undefined; - const entryPath = join(root, entryFile); - const entryStat = await stat(entryPath).catch(() => undefined); - if (entryStat?.isFile() !== true) return undefined; - return { id, root, entryFile }; +async function discoverLocalPlugin(root: string, fallbackId: string, localRoot: LocalPluginRoot): Promise { + const config = await readPiWebPackageConfig(root) ?? { plugins: [{ path: defaultEntryFile }] }; + const plugins = await discoverPluginEntries(root, config, fallbackId); + return plugins.map((plugin) => ({ ...plugin, source: localRoot.source, scope: localRoot.scope })); } -async function readPluginPackage(root: string): Promise<{ id?: string; entryFile?: string } | undefined> { +async function discoverPackageRoot(root: string, configuredPackage: ConfiguredPiPackage): Promise { + const config = await readPiWebPackageConfig(root); + if (config === undefined) return []; + const fallbackId = sanitizePluginId(config.id ?? configuredPackage.source); + const plugins = await discoverPluginEntries(root, config, fallbackId); + return plugins.map((plugin) => ({ ...plugin, source: configuredPackage.source, scope: configuredPackage.scope })); +} + +async function discoverPluginEntries(root: string, config: PiWebPackageConfig, fallbackId: string): Promise { + const plugins: ArraylessPluginRecord[] = []; + for (const [index, entry] of config.plugins.entries()) { + if (!isSafeRelativePath(entry.path)) continue; + const entryPath = join(root, entry.path); + const entryStat = await stat(entryPath).catch(() => undefined); + if (entryStat?.isFile() !== true) continue; + const id = pluginEntryId(config, entry, fallbackId, index); + plugins.push({ id, root, entryFile: entry.path, version: String(Math.floor(entryStat.mtimeMs)) }); + } + return plugins; +} + +function pluginEntryId(config: PiWebPackageConfig, entry: PiWebPluginEntry, fallbackId: string, index: number): string { + if (entry.id !== undefined) return sanitizePluginId(entry.id); + if (config.id !== undefined && config.plugins.length === 1) return sanitizePluginId(config.id); + if (config.id !== undefined) return sanitizePluginId(`${config.id}.${basename(entry.path, ".js")}`); + if (config.plugins.length === 1) return sanitizePluginId(fallbackId); + return sanitizePluginId(`${fallbackId}.${String(index + 1)}`); +} + +async function readPiWebPackageConfig(root: string): Promise { const packagePath = join(root, "package.json"); const content = await readFile(packagePath, "utf8").catch(() => undefined); if (content === undefined) return undefined; @@ -99,12 +209,50 @@ async function readPluginPackage(root: string): Promise<{ id?: string; entryFile const pi = parsed["pi"]; const piWeb = isRecord(parsed["piWeb"]) ? parsed["piWeb"] : isRecord(pi) && isRecord(pi["piWeb"]) ? pi["piWeb"] : undefined; if (!isRecord(piWeb)) return undefined; + + const plugins = parsePluginEntries(piWeb); + if (plugins.length === 0) return undefined; return { ...(typeof piWeb["id"] === "string" ? { id: piWeb["id"] } : {}), - ...(typeof piWeb["plugin"] === "string" ? { entryFile: piWeb["plugin"] } : {}), + plugins, }; } +function parsePluginEntries(piWeb: Record): PiWebPluginEntry[] { + const plugin = piWeb["plugin"]; + if (typeof plugin === "string") return [{ path: plugin }]; + const plugins = piWeb["plugins"]; + if (!Array.isArray(plugins)) return []; + return plugins.flatMap((entry): PiWebPluginEntry[] => { + if (typeof entry === "string" && entry !== "") return [{ path: entry }]; + if (!isRecord(entry) || typeof entry["module"] !== "string" || entry["module"] === "") return []; + return [{ path: entry["module"], ...(typeof entry["id"] === "string" ? { id: entry["id"] } : {}) }]; + }); +} + +function addUnique(records: Map, plugin: PluginRecord): void { + if (!records.has(plugin.id)) { + records.set(plugin.id, plugin); + return; + } + for (let index = 2; ; index += 1) { + const id = sanitizePluginId(`${plugin.id}.${String(index)}`); + if (!records.has(id)) { + records.set(id, { ...plugin, id }); + return; + } + } +} + +function sanitizePluginId(value: string): string { + const normalized = value.toLowerCase().replace(/^npm:/u, "").replace(/^git:/u, "").replace(/[^a-z0-9.-]+/gu, ".").replace(/^[^a-z]+/u, "").replace(/[.-]+$/u, ""); + return pluginIdPattern.test(normalized) ? normalized : "plugin"; +} + +function isSafeRelativePath(path: string): boolean { + return path !== "" && !path.includes("..") && !path.startsWith("/"); +} + function isWithin(root: string, candidate: string): boolean { const rel = relative(root, candidate); return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep));