Discover Pi Web plugins from Pi packages

This commit is contained in:
Federico Jaramillo Martinez
2026-05-10 15:20:08 +02:00
parent 125dedf300
commit 95f68568ab
4 changed files with 294 additions and 28 deletions
+18
View File
@@ -19,6 +19,10 @@ beforeEach(async () => {
app = await buildApp({ app = await buildApp({
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))), projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
workspaces: new WorkspaceService(), 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, clientDist: false,
logger: false, logger: false,
}); });
@@ -54,6 +58,20 @@ describe("buildApp", () => {
expect(emptyListResponse.json<Project[]>()).toEqual([]); expect(emptyListResponse.json<Project[]>()).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 () => { it("returns stable errors for invalid project requests", async () => {
const addResponse = await app.inject({ const addResponse = await app.inject({
method: "POST", method: "POST",
+2 -1
View File
@@ -18,6 +18,7 @@ import { PiWebPluginService } from "./piWebPluginService.js";
export interface AppDependencies { export interface AppDependencies {
projects?: ProjectService; projects?: ProjectService;
workspaces?: WorkspaceService; workspaces?: WorkspaceService;
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "readAsset">;
clientDist?: string | false; clientDist?: string | false;
logger?: FastifyServerOptions["logger"]; logger?: FastifyServerOptions["logger"];
} }
@@ -28,7 +29,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const projects = deps.projects ?? new ProjectService(new ProjectStore()); const projects = deps.projects ?? new ProjectService(new ProjectStore());
const workspaces = deps.workspaces ?? new WorkspaceService(); const workspaces = deps.workspaces ?? new WorkspaceService();
const piWebPlugins = new PiWebPluginService(); const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest()); app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
+99
View File
@@ -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<string, string> }): Promise<void> {
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);
}
}
+175 -27
View File
@@ -1,31 +1,103 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { readdir, readFile, realpath, stat } from "node:fs/promises"; 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 { fileURLToPath } from "node:url";
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import { piWebDataDir } from "../config.js"; import { piWebDataDir } from "../config.js";
const pluginIdPattern = /^[a-z][a-z0-9.-]*$/u; const pluginIdPattern = /^[a-z][a-z0-9.-]*$/u;
const defaultEntryFile = "pi-web-plugin.js"; const defaultEntryFile = "pi-web-plugin.js";
export interface PiWebPluginManifest { 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 { interface PluginRecord {
id: string; id: string;
root: string; root: string;
entryFile: 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<PluginRecord, "source" | "scope">;
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 { 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<PiWebPluginManifest> { async manifest(): Promise<PiWebPluginManifest> {
const plugins = await this.discoverPlugins(); const plugins = await this.discoverPlugins();
return { return {
plugins: plugins.map((plugin) => ({ plugins: plugins.map((plugin) => ({
id: plugin.id, 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<PluginRecord[]> { private async discoverPlugins(): Promise<PluginRecord[]> {
const records = new Map<string, PluginRecord>(); const records = new Map<string, PluginRecord>();
for (const root of this.roots) { for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin);
for (const plugin of await discoverRoot(root)) { if (this.packageProvider !== undefined) {
if (!records.has(plugin.id)) records.set(plugin.id, plugin); for (const plugin of await this.discoverPiPackagePlugins(this.packageProvider)) addUnique(records, plugin);
}
} }
return [...records.values()].sort((left, right) => left.id.localeCompare(right.id)); return [...records.values()].sort((left, right) => left.id.localeCompare(right.id));
} }
private async discoverLocalPlugins(): Promise<PluginRecord[]> {
const plugins: PluginRecord[] = [];
for (const root of this.roots) plugins.push(...await discoverLocalRoot(root));
return plugins;
}
private async discoverPiPackagePlugins(packageProvider: PiPackageProvider): Promise<PluginRecord[]> {
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)); const moduleDir = dirname(fileURLToPath(import.meta.url));
return [ return [
join(moduleDir, "..", "..", "pi-web-plugins"), { path: join(moduleDir, "..", "..", "pi-web-plugins"), source: "bundled", scope: "bundled" },
join(piWebDataDir(), "plugins"), { path: join(piWebDataDir(), "plugins"), source: "local", scope: "local" },
]; ];
} }
async function discoverRoot(root: string): Promise<PluginRecord[]> { async function discoverLocalRoot(root: LocalPluginRoot): Promise<PluginRecord[]> {
if (!existsSync(root)) return []; if (!existsSync(root.path)) return [];
const entries = await readdir(root, { withFileTypes: true }).catch(() => []); const entries = await readdir(root.path, { withFileTypes: true }).catch(() => []);
const plugins: PluginRecord[] = []; const plugins: PluginRecord[] = [];
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory() || !pluginIdPattern.test(entry.name)) continue; if (!entry.isDirectory() || !pluginIdPattern.test(entry.name)) continue;
const plugin = await discoverPlugin(join(root, entry.name), entry.name); plugins.push(...await discoverLocalPlugin(join(root.path, entry.name), entry.name, root));
if (plugin !== undefined) plugins.push(plugin);
} }
return plugins; return plugins;
} }
async function discoverPlugin(root: string, fallbackId: string): Promise<PluginRecord | undefined> { async function discoverLocalPlugin(root: string, fallbackId: string, localRoot: LocalPluginRoot): Promise<PluginRecord[]> {
const metadata = await readPluginPackage(root); const config = await readPiWebPackageConfig(root) ?? { plugins: [{ path: defaultEntryFile }] };
const id = metadata?.id ?? fallbackId; const plugins = await discoverPluginEntries(root, config, fallbackId);
const entryFile = metadata?.entryFile ?? defaultEntryFile; return plugins.map((plugin) => ({ ...plugin, source: localRoot.source, scope: localRoot.scope }));
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 readPluginPackage(root: string): Promise<{ id?: string; entryFile?: string } | undefined> { async function discoverPackageRoot(root: string, configuredPackage: ConfiguredPiPackage): Promise<PluginRecord[]> {
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<ArraylessPluginRecord[]> {
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<PiWebPackageConfig | undefined> {
const packagePath = join(root, "package.json"); const packagePath = join(root, "package.json");
const content = await readFile(packagePath, "utf8").catch(() => undefined); const content = await readFile(packagePath, "utf8").catch(() => undefined);
if (content === undefined) return undefined; if (content === undefined) return undefined;
@@ -99,12 +209,50 @@ async function readPluginPackage(root: string): Promise<{ id?: string; entryFile
const pi = parsed["pi"]; const pi = parsed["pi"];
const piWeb = isRecord(parsed["piWeb"]) ? parsed["piWeb"] : isRecord(pi) && isRecord(pi["piWeb"]) ? pi["piWeb"] : undefined; const piWeb = isRecord(parsed["piWeb"]) ? parsed["piWeb"] : isRecord(pi) && isRecord(pi["piWeb"]) ? pi["piWeb"] : undefined;
if (!isRecord(piWeb)) return undefined; if (!isRecord(piWeb)) return undefined;
const plugins = parsePluginEntries(piWeb);
if (plugins.length === 0) return undefined;
return { return {
...(typeof piWeb["id"] === "string" ? { id: piWeb["id"] } : {}), ...(typeof piWeb["id"] === "string" ? { id: piWeb["id"] } : {}),
...(typeof piWeb["plugin"] === "string" ? { entryFile: piWeb["plugin"] } : {}), plugins,
}; };
} }
function parsePluginEntries(piWeb: Record<string, unknown>): 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<string, PluginRecord>, 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 { function isWithin(root: string, candidate: string): boolean {
const rel = relative(root, candidate); const rel = relative(root, candidate);
return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep)); return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep));