feat: support machine-specific plugins

This commit is contained in:
Federico Jaramillo Martinez
2026-06-09 15:10:32 +02:00
parent 0118e6ebe9
commit c57f24dfa5
22 changed files with 286 additions and 47 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Allow PI WEB plugins to mark themselves as machine-specific so the gateway copy stays local-only and remote machines can provide their own status/plugin UI.
+1 -1
View File
@@ -112,7 +112,7 @@ Remote model-provider credentials and OAuth state stay on the target machine. AP
PI WEB production installs can load trusted local UI plugins without rebuilding PI WEB. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata, using documented context helpers for workspace files and terminals. They do not run in the session daemon and are not sandboxed. PI WEB production installs can load trusted local UI plugins without rebuilding PI WEB. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata, using documented context helpers for workspace files and terminals. They do not run in the session daemon and are not sandboxed.
The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module`, plus a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` TypeScript source is the canonical minimal real example, `pi-web-plugins/updates` demonstrates a dynamic status panel, and built-in [Workspace Tasks](docs/plugins.md#workspace-tasks) adds a workspace tab for running configured shell commands in PI WEB terminals. The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module` plus optional `machineSpecific` metadata, and a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` TypeScript source is the canonical minimal real example, `pi-web-plugins/updates` demonstrates a dynamic status panel, and built-in [Workspace Tasks](docs/plugins.md#workspace-tasks) adds a workspace tab for running configured shell commands in PI WEB terminals.
A useful prompt for AI agents: A useful prompt for AI agents:
+8 -2
View File
@@ -191,7 +191,8 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
<h3>Updates</h3> <h3>Updates</h3>
<p> <p>
<strong>Updates</strong> adds a conditional <strong>Updates</strong> workspace tab with PI WEB update, <strong>Updates</strong> adds a conditional <strong>Updates</strong> workspace tab with PI WEB update,
restart, and installed-service guidance. It is built into PI WEB and enabled by default. restart, and installed-service guidance. It is built into PI WEB, enabled by default, and uses the
selected machine's plugin copy when machine federation is active.
</p> </p>
<ul> <ul>
<li>Plugin id: <code>updates</code></li> <li>Plugin id: <code>updates</code></li>
@@ -309,7 +310,7 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
<ul> <ul>
<li>File and terminal helpers run against the selected remote machine.</li> <li>File and terminal helpers run against the selected remote machine.</li>
<li>Remote plugin code is loaded best-effort through the current gateway and cached for the page lifetime.</li> <li>Remote plugin code is loaded best-effort through the current gateway and cached for the page lifetime.</li>
<li>If the gateway already has an enabled plugin with the same original id, the gateway plugin wins and the remote duplicate stays hidden.</li> <li>If the gateway and remote machine both have an enabled plugin with the same original id, <code>machineSpecific</code> metadata decides whether the gateway copy is reused or only the selected-machine copy can appear.</li>
<li>Remote theme contributions are ignored for now because themes are app-wide.</li> <li>Remote theme contributions are ignored for now because themes are app-wide.</li>
<li>Mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible.</li> <li>Mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible.</li>
</ul> </ul>
@@ -317,6 +318,11 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable
one, open that machine directly or update its config file. one, open that machine directly or update its config file.
</p> </p>
<p>
Plugin package metadata can set <code>machineSpecific: true</code>. Use it for plugins like Updates whose
UI should come from the selected PI WEB instance; on remote machines, the gateway copy is hidden unless
the remote machine exposes its own copy.
</p>
<p> <p>
For portable plugin assets, prefer URLs relative to the plugin module, such as For portable plugin assets, prefer URLs relative to the plugin module, such as
<code>new URL("./asset.json", import.meta.url)</code>. If a remote plugin constructs absolute asset URLs, <code>new URL("./asset.json", import.meta.url)</code>. If a remote plugin constructs absolute asset URLs,
+14 -7
View File
@@ -136,12 +136,17 @@ When [machine federation](https://pi-web.dev/machines.html) is enabled, PI WEB a
- actions, workspace panels, and workspace labels only appear while that machine is selected; - actions, workspace panels, and workspace labels only appear while that machine is selected;
- plugin file and terminal helpers run against that machine; - plugin file and terminal helpers run against that machine;
- plugin code is loaded best-effort through the current gateway and cached for the browser page lifetime; - plugin code is loaded best-effort through the current gateway and cached for the browser page lifetime;
- if the gateway already has an enabled plugin with the same original id, the gateway plugin wins and the remote duplicate stays hidden; - if the gateway and remote machine both have an enabled plugin with the same original id, `machineSpecific` metadata decides whether the gateway copy is reused or only the selected machine's copy can appear;
- remote theme contributions are ignored for now because themes are app-wide; - remote theme contributions are ignored for now because themes are app-wide;
- mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible. - mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible.
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable a remote machine plugin, open that machine directly or update its config file. Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable a remote machine plugin, open that machine directly or update its config file.
Plugin package metadata may set `machineSpecific: true` when the plugin's meaning is tied to the selected PI WEB machine:
- Omitted or `false`: use the gateway copy when the same plugin id is also present on a remote machine. This is best for portable UI plugins whose helpers already route through the selected machine.
- `true`: the gateway copy only appears for the local machine. When a remote machine is selected, only that remote machine's copy can appear; if the remote machine does not expose the plugin, the plugin is hidden. This is best for plugins that report machine-local PI WEB status or depend on machine-local plugin code.
For portable plugin assets, prefer URLs relative to the plugin module, for example: For portable plugin assets, prefer URLs relative to the plugin module, for example:
```js ```js
@@ -185,7 +190,7 @@ Built-in plugins can be managed from **Settings → Plugins** or with the top-le
**Plugin id:** `updates` **Plugin id:** `updates`
**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance. **What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance.
Updates is enabled by default. To hide it, disable `updates` in **Settings → Plugins** or set: Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab only appears for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → Plugins** or set:
```json ```json
{ {
@@ -286,7 +291,7 @@ A package can expose one or more PI WEB plugin modules. There is exactly one sup
"piWeb": { "piWeb": {
"plugins": [ "plugins": [
{ "id": "review", "module": "dist/review.js" }, { "id": "review", "module": "dist/review.js" },
{ "id": "dashboard", "module": "dist/dashboard.js" } { "id": "dashboard", "module": "dist/dashboard.js", "machineSpecific": true }
] ]
} }
} }
@@ -298,6 +303,7 @@ Rules:
- Each entry must have an explicit `id` and `module`. - Each entry must have an explicit `id` and `module`.
- `id` must match `^[a-z][a-z0-9.-]*$`. - `id` must match `^[a-z][a-z0-9.-]*$`.
- `module` must be a safe relative path inside the plugin package root. - `module` must be a safe relative path inside the plugin package root.
- `machineSpecific` is optional and must be a boolean; omit it for the default portable gateway behavior.
- Duplicate plugin ids are not auto-renamed; later duplicates are skipped. - Duplicate plugin ids are not auto-renamed; later duplicates are skipped.
- Legacy shortcuts such as `piWeb.plugin`, string entries in `piWeb.plugins`, `piWeb.id` fallback ids, and no-`package.json` fallbacks are not supported. - Legacy shortcuts such as `piWeb.plugin`, string entries in `piWeb.plugins`, `piWeb.id` fallback ids, and no-`package.json` fallbacks are not supported.
@@ -312,13 +318,14 @@ The manifest contains each discovered plugin module:
"id": "my-plugin", "id": "my-plugin",
"module": "/pi-web-plugins/my-plugin/pi-web-plugin.js?v=1234567890", "module": "/pi-web-plugins/my-plugin/pi-web-plugin.js?v=1234567890",
"source": "local", "source": "local",
"scope": "local" "scope": "local",
"machineSpecific": false
} }
] ]
} }
``` ```
`source` describes where the plugin came from (`bundled`, `local`, or the Pi package source). `scope` is `bundled`, `local`, `user`, or `project`. `source` describes where the plugin came from (`bundled`, `local`, or the Pi package source). `scope` is `bundled`, `local`, `user`, or `project`. `machineSpecific` controls whether the gateway copy is valid for remote machines or only each selected machine's own copy can appear.
A plugin can fetch its own static assets with URLs under: A plugin can fetch its own static assets with URLs under:
@@ -450,7 +457,7 @@ interface PluginRuntimeContext {
Notes: Notes:
- `state` is a snapshot of current UI state when actions are built. - `state` is a snapshot of current UI state when actions are built.
- The stable state fields are `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`. - The stable state fields are `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`. `state.piWebStatus` describes the currently selected machine's PI WEB runtime, or the gateway/local runtime when the local machine is selected.
- Other `state` fields may exist at runtime, but they are private PI WEB internals that may graduate into stable helpers, change shape, or disappear. - Other `state` fields may exist at runtime, but they are private PI WEB internals that may graduate into stable helpers, change shape, or disappear.
- `enabled` is evaluated when the action palette asks for actions. - `enabled` is evaluated when the action palette asks for actions.
- `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`. - `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`.
@@ -784,7 +791,7 @@ PI WEB does not provide a plugin cache/invalidation framework. Keep host callbac
If you are an AI agent building or editing a PI WEB plugin, follow this checklist: If you are an AI agent building or editing a PI WEB plugin, follow this checklist:
1. Create or update a plugin folder with `package.json` and a JavaScript module such as `pi-web-plugin.js`. 1. Create or update a plugin folder with `package.json` and a JavaScript module such as `pi-web-plugin.js`.
2. Use the single supported package metadata shape: `piWeb.plugins` array with `{ id, module }` entries. 2. Use the single supported package metadata shape: `piWeb.plugins` array with `{ id, module, machineSpecific? }` entries.
3. Default-export `{ apiVersion: 1, name, activate }` from the module. 3. Default-export `{ apiVersion: 1, name, activate }` from the module.
4. Return `{ contributions: { actions, workspacePanels, workspaceLabels } }` from `activate()`. 4. Return `{ contributions: { actions, workspacePanels, workspaceLabels } }` from `activate()`.
5. Use ids matching `^[a-z][a-z0-9.-]*$`. 5. Use ids matching `^[a-z][a-z0-9.-]*$`.
+1 -1
View File
@@ -3,7 +3,7 @@
"private": true, "private": true,
"piWeb": { "piWeb": {
"plugins": [ "plugins": [
{ "id": "updates", "module": "pi-web-plugin.js" } { "id": "updates", "module": "pi-web-plugin.js", "machineSpecific": true }
] ]
} }
} }
+20 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes"; import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
import { machinesApi, terminalsApi, workspacesApi } from "./clients"; import { machinesApi, piWebApi, terminalsApi, workspacesApi } from "./clients";
const workspace: Workspace = { const workspace: Workspace = {
id: "w/1", id: "w/1",
@@ -31,6 +31,25 @@ afterEach(() => {
}); });
describe("machine-scoped runtime API", () => { describe("machine-scoped runtime API", () => {
it("reads machine PI WEB status through the gateway route", async () => {
const fetchMock = stubJsonFetch({
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "PI WEB", available: true, stale: false },
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: {},
messages: [],
});
await piWebApi.piWebStatus("remote a");
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status");
});
it("reads machine runtime through the gateway route", async () => { it("reads machine runtime through the gateway route", async () => {
const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }); const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
+1 -1
View File
@@ -43,7 +43,7 @@ import { machineGitDiffUrl, messageUrl } from "./urls";
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`; const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`;
export const piWebApi = { export const piWebApi = {
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse), piWebStatus: (machineId = "local") => request(machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`, parsePiWebStatusResponse),
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse), piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse),
}; };
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { Workspace } from "../../../shared/apiTypes"; import type { Workspace } from "../../../shared/apiTypes";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes"; import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
import { activityApi, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; import { activityApi, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets"; import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
import { workspaceImagePreviewUrl } from "./urls"; import { workspaceImagePreviewUrl } from "./urls";
@@ -26,6 +26,7 @@ describe("federated route contract", () => {
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
await Promise.all([ await Promise.all([
ignoreParseFailure(piWebApi.piWebStatus(machineId)),
ignoreParseFailure(activityApi.workspaceActivity(machineId)), ignoreParseFailure(activityApi.workspaceActivity(machineId)),
ignoreParseFailure(projectsApi.projects(machineId)), ignoreParseFailure(projectsApi.projects(machineId)),
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)), ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
+2 -2
View File
@@ -33,9 +33,9 @@ describe("API parsers", () => {
it("parses PI WEB plugin status responses", () => { it("parses PI WEB plugin status responses", () => {
expect(parsePiWebPluginsResponse({ expect(parsePiWebPluginsResponse({
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }], plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }],
})).toEqual({ })).toEqual({
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }], plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }],
}); });
}); });
+7
View File
@@ -492,6 +492,7 @@ function parsePiWebPluginInfo(value: unknown): PiWebPluginInfo {
module: requireString(record, "module"), module: requireString(record, "module"),
source: requireString(record, "source"), source: requireString(record, "source"),
scope: parsePiWebPluginScope(record["scope"]), scope: parsePiWebPluginScope(record["scope"]),
machineSpecific: parseOptionalBoolean(record["machineSpecific"], "machineSpecific") ?? false,
enabled: requireBoolean(record, "enabled"), enabled: requireBoolean(record, "enabled"),
}; };
} }
@@ -501,6 +502,12 @@ function parsePiWebPluginScope(value: unknown): PiWebPluginScope {
return value; return value;
} }
function parseOptionalBoolean(value: unknown, key: string): boolean | undefined {
if (value === undefined) return undefined;
if (typeof value !== "boolean") throw new Error(`Expected optional boolean field: ${key}`);
return value;
}
export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse { export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
const record = requireRecord(value); const record = requireRecord(value);
return { return {
+11 -3
View File
@@ -266,10 +266,13 @@ export class PiWebApp extends LitElement {
} }
private async refreshPiWebStatus(): Promise<void> { private async refreshPiWebStatus(): Promise<void> {
const machineId = selectedMachineId(this.state);
try { try {
this.setState({ piWebStatus: await piWebApi.piWebStatus() }); const piWebStatus = await piWebApi.piWebStatus(machineId);
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus });
} catch (error) { } catch (error) {
console.warn("Failed to refresh PI WEB status", error); if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus: undefined });
console.warn(`Failed to refresh PI WEB status for ${machineId}`, error);
} }
} }
@@ -723,7 +726,9 @@ export class PiWebApp extends LitElement {
this.realtime.close(); this.realtime.close();
this.connectRealtime(); this.connectRealtime();
this.activeTerminalIds.clear(); this.activeTerminalIds.clear();
this.setState({ piWebStatus: undefined });
this.git.updatePolling(); this.git.updatePolling();
void this.refreshPiWebStatus();
void this.loadPluginsForSelectedMachine(); void this.loadPluginsForSelectedMachine();
} }
@@ -1186,7 +1191,10 @@ export class PiWebApp extends LitElement {
const existing = this.machinePluginLoadPromises.get(machine.id); const existing = this.machinePluginLoadPromises.get(machine.id);
if (existing !== undefined) return existing; if (existing !== undefined) return existing;
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, { machineId: machine.id })) const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, {
machineId: machine.id,
shouldLoadPlugin: (entry) => this.plugins.shouldLoadRemotePlugin(entry.id, entry.machineSpecific),
}))
.then((loaded) => { if (loaded) this.loadedMachinePluginIds.add(machine.id); }) .then((loaded) => { if (loaded) this.loadedMachinePluginIds.add(machine.id); })
.finally(() => { this.machinePluginLoadPromises.delete(machine.id); }); .finally(() => { this.machinePluginLoadPromises.delete(machine.id); });
this.machinePluginLoadPromises.set(machine.id, load); this.machinePluginLoadPromises.set(machine.id, load);
@@ -46,7 +46,7 @@ export class SettingsPluginsPanel extends LitElement {
<article class=${`plugin-card${plugin.enabled ? "" : " disabled"}`}> <article class=${`plugin-card${plugin.enabled ? "" : " disabled"}`}>
<div class="plugin-main"> <div class="plugin-main">
<strong>${plugin.id}</strong> <strong>${plugin.id}</strong>
<small>${plugin.source} · ${plugin.scope}</small> <small>${plugin.source} · ${plugin.scope}${plugin.machineSpecific ? " · machine-specific" : ""}</small>
<small>${configuredState}</small> <small>${configuredState}</small>
</div> </div>
<label class="toggle"> <label class="toggle">
+12 -2
View File
@@ -1,9 +1,10 @@
import { machineScopedPluginId } from "../../../shared/machinePluginIds"; import { machineScopedPluginId } from "../../../shared/machinePluginIds";
import type { PiWebPlugin, PiWebPluginRegistration } from "./types"; import type { PiWebPlugin, PiWebPluginRegistration } from "./types";
interface PluginManifestEntry { export interface PluginManifestEntry {
id: string; id: string;
module: string; module: string;
machineSpecific: boolean;
} }
interface PluginManifest { interface PluginManifest {
@@ -12,6 +13,7 @@ interface PluginManifest {
export interface LoadExternalPluginsOptions { export interface LoadExternalPluginsOptions {
machineId?: string; machineId?: string;
shouldLoadPlugin?: (entry: PluginManifestEntry) => boolean;
} }
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> { export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> {
@@ -20,6 +22,7 @@ export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifes
const registrations: PiWebPluginRegistration[] = []; const registrations: PiWebPluginRegistration[] = [];
for (const entry of manifest.plugins) { for (const entry of manifest.plugins) {
if (options.shouldLoadPlugin?.(entry) === false) continue;
try { try {
const moduleUrl = new URL(entry.module, new URL(manifestUrl, window.location.href)).toString(); const moduleUrl = new URL(entry.module, new URL(manifestUrl, window.location.href)).toString();
const module: unknown = await import(/* @vite-ignore */ moduleUrl); const module: unknown = await import(/* @vite-ignore */ moduleUrl);
@@ -27,6 +30,7 @@ export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifes
registrations.push({ registrations.push({
id: options.machineId === undefined ? entry.id : machineScopedPluginId(options.machineId, entry.id), id: options.machineId === undefined ? entry.id : machineScopedPluginId(options.machineId, entry.id),
plugin, plugin,
machineSpecific: entry.machineSpecific,
...(options.machineId === undefined ? {} : { machineId: options.machineId, sourcePluginId: entry.id }), ...(options.machineId === undefined ? {} : { machineId: options.machineId, sourcePluginId: entry.id }),
}); });
} catch (error) { } catch (error) {
@@ -48,11 +52,17 @@ function parseManifest(value: unknown): PluginManifest {
return { return {
plugins: value["plugins"].map((entry) => { plugins: value["plugins"].map((entry) => {
if (!isRecord(entry) || typeof entry["id"] !== "string" || entry["id"] === "" || typeof entry["module"] !== "string" || entry["module"] === "") throw new Error("Invalid plugin manifest entry"); 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"] }; return { id: entry["id"], module: entry["module"], machineSpecific: parseMachineSpecific(entry["machineSpecific"]) };
}), }),
}; };
} }
function parseMachineSpecific(value: unknown): boolean {
if (value === undefined) return false;
if (typeof value !== "boolean") throw new Error("Invalid plugin manifest entry");
return value;
}
function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin { function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin {
if (!isRecord(module)) throw new Error(`Plugin module ${moduleUrl} did not export an object`); if (!isRecord(module)) throw new Error(`Plugin module ${moduleUrl} did not export an object`);
const plugin = module["default"]; const plugin = module["default"];
+85
View File
@@ -406,6 +406,91 @@ describe("PluginRegistry", () => {
expect(panels.find((panel) => panel.id === `${remotePluginId}:workspace.remote`)?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(false); expect(panels.find((panel) => panel.id === `${remotePluginId}:workspace.remote`)?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(false);
expect(panels.find((panel) => panel.id === "shared-tools:workspace.gateway")?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true); expect(panels.find((panel) => panel.id === "shared-tools:workspace.gateway")?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true);
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("remote-1", workspace))).toEqual([{ type: "text", text: "gateway" }]); expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("remote-1", workspace))).toEqual([{ type: "text", text: "gateway" }]);
expect(registry.shouldLoadRemotePlugin("shared-tools")).toBe(false);
expect(registry.shouldLoadRemotePlugin("shared-tools", true)).toBe(true);
});
it("uses machine-specific remote duplicates instead of the gateway plugin for that machine", () => {
const registry = new PluginRegistry();
const workspace = testWorkspace();
const remotePluginId = machineScopedPluginId("remote-1", "updates");
registry.register({
id: "updates",
machineSpecific: true,
plugin: {
apiVersion: 1,
name: "Gateway Updates",
activate: () => ({
contributions: {
actions: [{ id: "open", title: "Open Gateway Updates", run: () => undefined }],
workspacePanels: [{ id: "workspace.updates", title: "Gateway Updates", render: () => html`<p>Gateway</p>` }],
workspaceLabels: [{ id: "label", items: () => [{ type: "text", text: "gateway" }] }],
},
}),
},
});
expect(registry.getActions(createContext().context).map((action) => action.id)).toContain("updates:open");
expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).not.toContain("updates:open");
expect(registry.shouldLoadRemotePlugin("updates")).toBe(true);
registry.register({
id: remotePluginId,
machineId: "remote-1",
sourcePluginId: "updates",
plugin: {
apiVersion: 1,
name: "Remote Updates",
activate: () => ({
contributions: {
actions: [{ id: "open", title: "Open Remote Updates", run: () => undefined }],
workspacePanels: [{ id: "workspace.updates", title: "Remote Updates", render: () => html`<p>Remote</p>` }],
workspaceLabels: [{ id: "label", items: () => [{ type: "text", text: "remote" }] }],
},
}),
},
});
expect(registry.getActions(createContext().context).map((action) => action.id)).toContain("updates:open");
expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).toEqual([`${remotePluginId}:open`]);
const panels = registry.getWorkspacePanels();
expect(panels.find((panel) => panel.id === "updates:workspace.updates")?.visible?.(createWorkspacePanelContext("local"))).toBe(true);
expect(panels.find((panel) => panel.id === "updates:workspace.updates")?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(false);
expect(panels.find((panel) => panel.id === `${remotePluginId}:workspace.updates`)?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true);
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("local", workspace))).toEqual([{ type: "text", text: "gateway" }]);
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("remote-1", workspace))).toEqual([{ type: "text", text: "remote" }]);
});
it("allows a machine-specific remote duplicate to override a portable gateway plugin for that machine", () => {
const registry = new PluginRegistry();
const remotePluginId = machineScopedPluginId("remote-1", "status-tools");
registry.register({
id: "status-tools",
plugin: {
apiVersion: 1,
name: "Gateway Status Tools",
activate: () => ({ contributions: { actions: [{ id: "open", title: "Open Gateway Status", run: () => undefined }] } }),
},
});
expect(registry.shouldLoadRemotePlugin("status-tools")).toBe(false);
expect(registry.shouldLoadRemotePlugin("status-tools", true)).toBe(true);
registry.register({
id: remotePluginId,
machineId: "remote-1",
sourcePluginId: "status-tools",
machineSpecific: true,
plugin: {
apiVersion: 1,
name: "Remote Status Tools",
activate: () => ({ contributions: { actions: [{ id: "open", title: "Open Remote Status", run: () => undefined }] } }),
},
});
expect(registry.getActions(createContext().context).map((action) => action.id)).toEqual(["status-tools:open"]);
expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).toEqual([`${remotePluginId}:open`]);
}); });
it("does not activate remote duplicates when the gateway plugin is already registered", () => { it("does not activate remote duplicates when the gateway plugin is already registered", () => {
+62 -14
View File
@@ -22,13 +22,16 @@ export class PluginRegistry {
private readonly themePairs: QualifiedThemePairContribution[] = []; private readonly themePairs: QualifiedThemePairContribution[] = [];
private readonly pluginIds = new Set<string>(); private readonly pluginIds = new Set<string>();
private readonly gatewayPluginIds = new Set<string>(); private readonly gatewayPluginIds = new Set<string>();
private readonly gatewayMachineSpecificPluginIds = new Set<string>();
private readonly remoteMachineSpecificPluginIds = new Map<string, Set<string>>();
private readonly contributionIds = new Set<QualifiedContributionId>(); private readonly contributionIds = new Set<QualifiedContributionId>();
register(registration: PiWebPluginRegistration): void { register(registration: PiWebPluginRegistration): void {
const { id, plugin } = registration; const { id, plugin } = registration;
this.validatePluginId(id); this.validatePluginId(id);
const machineSpecific = this.parseMachineSpecific(id, registration.machineSpecific);
if (this.pluginIds.has(id)) throw new Error(`Duplicate plugin id: ${id}`); if (this.pluginIds.has(id)) throw new Error(`Duplicate plugin id: ${id}`);
if (isDuplicateOfGatewayPlugin(registration, this.gatewayPluginIds)) return; if (this.isRemoteDuplicateHiddenByGateway(registration.sourcePluginId, registration.machineId, machineSpecific)) return;
this.pluginIds.add(id); this.pluginIds.add(id);
const apiVersion: unknown = plugin.apiVersion; const apiVersion: unknown = plugin.apiVersion;
@@ -42,11 +45,19 @@ export class PluginRegistry {
for (const theme of contributions.themes ?? []) this.themes.push(this.qualifyTheme(id, theme)); for (const theme of contributions.themes ?? []) this.themes.push(this.qualifyTheme(id, theme));
for (const pair of contributions.themePairs ?? []) this.themePairs.push(this.qualifyThemePair(id, pair)); for (const pair of contributions.themePairs ?? []) this.themePairs.push(this.qualifyThemePair(id, pair));
this.gatewayPluginIds.add(id); this.gatewayPluginIds.add(id);
if (machineSpecific) this.gatewayMachineSpecificPluginIds.add(id);
} else if (registration.sourcePluginId !== undefined && machineSpecific) {
addMappedSetValue(this.remoteMachineSpecificPluginIds, registration.sourcePluginId, registration.machineId);
} }
} }
shouldLoadRemotePlugin(sourcePluginId: string, machineSpecific = false): boolean {
return !this.gatewayPluginIds.has(sourcePluginId) || this.gatewayMachineSpecificPluginIds.has(sourcePluginId) || machineSpecific;
}
getActions(context: PluginRuntimeContext): QualifiedPluginAction[] { getActions(context: PluginRuntimeContext): QualifiedPluginAction[] {
return this.actions.filter((action) => isActiveForMachine(action.machineId, runtimeContextMachineId(context), action.sourcePluginId, this.gatewayPluginIds)).map((action) => { const selectedMachineId = runtimeContextMachineId(context);
return this.actions.filter((action) => this.isContributionActive(action.pluginId, action.machineId, selectedMachineId, action.sourcePluginId)).map((action) => {
const scopedContext = pluginRuntimeContextFor(context, action.pluginId); const scopedContext = pluginRuntimeContextFor(context, action.pluginId);
const enabled = action.enabled?.(scopedContext); const enabled = action.enabled?.(scopedContext);
const qualified: QualifiedPluginAction = { const qualified: QualifiedPluginAction = {
@@ -101,8 +112,8 @@ export class PluginRegistry {
pluginId, pluginId,
localId: panel.id, localId: panel.id,
...(machineId === undefined ? {} : { machineId }), ...(machineId === undefined ? {} : { machineId }),
visible: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) && (visible?.(workspacePanelContextFor(context, pluginId)) ?? true), visible: (context: WorkspacePanelContext) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) && (visible?.(workspacePanelContextFor(context, pluginId)) ?? true),
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) ? badge(workspacePanelContextFor(context, pluginId)) : undefined }), ...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) ? badge(workspacePanelContextFor(context, pluginId)) : undefined }),
render: (context: WorkspacePanelContext) => panel.render(workspacePanelContextFor(context, pluginId)), render: (context: WorkspacePanelContext) => panel.render(workspacePanelContextFor(context, pluginId)),
}; };
} }
@@ -117,8 +128,8 @@ export class PluginRegistry {
pluginId, pluginId,
localId: contribution.id, localId: contribution.id,
...(machineId === undefined ? {} : { machineId }), ...(machineId === undefined ? {} : { machineId }),
visible: (context) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) && (visible?.(context) ?? true), visible: (context) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) && (visible?.(context) ?? true),
items: (context) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) ? items(context) : [], items: (context) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) ? items(context) : [],
}; };
} }
@@ -152,6 +163,33 @@ export class PluginRegistry {
return `${pluginId}:${localId}`; return `${pluginId}:${localId}`;
} }
private isContributionActive(pluginId: string, machineId: string | undefined, selectedMachineId: string, sourcePluginId: string | undefined): boolean {
if (machineId === undefined) return !this.isGatewayPluginHiddenForMachine(pluginId, selectedMachineId);
return machineId === selectedMachineId && !this.isRemotePluginHiddenByGateway(sourcePluginId, machineId);
}
private isRemoteDuplicateHiddenByGateway(sourcePluginId: string | undefined, machineId: string | undefined, machineSpecific: boolean): boolean {
return sourcePluginId !== undefined
&& machineId !== undefined
&& this.gatewayPluginIds.has(sourcePluginId)
&& !this.gatewayMachineSpecificPluginIds.has(sourcePluginId)
&& !machineSpecific;
}
private isRemotePluginHiddenByGateway(sourcePluginId: string | undefined, machineId: string): boolean {
if (sourcePluginId === undefined) return false;
if (this.gatewayMachineSpecificPluginIds.has(sourcePluginId)) return false;
if (this.remoteMachineSpecificPluginIds.get(sourcePluginId)?.has(machineId) === true) return false;
return this.gatewayPluginIds.has(sourcePluginId);
}
private isGatewayPluginHiddenForMachine(pluginId: string, machineId: string): boolean {
return machineId !== "local" && (
this.gatewayMachineSpecificPluginIds.has(pluginId)
|| this.remoteMachineSpecificPluginIds.get(pluginId)?.has(machineId) === true
);
}
private validatePluginId(pluginId: string): void { private validatePluginId(pluginId: string): void {
if (!idPattern.test(pluginId)) throw new Error(`Invalid plugin id: ${pluginId}`); if (!idPattern.test(pluginId)) throw new Error(`Invalid plugin id: ${pluginId}`);
} }
@@ -159,6 +197,12 @@ export class PluginRegistry {
private validateLocalId(localId: string): void { private validateLocalId(localId: string): void {
if (!localIdPattern.test(localId)) throw new Error(`Invalid contribution id: ${localId}`); if (!localIdPattern.test(localId)) throw new Error(`Invalid contribution id: ${localId}`);
} }
private parseMachineSpecific(pluginId: string, value: unknown): boolean {
if (value === undefined) return false;
if (typeof value !== "boolean") throw new Error(`Invalid plugin machineSpecific value for ${pluginId}: ${formatUnknownValue(value)}`);
return value;
}
} }
function pluginRuntimeContextFor(context: PluginRuntimeContext, pluginId: string): PluginRuntimeContext { function pluginRuntimeContextFor(context: PluginRuntimeContext, pluginId: string): PluginRuntimeContext {
@@ -179,16 +223,20 @@ export function installWorkspacePanelScope(context: WorkspacePanelContext, scope
return context; return context;
} }
function isDuplicateOfGatewayPlugin(registration: PiWebPluginRegistration, gatewayPluginIds: ReadonlySet<string>): boolean { function addMappedSetValue(map: Map<string, Set<string>>, key: string, value: string): void {
return registration.machineId !== undefined && registration.sourcePluginId !== undefined && gatewayPluginIds.has(registration.sourcePluginId); const existing = map.get(key);
if (existing === undefined) map.set(key, new Set([value]));
else existing.add(value);
} }
function isActiveForMachine(machineId: string | undefined, selectedMachineId: string, sourcePluginId: string | undefined, gatewayPluginIds: ReadonlySet<string>): boolean { function formatUnknownValue(value: unknown): string {
return machineId === undefined || (machineId === selectedMachineId && !isHiddenByGatewayPlugin(sourcePluginId, gatewayPluginIds)); if (typeof value === "string") return value;
} if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol" || typeof value === "function" || value === null || value === undefined) return String(value);
try {
function isHiddenByGatewayPlugin(sourcePluginId: string | undefined, gatewayPluginIds: ReadonlySet<string>): boolean { return JSON.stringify(value);
return sourcePluginId !== undefined && gatewayPluginIds.has(sourcePluginId); } catch {
return Object.prototype.toString.call(value);
}
} }
function runtimeContextMachineId(context: PluginRuntimeContext): string { function runtimeContextMachineId(context: PluginRuntimeContext): string {
+1
View File
@@ -14,6 +14,7 @@ export interface PiWebPluginRegistration {
plugin: PiWebPlugin; plugin: PiWebPlugin;
machineId?: string; machineId?: string;
sourcePluginId?: PluginId; sourcePluginId?: PluginId;
machineSpecific?: boolean;
} }
export interface PiWebPlugin { export interface PiWebPlugin {
+6 -6
View File
@@ -60,8 +60,8 @@ beforeEach(async () => {
}), }),
sessionDaemon: fakeSessionDaemon(), sessionDaemon: fakeSessionDaemon(),
piWebPlugins: { piWebPlugins: {
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }), manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", enabled: true }] }), plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined), 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,
@@ -332,11 +332,11 @@ describe("buildApp", () => {
it("serves the PI WEB plugin manifest and plugin assets", async () => { it("serves the PI WEB plugin manifest and plugin assets", async () => {
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" }); const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
expect(manifestResponse.statusCode).toBe(200); 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" }] }); expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] });
const pluginsResponse = await app.inject({ method: "GET", url: "/api/plugins" }); const pluginsResponse = await app.inject({ method: "GET", url: "/api/plugins" });
expect(pluginsResponse.statusCode).toBe(200); expect(pluginsResponse.statusCode).toBe(200);
expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", enabled: true }] }); expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
const assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" }); const assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
expect(assetResponse.statusCode).toBe(200); expect(assetResponse.statusCode).toBe(200);
@@ -353,7 +353,7 @@ describe("buildApp", () => {
const requestJson = vi.fn(() => Promise.resolve({ const requestJson = vi.fn(() => Promise.resolve({
statusCode: 200, statusCode: 200,
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local" }] }, body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local", machineSpecific: true }] },
})); }));
const request = vi.fn(() => Promise.resolve({ const request = vi.fn(() => Promise.resolve({
statusCode: 200, statusCode: 200,
@@ -366,7 +366,7 @@ describe("buildApp", () => {
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools"); const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
expect(manifestResponse.statusCode).toBe(200); expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({ expect(manifestResponse.json()).toEqual({
plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local" }], plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local", machineSpecific: true }],
}); });
expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 }); expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 });
@@ -9,6 +9,7 @@ interface RemotePluginManifestEntry {
module: string; module: string;
source?: string; source?: string;
scope?: string; scope?: string;
machineSpecific?: boolean;
} }
interface RemotePluginManifest { interface RemotePluginManifest {
@@ -158,11 +159,18 @@ function parseRemoteManifest(value: unknown): RemotePluginManifest {
module: entry["module"], module: entry["module"],
...(typeof entry["source"] === "string" ? { source: entry["source"] } : {}), ...(typeof entry["source"] === "string" ? { source: entry["source"] } : {}),
...(typeof entry["scope"] === "string" ? { scope: entry["scope"] } : {}), ...(typeof entry["scope"] === "string" ? { scope: entry["scope"] } : {}),
...(parseRemoteMachineSpecific(entry["machineSpecific"])),
}; };
}), }),
}; };
} }
function parseRemoteMachineSpecific(value: unknown): { machineSpecific?: boolean } {
if (value === undefined) return {};
if (typeof value !== "boolean") throw new Error("Invalid remote PI WEB plugin manifest entry");
return { machineSpecific: value };
}
function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void { function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void {
for (const [name, value] of Object.entries(headers)) { for (const [name, value] of Object.entries(headers)) {
if (value === undefined) continue; if (value === undefined) continue;
+13 -1
View File
@@ -25,7 +25,7 @@ describe("PiWebPluginService", () => {
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
await expect(service.manifest()).resolves.toEqual({ await expect(service.manifest()).resolves.toEqual({
plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local" })], plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })],
}); });
const manifest = await service.manifest(); const manifest = await service.manifest();
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/info\/pi-web-plugin\.js\?v=\d+$/u); expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/info\/pi-web-plugin\.js\?v=\d+$/u);
@@ -35,6 +35,18 @@ describe("PiWebPluginService", () => {
expect(asset?.content.toString("utf8")).toContain("export default"); expect(asset?.content.toString("utf8")).toContain("export default");
}); });
it("includes machine-specific preferences in plugin manifests", async () => {
await writePlugin(join(tempDir, "plugins", "updates"), {
packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true }] });
await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true, enabled: true }] });
});
it("discovers Pi package plugins through an injected package provider", async () => { it("discovers Pi package plugins through an injected package provider", async () => {
const packageDir = join(tempDir, "pkg"); const packageDir = join(tempDir, "pkg");
await writePlugin(packageDir, { await writePlugin(packageDir, {
+24 -4
View File
@@ -18,6 +18,7 @@ export interface PiWebPluginManifestEntry {
module: string; module: string;
source: string; source: string;
scope: PiWebPluginScope; scope: PiWebPluginScope;
machineSpecific: boolean;
} }
export interface ConfiguredPiPackage { export interface ConfiguredPiPackage {
@@ -38,6 +39,7 @@ interface PluginRecord {
version: string; version: string;
source: string; source: string;
scope: PiWebPluginScope; scope: PiWebPluginScope;
machineSpecific: boolean;
} }
interface PiWebPluginServiceOptions { interface PiWebPluginServiceOptions {
@@ -61,6 +63,7 @@ interface PiWebPackageConfig {
interface PiWebPluginEntry { interface PiWebPluginEntry {
id: string; id: string;
module: string; module: string;
machineSpecific: boolean;
} }
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">; type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
@@ -102,7 +105,7 @@ export class PiWebPluginService {
return { return {
plugins: (await this.plugins()).plugins plugins: (await this.plugins()).plugins
.filter((plugin) => plugin.enabled) .filter((plugin) => plugin.enabled)
.map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope })), .map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope, machineSpecific: plugin.machineSpecific })),
}; };
} }
@@ -135,6 +138,7 @@ export class PiWebPluginService {
module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`, module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`,
source: plugin.source, source: plugin.source,
scope: plugin.scope, scope: plugin.scope,
machineSpecific: plugin.machineSpecific,
enabled: config.plugins?.[plugin.id]?.enabled !== false, enabled: config.plugins?.[plugin.id]?.enabled !== false,
}; };
} }
@@ -228,7 +232,7 @@ async function discoverPluginEntries(root: string, config: PiWebPackageConfig):
const entryPath = join(root, entry.module); const entryPath = join(root, entry.module);
const entryStat = await stat(entryPath).catch(() => undefined); const entryStat = await stat(entryPath).catch(() => undefined);
if (entryStat?.isFile() !== true) throw new Error(`PI WEB plugin module not found for ${entry.id}: ${entry.module}`); 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)) }); plugins.push({ id: entry.id, root, entryFile: entry.module, version: String(Math.floor(entryStat.mtimeMs)), machineSpecific: entry.machineSpecific });
} }
return plugins; return plugins;
} }
@@ -248,7 +252,7 @@ async function readPiWebPackageConfig(root: string): Promise<PiWebPackageConfig
} }
function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string): PiWebPluginEntry[] { 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`); if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported PI WEB plugin metadata in ${packagePath}: use piWeb.plugins with { id, module, machineSpecific? } entries`);
const plugins = piWeb["plugins"]; const plugins = piWeb["plugins"];
if (plugins === undefined) return []; if (plugins === undefined) return [];
if (!Array.isArray(plugins)) throw new Error(`PI WEB plugins must be an array in ${packagePath}`); if (!Array.isArray(plugins)) throw new Error(`PI WEB plugins must be an array in ${packagePath}`);
@@ -259,10 +263,26 @@ function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string)
const module = entry["module"]; const module = entry["module"];
if (typeof id !== "string" || !isPiWebPluginId(id)) throw new Error(`Invalid PI WEB plugin id in ${packagePath}: ${String(id)}`); if (typeof id !== "string" || !isPiWebPluginId(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}`); if (typeof module !== "string" || module === "") throw new Error(`Invalid PI WEB plugin module for ${id} in ${packagePath}`);
return { id, module }; return { id, module, machineSpecific: parseMachineSpecific(entry["machineSpecific"], packagePath, id) };
}); });
} }
function parseMachineSpecific(value: unknown, packagePath: string, pluginId: string): boolean {
if (value === undefined) return false;
if (typeof value !== "boolean") throw new Error(`Invalid PI WEB plugin machineSpecific value for ${pluginId} in ${packagePath}: ${formatUnknownValue(value)}`);
return value;
}
function formatUnknownValue(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol" || typeof value === "function" || value === null || value === undefined) return String(value);
try {
return JSON.stringify(value);
} catch {
return Object.prototype.toString.call(value);
}
}
function addUnique(records: Map<string, PluginRecord>, plugin: PluginRecord): void { function addUnique(records: Map<string, PluginRecord>, plugin: PluginRecord): void {
if (records.has(plugin.id)) { if (records.has(plugin.id)) {
warnInvalidPlugin(plugin.source, `Duplicate PI WEB plugin id: ${plugin.id}`); warnInvalidPlugin(plugin.source, `Duplicate PI WEB plugin id: ${plugin.id}`);
+1
View File
@@ -64,6 +64,7 @@ export interface PiWebPluginInfo {
module: string; module: string;
source: string; source: string;
scope: PiWebPluginScope; scope: PiWebPluginScope;
machineSpecific: boolean;
enabled: boolean; enabled: boolean;
} }
+1
View File
@@ -6,6 +6,7 @@ export interface FederatedHttpRouteSpec {
} }
export const FEDERATED_HTTP_ROUTES = [ export const FEDERATED_HTTP_ROUTES = [
{ method: "GET", path: "/pi-web/status" },
{ method: "GET", path: "/projects" }, { method: "GET", path: "/projects" },
{ method: "POST", path: "/projects" }, { method: "POST", path: "/projects" },
{ method: "DELETE", path: "/projects/:projectId" }, { method: "DELETE", path: "/projects/:projectId" },