Archived
feat: stabilize Pi Web plugin API
This commit is contained in:
@@ -282,7 +282,7 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
|
||||
const workspace = this.state.selectedWorkspace;
|
||||
return this.plugins.getWorkspacePanels().filter((panel) => workspace === undefined || (panel.visible?.(workspace) ?? true));
|
||||
return this.plugins.getWorkspacePanels().filter((panel) => workspace === undefined || (panel.visible?.({ workspace, state: this.state }) ?? true));
|
||||
}
|
||||
|
||||
private renderMobilePanelTitle(panel: QualifiedWorkspacePanelContribution) {
|
||||
@@ -322,7 +322,7 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private async loadExternalPlugins(): Promise<void> {
|
||||
try {
|
||||
for (const plugin of await loadExternalPlugins()) this.plugins.register(plugin);
|
||||
for (const registration of await loadExternalPlugins()) this.plugins.register(registration);
|
||||
this.requestUpdate();
|
||||
} catch (error) {
|
||||
console.warn("Failed to load external Pi Web plugins", error);
|
||||
@@ -436,7 +436,7 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
function createPluginRegistry(): PluginRegistry {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register(corePlugin);
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
return registry;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export class WorkspacePanel extends LitElement {
|
||||
override render() {
|
||||
const workspace = this.workspace;
|
||||
if (workspace === undefined) return html`<section class="empty">Select a workspace.</section>`;
|
||||
const visiblePanels = this.panels.filter((panel) => panel.visible?.(workspace) ?? true);
|
||||
const visiblePanels = this.panels;
|
||||
const selectedPanel = visiblePanels.find((panel) => panel.id === this.tool) ?? visiblePanels[0];
|
||||
const context = this.createPanelContext(workspace);
|
||||
return html`
|
||||
|
||||
@@ -3,10 +3,12 @@ import { createCoreActions } from "./actions";
|
||||
import { createCoreWorkspacePanels } from "./panels";
|
||||
|
||||
export const corePlugin: PiWebPlugin = {
|
||||
id: "core",
|
||||
apiVersion: 1,
|
||||
name: "Pi Web Core",
|
||||
activate: () => ({
|
||||
actions: createCoreActions(),
|
||||
workspacePanels: createCoreWorkspacePanels(),
|
||||
contributions: {
|
||||
actions: createCoreActions(),
|
||||
workspacePanels: createCoreWorkspacePanels(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ export function createCoreWorkspacePanels(): WorkspacePanelContribution[] {
|
||||
id: "workspace.git",
|
||||
title: "Git",
|
||||
order: 20,
|
||||
visible: (workspace) => workspace.isGitRepo,
|
||||
visible: ({ workspace }) => workspace.isGitRepo,
|
||||
render: renderGit,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,42 +2,44 @@ import { html } from "lit";
|
||||
import type { PiWebPlugin } from "../types";
|
||||
|
||||
export const examplePlugin: PiWebPlugin = {
|
||||
id: "example",
|
||||
apiVersion: 1,
|
||||
name: "Example Plugin",
|
||||
activate: () => ({
|
||||
actions: [
|
||||
{
|
||||
id: "workspace.show-path",
|
||||
title: "Show Current Workspace Path",
|
||||
group: "Example",
|
||||
enabled: (context) => context.state.selectedWorkspace !== undefined,
|
||||
run: (context) => {
|
||||
const path = context.state.selectedWorkspace?.path ?? "No workspace selected";
|
||||
window.alert(path);
|
||||
contributions: {
|
||||
actions: [
|
||||
{
|
||||
id: "workspace.show-path",
|
||||
title: "Show Current Workspace Path",
|
||||
group: "Example",
|
||||
enabled: (context) => context.state.selectedWorkspace !== undefined,
|
||||
run: (context) => {
|
||||
const path = context.state.selectedWorkspace?.path ?? "No workspace selected";
|
||||
window.alert(path);
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
workspaceLabelContributions: [
|
||||
{
|
||||
id: "workspace.example-label",
|
||||
order: 100,
|
||||
items: (context) => ({ type: "text", text: context.workspace.isGitRepo ? "git" : "folder", title: context.workspace.path }),
|
||||
},
|
||||
],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "workspace.info",
|
||||
title: "Info",
|
||||
order: 100,
|
||||
render: (context) => html`
|
||||
<section class="toolbar"><strong>Info</strong></section>
|
||||
<section class="viewer">
|
||||
<p><strong>Workspace</strong></p>
|
||||
<p class="muted">${context.workspace.label}</p>
|
||||
<p class="muted">${context.workspace.path}</p>
|
||||
</section>
|
||||
`,
|
||||
},
|
||||
],
|
||||
],
|
||||
workspaceLabels: [
|
||||
{
|
||||
id: "workspace.example-label",
|
||||
order: 100,
|
||||
items: (context) => [{ type: "text", text: context.workspace.isGitRepo ? "git" : "folder", title: context.workspace.path }],
|
||||
},
|
||||
],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "workspace.info",
|
||||
title: "Info",
|
||||
order: 100,
|
||||
render: (context) => html`
|
||||
<section class="toolbar"><strong>Info</strong></section>
|
||||
<section class="viewer">
|
||||
<p><strong>Workspace</strong></p>
|
||||
<p class="muted">${context.workspace.label}</p>
|
||||
<p class="muted">${context.workspace.path}</p>
|
||||
</section>
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { html } from "lit";
|
||||
import type { PiWebPlugin } from "./types";
|
||||
import type { PiWebPlugin, PiWebPluginRegistration } from "./types";
|
||||
|
||||
interface PluginManifestEntry {
|
||||
id: string;
|
||||
module: string;
|
||||
}
|
||||
|
||||
@@ -9,33 +9,22 @@ interface PluginManifest {
|
||||
plugins: PluginManifestEntry[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
piWebPluginApi?: {
|
||||
apiVersion: 1;
|
||||
html: typeof html;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json"): Promise<PiWebPlugin[]> {
|
||||
window.piWebPluginApi = { apiVersion: 1, html };
|
||||
|
||||
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json"): Promise<PiWebPluginRegistration[]> {
|
||||
const manifest = await fetchPluginManifest(manifestUrl);
|
||||
if (manifest === undefined) return [];
|
||||
|
||||
const plugins: PiWebPlugin[] = [];
|
||||
const registrations: PiWebPluginRegistration[] = [];
|
||||
for (const entry of manifest.plugins) {
|
||||
try {
|
||||
const moduleUrl = new URL(entry.module, new URL(manifestUrl, window.location.href)).toString();
|
||||
const module: unknown = await import(/* @vite-ignore */ moduleUrl);
|
||||
const plugin = parsePluginModule(module, moduleUrl);
|
||||
if (plugin !== undefined) plugins.push(plugin);
|
||||
registrations.push({ id: entry.id, plugin });
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load Pi Web plugin ${entry.module}`, error);
|
||||
}
|
||||
}
|
||||
return plugins;
|
||||
return registrations;
|
||||
}
|
||||
|
||||
async function fetchPluginManifest(manifestUrl: string): Promise<PluginManifest | undefined> {
|
||||
@@ -49,13 +38,13 @@ function parseManifest(value: unknown): PluginManifest {
|
||||
if (!isRecord(value) || !Array.isArray(value["plugins"])) throw new Error("Invalid plugin manifest");
|
||||
return {
|
||||
plugins: value["plugins"].map((entry) => {
|
||||
if (!isRecord(entry) || typeof entry["module"] !== "string" || entry["module"] === "") throw new Error("Invalid plugin manifest entry");
|
||||
return { module: entry["module"] };
|
||||
if (!isRecord(entry) || typeof entry["id"] !== "string" || entry["id"] === "" || typeof entry["module"] !== "string" || entry["module"] === "") throw new Error("Invalid plugin manifest entry");
|
||||
return { id: entry["id"], module: entry["module"] };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin | undefined {
|
||||
function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin {
|
||||
if (!isRecord(module)) throw new Error(`Plugin module ${moduleUrl} did not export an object`);
|
||||
const plugin = module["default"];
|
||||
if (!isPiWebPlugin(plugin)) throw new Error(`Plugin module ${moduleUrl} default export is not a PiWebPlugin`);
|
||||
@@ -63,7 +52,7 @@ function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin | un
|
||||
}
|
||||
|
||||
function isPiWebPlugin(value: unknown): value is PiWebPlugin {
|
||||
return isRecord(value) && typeof value["id"] === "string" && typeof value["name"] === "string" && typeof value["activate"] === "function";
|
||||
return isRecord(value) && value["apiVersion"] === 1 && typeof value["name"] === "string" && typeof value["activate"] === "function";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -28,7 +28,7 @@ function createContext(statePatch: Partial<AppState> = {}) {
|
||||
describe("PluginRegistry", () => {
|
||||
it("namespaces contribution ids with the owning plugin id", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register(corePlugin);
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
|
||||
expect(registry.getActions(createContext().context).some((action) => action.id === "core:actions.show")).toBe(true);
|
||||
expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git", "core:workspace.terminal"]);
|
||||
@@ -40,20 +40,25 @@ describe("PluginRegistry", () => {
|
||||
expect(() => {
|
||||
registry.register({
|
||||
id: "example",
|
||||
name: "Example",
|
||||
activate: () => ({
|
||||
actions: [
|
||||
{ id: "duplicate", title: "One", run: () => undefined },
|
||||
{ id: "duplicate", title: "Two", run: () => undefined },
|
||||
],
|
||||
}),
|
||||
plugin: {
|
||||
apiVersion: 1,
|
||||
name: "Example",
|
||||
activate: () => ({
|
||||
contributions: {
|
||||
actions: [
|
||||
{ id: "duplicate", title: "One", run: () => undefined },
|
||||
{ id: "duplicate", title: "Two", run: () => undefined },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
}).toThrow("Duplicate contribution id: example:duplicate");
|
||||
});
|
||||
|
||||
it("evaluates core action enablement against runtime state", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register(corePlugin);
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
|
||||
const inactive = registry.getActions(createContext().context);
|
||||
const active = registry.getActions(createContext({ selectedWorkspace: testWorkspace() }).context);
|
||||
@@ -64,7 +69,7 @@ describe("PluginRegistry", () => {
|
||||
|
||||
it("routes refresh current to the active core workspace panel", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register(corePlugin);
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
const { context, calls } = createContext({
|
||||
selectedWorkspace: testWorkspace(),
|
||||
workspaceTool: "core:workspace.git",
|
||||
@@ -81,14 +86,19 @@ describe("PluginRegistry", () => {
|
||||
const workspace = testWorkspace();
|
||||
registry.register({
|
||||
id: "example",
|
||||
name: "Example",
|
||||
activate: () => ({
|
||||
workspaceLabelContributions: [
|
||||
{ id: "last", order: 20, items: () => ({ type: "text", text: "last" }) },
|
||||
{ id: "hidden", order: 5, visible: () => false, items: () => ({ type: "text", text: "hidden" }) },
|
||||
{ id: "first", order: 10, items: () => [{ type: "link", text: "web", href: "http://localhost:5173" }] },
|
||||
],
|
||||
}),
|
||||
plugin: {
|
||||
apiVersion: 1,
|
||||
name: "Example",
|
||||
activate: () => ({
|
||||
contributions: {
|
||||
workspaceLabels: [
|
||||
{ id: "last", order: 20, items: () => [{ type: "text", text: "last" }] },
|
||||
{ id: "hidden", order: 5, visible: () => false, items: () => [{ type: "text", text: "hidden" }] },
|
||||
{ id: "first", order: 10, items: () => [{ type: "link", text: "web", href: "http://localhost:5173" }] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.getWorkspaceLabelItems(initialAppState(), workspace)).toEqual([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html } from "lit";
|
||||
import type { AppState } from "../appState";
|
||||
import type { Workspace } from "../api";
|
||||
import type { PiWebPlugin, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContribution } from "./types";
|
||||
import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContribution } from "./types";
|
||||
|
||||
const idPattern = /^[a-z][a-z0-9.-]*$/u;
|
||||
const localIdPattern = /^[a-z][a-z0-9.-]*$/u;
|
||||
@@ -14,24 +15,28 @@ type RegisteredPluginAction = Omit<PluginAction, "id"> & {
|
||||
export class PluginRegistry {
|
||||
private readonly actions: RegisteredPluginAction[] = [];
|
||||
private readonly workspacePanels: QualifiedWorkspacePanelContribution[] = [];
|
||||
private readonly workspaceLabelContributions: QualifiedWorkspaceLabelContribution[] = [];
|
||||
private readonly workspaceLabels: QualifiedWorkspaceLabelContribution[] = [];
|
||||
private readonly pluginIds = new Set<string>();
|
||||
private readonly contributionIds = new Set<QualifiedContributionId>();
|
||||
|
||||
register(plugin: PiWebPlugin): void {
|
||||
this.validatePluginId(plugin.id);
|
||||
if (this.pluginIds.has(plugin.id)) throw new Error(`Duplicate plugin id: ${plugin.id}`);
|
||||
this.pluginIds.add(plugin.id);
|
||||
register(registration: PiWebPluginRegistration): void {
|
||||
const { id, plugin } = registration;
|
||||
this.validatePluginId(id);
|
||||
if (this.pluginIds.has(id)) throw new Error(`Duplicate plugin id: ${id}`);
|
||||
this.pluginIds.add(id);
|
||||
|
||||
const contributions = plugin.activate({ apiVersion: 1 });
|
||||
for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(plugin.id, action));
|
||||
for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(plugin.id, panel));
|
||||
for (const contribution of contributions.workspaceLabelContributions ?? []) this.workspaceLabelContributions.push(this.qualifyWorkspaceLabelContribution(plugin.id, contribution));
|
||||
const apiVersion: unknown = plugin.apiVersion;
|
||||
if (apiVersion !== 1) throw new Error(`Unsupported plugin API version for ${id}: ${String(apiVersion)}`);
|
||||
const result = plugin.activate({ apiVersion: 1, pluginId: id, html });
|
||||
const contributions = result.contributions;
|
||||
for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action));
|
||||
for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel));
|
||||
for (const contribution of contributions.workspaceLabels ?? []) this.workspaceLabels.push(this.qualifyWorkspaceLabelContribution(id, contribution));
|
||||
}
|
||||
|
||||
getActions(context: PluginRuntimeContext): QualifiedPluginAction[] {
|
||||
return this.actions.map((action) => {
|
||||
const enabled = typeof action.enabled === "function" ? action.enabled(context) : action.enabled;
|
||||
const enabled = action.enabled?.(context);
|
||||
const qualified: QualifiedPluginAction = {
|
||||
id: action.id,
|
||||
pluginId: action.pluginId,
|
||||
@@ -53,13 +58,11 @@ export class PluginRegistry {
|
||||
|
||||
getWorkspaceLabelItems(state: AppState, workspace: Workspace): WorkspaceLabelItem[] {
|
||||
const context = { state, workspace };
|
||||
return [...this.workspaceLabelContributions]
|
||||
return [...this.workspaceLabels]
|
||||
.sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.id.localeCompare(right.id))
|
||||
.flatMap((contribution) => {
|
||||
if (contribution.visible?.(context) === false) return [];
|
||||
const items = contribution.items(context);
|
||||
if (items === undefined) return [];
|
||||
return Array.isArray(items) ? items : [items];
|
||||
return contribution.items(context);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,21 +6,33 @@ import type { AppState } from "../appState";
|
||||
export type PluginId = string;
|
||||
export type LocalContributionId = string;
|
||||
export type QualifiedContributionId = `${PluginId}:${LocalContributionId}`;
|
||||
export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult;
|
||||
|
||||
export interface PiWebPluginRegistration {
|
||||
id: PluginId;
|
||||
plugin: PiWebPlugin;
|
||||
}
|
||||
|
||||
export interface PiWebPlugin {
|
||||
id: PluginId;
|
||||
apiVersion: 1;
|
||||
name: string;
|
||||
activate: (context: PluginActivationContext) => PluginContributions;
|
||||
activate: (context: PluginActivationContext) => PluginActivationResult;
|
||||
}
|
||||
|
||||
export interface PluginActivationContext {
|
||||
apiVersion: 1;
|
||||
pluginId: PluginId;
|
||||
html: HtmlTemplateTag;
|
||||
}
|
||||
|
||||
export interface PluginActivationResult {
|
||||
contributions: PluginContributions;
|
||||
}
|
||||
|
||||
export interface PluginContributions {
|
||||
actions?: PluginAction[];
|
||||
workspacePanels?: WorkspacePanelContribution[];
|
||||
workspaceLabelContributions?: WorkspaceLabelContribution[];
|
||||
workspaceLabels?: WorkspaceLabelContribution[];
|
||||
}
|
||||
|
||||
export interface PluginRuntimeContext {
|
||||
@@ -45,7 +57,7 @@ export interface PluginAction {
|
||||
description?: string;
|
||||
shortcut?: string;
|
||||
group?: string;
|
||||
enabled?: boolean | ((context: PluginRuntimeContext) => boolean);
|
||||
enabled?: (context: PluginRuntimeContext) => boolean;
|
||||
run: (context: PluginRuntimeContext) => void | Promise<void>;
|
||||
}
|
||||
|
||||
@@ -54,6 +66,11 @@ export interface QualifiedPluginAction extends AppAction {
|
||||
localId: LocalContributionId;
|
||||
}
|
||||
|
||||
export interface WorkspacePanelVisibilityContext {
|
||||
workspace: Workspace;
|
||||
state: AppState;
|
||||
}
|
||||
|
||||
export interface WorkspacePanelContext {
|
||||
workspace: Workspace;
|
||||
fileTree: FileTreeEntry[];
|
||||
@@ -79,7 +96,7 @@ export interface WorkspacePanelContribution {
|
||||
id: LocalContributionId;
|
||||
title: string;
|
||||
order?: number;
|
||||
visible?: (workspace: Workspace) => boolean;
|
||||
visible?: (context: WorkspacePanelVisibilityContext) => boolean;
|
||||
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
|
||||
render: (context: WorkspacePanelContext) => TemplateResult;
|
||||
}
|
||||
@@ -120,7 +137,7 @@ export interface WorkspaceLabelContribution {
|
||||
id: LocalContributionId;
|
||||
order?: number;
|
||||
visible?: (context: WorkspaceLabelContext) => boolean;
|
||||
items: (context: WorkspaceLabelContext) => WorkspaceLabelItem | WorkspaceLabelItem[] | undefined;
|
||||
items: (context: WorkspaceLabelContext) => WorkspaceLabelItem[];
|
||||
}
|
||||
|
||||
export interface QualifiedWorkspaceLabelContribution extends WorkspaceLabelContribution {
|
||||
|
||||
@@ -18,8 +18,8 @@ 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' };" },
|
||||
packageJson: { piWeb: { plugins: [{ id: "info", module: "pi-web-plugin.js" }] } },
|
||||
files: { "pi-web-plugin.js": "export default { apiVersion: 1, name: 'Info', activate: () => ({ contributions: {} }) };" },
|
||||
});
|
||||
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||
@@ -38,8 +38,8 @@ describe("PiWebPluginService", () => {
|
||||
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' };" },
|
||||
packageJson: { piWeb: { plugins: [{ id: "review", module: "dist/review.js" }] } },
|
||||
files: { "dist/review.js": "export default { apiVersion: 1, name: 'Review', activate: () => ({ contributions: {} }) };" },
|
||||
});
|
||||
const packageProvider: PiPackageProvider = {
|
||||
listPackages: () => [{ source: "npm:@acme/review", scope: "user", installedPath: packageDir }],
|
||||
@@ -57,8 +57,8 @@ describe("PiWebPluginService", () => {
|
||||
it("discovers local plugins through symlinks for development", async () => {
|
||||
const pluginDir = join(tempDir, "dev-plugin");
|
||||
await writePlugin(pluginDir, {
|
||||
packageJson: { piWeb: { id: "dev", plugin: "pi-web-plugin.js" } },
|
||||
files: { "pi-web-plugin.js": "export default { id: 'dev' };" },
|
||||
packageJson: { piWeb: { plugins: [{ id: "dev", module: "pi-web-plugin.js" }] } },
|
||||
files: { "pi-web-plugin.js": "export default { apiVersion: 1, name: 'Dev', activate: () => ({ contributions: {} }) };" },
|
||||
});
|
||||
await mkdir(join(tempDir, "plugins"), { recursive: true });
|
||||
await symlink(pluginDir, join(tempDir, "plugins", "dev"), "dir");
|
||||
@@ -71,27 +71,58 @@ describe("PiWebPluginService", () => {
|
||||
await expect(service.readAsset("dev", "pi-web-plugin.js")).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps duplicate plugin ids addressable", async () => {
|
||||
it("skips duplicate plugin ids", async () => {
|
||||
await writePlugin(join(tempDir, "plugins", "one"), {
|
||||
packageJson: { piWeb: { id: "duplicate", plugin: "pi-web-plugin.js" } },
|
||||
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "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" } },
|
||||
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "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();
|
||||
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate"]);
|
||||
});
|
||||
|
||||
it("rejects unsafe plugin entries and asset traversal", async () => {
|
||||
it("skips legacy metadata shortcuts and unsafe module paths", async () => {
|
||||
const legacyRoot = join(tempDir, "legacy-root");
|
||||
await writePlugin(join(legacyRoot, "legacy"), {
|
||||
packageJson: { piWeb: { id: "legacy", plugin: "pi-web-plugin.js" } },
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
const unsafeRoot = join(tempDir, "unsafe-root");
|
||||
await writePlugin(join(unsafeRoot, "unsafe"), {
|
||||
packageJson: { piWeb: { plugins: [{ id: "unsafe", module: "../escape.js" }] } },
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
|
||||
await expect(new PiWebPluginService({ roots: [{ path: legacyRoot, source: "test", scope: "local" }], packageProvider: false }).manifest()).resolves.toEqual({ plugins: [] });
|
||||
await expect(new PiWebPluginService({ roots: [{ path: unsafeRoot, source: "test", scope: "local" }], packageProvider: false }).manifest()).resolves.toEqual({ plugins: [] });
|
||||
});
|
||||
|
||||
it("continues discovering valid plugins when another local plugin is invalid", async () => {
|
||||
await writePlugin(join(tempDir, "plugins", "valid"), {
|
||||
packageJson: { piWeb: { plugins: [{ id: "valid", module: "pi-web-plugin.js" }] } },
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
await writePlugin(join(tempDir, "plugins", "legacy"), {
|
||||
packageJson: { piWeb: { id: "legacy", 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(["valid"]);
|
||||
});
|
||||
|
||||
it("rejects unsafe asset traversal", async () => {
|
||||
const pluginDir = join(tempDir, "plugins", "safe");
|
||||
await writePlugin(pluginDir, {
|
||||
packageJson: { piWeb: { id: "safe", plugins: ["../escape.js", "pi-web-plugin.js"] } },
|
||||
packageJson: { piWeb: { plugins: [{ id: "safe", module: "pi-web-plugin.js" }] } },
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
await writeFile(join(tempDir, "plugins", "escape.js"), "nope");
|
||||
@@ -100,7 +131,6 @@ describe("PiWebPluginService", () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
||||
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
||||
import { 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; source: string; scope: PiWebPluginScope }[];
|
||||
@@ -48,13 +47,12 @@ interface LocalPluginRoot {
|
||||
}
|
||||
|
||||
interface PiWebPackageConfig {
|
||||
id?: string;
|
||||
plugins: PiWebPluginEntry[];
|
||||
}
|
||||
|
||||
interface PiWebPluginEntry {
|
||||
id?: string;
|
||||
path: string;
|
||||
id: string;
|
||||
module: string;
|
||||
}
|
||||
|
||||
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
|
||||
@@ -140,7 +138,11 @@ export class PiWebPluginService {
|
||||
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));
|
||||
try {
|
||||
plugins.push(...await discoverPackageRoot(root, configuredPackage));
|
||||
} catch (error) {
|
||||
warnInvalidPlugin(configuredPackage.source, error);
|
||||
}
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
@@ -163,93 +165,82 @@ async function discoverLocalRoot(root: LocalPluginRoot): Promise<PluginRecord[]>
|
||||
const pluginRoot = join(root.path, entry.name);
|
||||
const pluginStat = entry.isDirectory() ? undefined : entry.isSymbolicLink() ? await stat(pluginRoot).catch(() => undefined) : undefined;
|
||||
if (!entry.isDirectory() && pluginStat?.isDirectory() !== true) continue;
|
||||
plugins.push(...await discoverLocalPlugin(pluginRoot, entry.name, root));
|
||||
try {
|
||||
plugins.push(...await discoverLocalPlugin(pluginRoot, root));
|
||||
} catch (error) {
|
||||
warnInvalidPlugin(pluginRoot, error);
|
||||
}
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
|
||||
async function discoverLocalPlugin(root: string, fallbackId: string, localRoot: LocalPluginRoot): Promise<PluginRecord[]> {
|
||||
const config = await readPiWebPackageConfig(root) ?? { plugins: [{ path: defaultEntryFile }] };
|
||||
const plugins = await discoverPluginEntries(root, config, fallbackId);
|
||||
async function discoverLocalPlugin(root: string, localRoot: LocalPluginRoot): Promise<PluginRecord[]> {
|
||||
const config = await readPiWebPackageConfig(root);
|
||||
if (config === undefined) return [];
|
||||
const plugins = await discoverPluginEntries(root, config);
|
||||
return plugins.map((plugin) => ({ ...plugin, source: localRoot.source, scope: localRoot.scope }));
|
||||
}
|
||||
|
||||
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);
|
||||
const plugins = await discoverPluginEntries(root, config);
|
||||
return plugins.map((plugin) => ({ ...plugin, source: configuredPackage.source, scope: configuredPackage.scope }));
|
||||
}
|
||||
|
||||
async function discoverPluginEntries(root: string, config: PiWebPackageConfig, fallbackId: string): Promise<ArraylessPluginRecord[]> {
|
||||
async function discoverPluginEntries(root: string, config: PiWebPackageConfig): Promise<ArraylessPluginRecord[]> {
|
||||
const plugins: ArraylessPluginRecord[] = [];
|
||||
for (const [index, entry] of config.plugins.entries()) {
|
||||
if (!isSafeRelativePath(entry.path)) continue;
|
||||
const entryPath = join(root, entry.path);
|
||||
for (const entry of config.plugins) {
|
||||
if (!isSafeRelativePath(entry.module)) throw new Error(`Unsafe Pi Web plugin module path for ${entry.id}: ${entry.module}`);
|
||||
const entryPath = join(root, entry.module);
|
||||
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)) });
|
||||
if (entryStat?.isFile() !== true) throw new Error(`Pi Web plugin module not found for ${entry.id}: ${entry.module}`);
|
||||
plugins.push({ id: entry.id, root, entryFile: entry.module, 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 content = await readFile(packagePath, "utf8").catch(() => undefined);
|
||||
if (content === undefined) return undefined;
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (!isRecord(parsed)) return undefined;
|
||||
const pi = parsed["pi"];
|
||||
const piWeb = isRecord(parsed["piWeb"]) ? parsed["piWeb"] : isRecord(pi) && isRecord(pi["piWeb"]) ? pi["piWeb"] : undefined;
|
||||
const piWeb = parsed["piWeb"];
|
||||
if (!isRecord(piWeb)) return undefined;
|
||||
|
||||
const plugins = parsePluginEntries(piWeb);
|
||||
const plugins = parsePluginEntries(piWeb, packagePath);
|
||||
if (plugins.length === 0) return undefined;
|
||||
return {
|
||||
...(typeof piWeb["id"] === "string" ? { id: piWeb["id"] } : {}),
|
||||
plugins,
|
||||
};
|
||||
return { plugins };
|
||||
}
|
||||
|
||||
function parsePluginEntries(piWeb: Record<string, unknown>): PiWebPluginEntry[] {
|
||||
const plugin = piWeb["plugin"];
|
||||
if (typeof plugin === "string") return [{ path: plugin }];
|
||||
function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string): PiWebPluginEntry[] {
|
||||
if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported Pi Web plugin metadata in ${packagePath}: use piWeb.plugins with { id, module } entries`);
|
||||
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"] } : {}) }];
|
||||
if (plugins === undefined) return [];
|
||||
if (!Array.isArray(plugins)) throw new Error(`Pi Web plugins must be an array in ${packagePath}`);
|
||||
|
||||
return plugins.map((entry, index): PiWebPluginEntry => {
|
||||
if (!isRecord(entry)) throw new Error(`Pi Web plugin entry ${String(index + 1)} must be an object in ${packagePath}`);
|
||||
const id = entry["id"];
|
||||
const module = entry["module"];
|
||||
if (typeof id !== "string" || !pluginIdPattern.test(id)) throw new Error(`Invalid Pi Web plugin id in ${packagePath}: ${String(id)}`);
|
||||
if (typeof module !== "string" || module === "") throw new Error(`Invalid Pi Web plugin module for ${id} in ${packagePath}`);
|
||||
return { id, module };
|
||||
});
|
||||
}
|
||||
|
||||
function addUnique(records: Map<string, PluginRecord>, plugin: PluginRecord): void {
|
||||
if (!records.has(plugin.id)) {
|
||||
records.set(plugin.id, plugin);
|
||||
if (records.has(plugin.id)) {
|
||||
warnInvalidPlugin(plugin.source, `Duplicate Pi Web plugin id: ${plugin.id}`);
|
||||
return;
|
||||
}
|
||||
for (let index = 2; ; index += 1) {
|
||||
const id = sanitizePluginId(`${plugin.id}.${String(index)}`);
|
||||
if (!records.has(id)) {
|
||||
records.set(id, { ...plugin, id });
|
||||
return;
|
||||
}
|
||||
}
|
||||
records.set(plugin.id, plugin);
|
||||
}
|
||||
|
||||
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 warnInvalidPlugin(source: string, error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Skipping Pi Web plugin from ${source}: ${message}`);
|
||||
}
|
||||
|
||||
function isSafeRelativePath(path: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user