diff --git a/.changeset/actions-click-feedback.md b/.changeset/actions-click-feedback.md deleted file mode 100644 index b7cf5ad..0000000 --- a/.changeset/actions-click-feedback.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web-actions": patch ---- - -Prevent redundant workspace action panel re-renders from resetting mobile scroll position or replacing action buttons mid-click, and show feedback for stale, cancelled, or already-starting actions. diff --git a/.changeset/built-in-plugin-docs.md b/.changeset/built-in-plugin-docs.md new file mode 100644 index 0000000..309e0cb --- /dev/null +++ b/.changeset/built-in-plugin-docs.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Document built-in PI WEB plugins, including configuration guidance for Workspace Tasks. diff --git a/.changeset/plugin-disable-config.md b/.changeset/plugin-disable-config.md new file mode 100644 index 0000000..2aacf91 --- /dev/null +++ b/.changeset/plugin-disable-config.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add plugin enablement settings so discovered PI WEB plugins can be disabled before the browser imports them. diff --git a/.changeset/workspace-tasks-click-feedback.md b/.changeset/workspace-tasks-click-feedback.md new file mode 100644 index 0000000..731b4e2 --- /dev/null +++ b/.changeset/workspace-tasks-click-feedback.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Prevent redundant Workspace Tasks panel re-renders from resetting mobile scroll position or replacing task buttons mid-click, and show feedback for stale, cancelled, or already-starting tasks. diff --git a/.changeset/workspace-tasks-rename.md b/.changeset/workspace-tasks-rename.md new file mode 100644 index 0000000..d195d8b --- /dev/null +++ b/.changeset/workspace-tasks-rename.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Bundle Workspace Tasks with PI WEB as a built-in plugin for running `.pi-web/tasks.json` commands in workspace terminals. diff --git a/README.md b/README.md index 7ddd505..74488f9 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ PI WEB keeps its own state intentionally small: 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. 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, and `pi-web-plugins/pi-web` demonstrates a dynamic status panel. +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/pi-web` 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: @@ -113,6 +113,8 @@ Validate with /pi-web-plugins/manifest.json and explain reload/debug steps. Do not modify PI WEB itself. ``` +Manage discovered plugins in **Settings → Plugins** or with the top-level `plugins` config key. Plugins are enabled by default; set `plugins..enabled` to `false` and reload the browser tab to prevent PI WEB from importing that plugin. + Reload the browser tab after adding or editing a plugin. If `PI_WEB_DATA_DIR` is set, use `$PI_WEB_DATA_DIR/plugins` instead of `~/.pi-web/plugins`. Check discovery with: ```bash diff --git a/docs/plugins.html b/docs/plugins.html index 44ce4b6..ffe07b3 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -4,7 +4,7 @@ PI WEB plugins - + @@ -52,7 +52,7 @@

Plugin development

-

Customize PI WEB with local UI plugins.

+

Customize PI WEB with UI plugins.

Plugins are trusted browser-side ES modules. They can add actions, workspace panels, and compact workspace labels to the PI WEB UI. @@ -67,6 +67,8 @@ What can be extended What to ask AI to build Canonical example + Built-in plugins + Manage plugins Production usage AI-friendly docs Develop and debug @@ -165,6 +167,108 @@ After editing, check the manifest endpoint and browser-console failure cases.

+
+

Built-in plugins

+

+ PI WEB ships core, discoverable plugins in the main @jmfederico/pi-web npm package. No + separate pi install step is required: update PI WEB, reload the browser tab, and the bundled + plugins appear in /pi-web-plugins/manifest.json. +

+

+ Built-in plugins can be managed from Settings → Plugins or with the top-level + plugins config key. +

+ +

Workspace Tasks

+

+ Workspace Tasks adds a Tasks workspace tab for running configured shell + commands in dedicated PI WEB terminals. It is built into PI WEB and enabled by default. +

+ +
+
+ Disable Workspace Tasks + +
+
{
+  "plugins": {
+    "workspace-tasks": { "enabled": false }
+  }
+}
+
+
+
+ Example .pi-web/tasks.json + +
+
{
+  "version": 1,
+  "tasks": [
+    {
+      "id": "docker.start",
+      "title": "Start Docker",
+      "group": "Docker",
+      "description": "Start the local Docker Compose environment.",
+      "command": "./docker/scripts/docker-compose-dev up -d"
+    },
+    {
+      "id": "db.reset",
+      "title": "Reset DB",
+      "group": "Database",
+      "command": "go -C klingit-go run ./cli db reset",
+      "confirm": true
+    }
+  ]
+}
+
+

+ Open a workspace, choose the Tasks tab, and click Run next to a task. + Commands run in the workspace root because PI WEB creates the terminal for that workspace. +

+

+ Review task configs before running them, especially in shared projects. Workspace Tasks runs trusted + shell commands from your repositories. +

+
+ +
+

Manage plugins

+

+ Open Settings → Plugins to review discovered bundled, local, dev, and Pi package plugins. + PI WEB can disable any discovered plugin before the browser imports it. Core app contributions such as + the command palette, base workspace tools, and themes are not managed through this plugin list. +

+
+
+ Plugin config shape + +
+
{
+  "plugins": {
+    "workspace-tasks": {
+      "enabled": true,
+      "settings": {}
+    },
+    "info": {
+      "enabled": false
+    }
+  }
+}
+
+

+ Plugins are enabled by default. Set enabled to false to remove a plugin from + /pi-web-plugins/manifest.json so it is not imported or activated on the next page load. + The optional settings object is reserved for plugin-specific settings. +

+

+ After changing plugin enablement, reload the PI WEB browser tab. Already-loaded plugin JavaScript is not + unloaded from the current page. +

+
+

Production usage

diff --git a/docs/plugins.md b/docs/plugins.md index 7957508..74d1d1f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -127,26 +127,90 @@ ln -s /path/to/plugin-folder ~/.pi-web/plugins/plugin-id Reload the PI WEB browser tab. PI WEB serves plugin modules with an mtime-based `?v=` cache buster. After editing a plugin, hard reload the browser if you do not see changes. -## First-party separate plugin packages +## Manage plugins -First-party plugins that are published as their own npm packages can live in this repository under `plugins/*` as npm workspaces. These packages are **not bundled** into the main `@jmfederico/pi-web` npm package automatically; they are separate packages that share CI, tests, and local development tooling with the main repo. +Open **Settings → Plugins** to review discovered bundled, local, dev, and Pi package plugins. PI WEB can disable any discovered plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list. -A separate plugin package should: +Plugin preferences are stored under the top-level `plugins` config key in the PI WEB config file: -- use type-only imports from `@jmfederico/pi-web/plugin-api` when it needs shared PI WEB plugin interfaces; this subpath is currently a `.d.ts`-only dogfooding surface, not a runtime JavaScript module; -- keep its PI WEB metadata in its own `package.json` with `piWeb.plugins` entries pointing at built JavaScript in `dist/`; -- include a package-level `build` script and `prepack` script so `npm pack --workspace ` and `npm publish --workspace ` produce a usable plugin package; -- use a local symlink into `~/.pi-web/plugins/` while developing; -- document any private PI WEB APIs it dogfoods until those APIs become stable plugin runtime helpers. - -Typical local development loop from this repository: - -```bash -npm run dev -curl http://127.0.0.1:8504/pi-web-plugins/manifest.json +```json +{ + "plugins": { + "workspace-tasks": { + "enabled": true, + "settings": {} + }, + "info": { + "enabled": false + } + } +} ``` -The main PI WEB `dev` command watches bundled plugins in `pi-web-plugins/`, builds/watches separate plugin packages in `plugins/*`, and discovers those source-checkout plugin packages without symlinking them into `~/.pi-web/plugins`. +Plugins are enabled by default. Set `enabled` to `false` to remove a plugin from `/pi-web-plugins/manifest.json` so the browser will not import or activate it on the next page load. The optional `settings` object is reserved for plugin-specific settings. + +After changing plugin enablement, reload the PI WEB browser tab. Already-loaded plugin JavaScript is not unloaded from the current page. + +## Built-in plugins + +PI WEB ships core, discoverable plugins in the main `@jmfederico/pi-web` npm package. No separate `pi install` step is required: update PI WEB, reload the browser tab, and the bundled plugins appear in `/pi-web-plugins/manifest.json`. + +Built-in plugins can be managed from **Settings → Plugins** or with the top-level `plugins` config key. + +### Workspace Tasks + +**Plugin id:** `workspace-tasks` +**Config file:** `.pi-web/tasks.json` +**What it does:** adds a **Tasks** workspace tab for running configured shell commands in dedicated PI WEB terminals. + +Workspace Tasks is enabled by default. To hide it, disable `workspace-tasks` in **Settings → Plugins** or set: + +```json +{ + "plugins": { + "workspace-tasks": { "enabled": false } + } +} +``` + +Configure workspace tasks in `.pi-web/tasks.json`: + +```json +{ + "version": 1, + "tasks": [ + { + "id": "docker.start", + "title": "Start Docker", + "group": "Docker", + "description": "Start the local Docker Compose environment.", + "command": "./docker/scripts/docker-compose-dev up -d" + }, + { + "id": "db.reset", + "title": "Reset DB", + "group": "Database", + "command": "go -C klingit-go run ./cli db reset", + "confirm": true + } + ] +} +``` + +Open a workspace, choose the **Tasks** tab, and click **Run** next to a task. Commands run in the workspace root because PI WEB creates the terminal for that workspace. + +Task fields: + +- `version`: must be `1`. +- `tasks`: array of task definitions. +- `id`: stable task id, matching `^[a-z][a-z0-9.-]*$`. +- `title`: button label. +- `command`: literal shell command sent to the terminal. +- `description`: optional explanatory text. +- `group`: optional group heading. +- `confirm`: optional boolean. When true, the browser asks before dispatching the command. + +Review task configs before running them, especially in shared projects. Workspace Tasks runs trusted shell commands from your repositories. ## Discovery and packaging diff --git a/package-lock.json b/package-lock.json index d24c484..d9b2bf3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,6 @@ "name": "@jmfederico/pi-web", "version": "1.202606.0", "license": "MIT", - "workspaces": [ - ".", - "plugins/*" - ], "dependencies": { "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-go": "^6.0.1", @@ -2326,14 +2322,6 @@ } } }, - "node_modules/@jmfederico/pi-web": { - "resolved": "", - "link": true - }, - "node_modules/@jmfederico/pi-web-actions": { - "resolved": "plugins/actions", - "link": true - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -8678,23 +8666,6 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } - }, - "plugins/actions": { - "name": "@jmfederico/pi-web-actions", - "version": "0.1.2", - "license": "MIT", - "devDependencies": { - "typescript": "^5.9.3", - "vitest": "^4.1.5" - }, - "peerDependencies": { - "@jmfederico/pi-web": ">=1.202605.14" - }, - "peerDependenciesMeta": { - "@jmfederico/pi-web": { - "optional": true - } - } } } } diff --git a/package.json b/package.json index e81618c..0327881 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,6 @@ "license": "MIT", "author": "Federico Jaramillo Martinez", "type": "module", - "workspaces": [ - ".", - "plugins/*" - ], "bin": { "pi-web": "dist/cli.js", "pi-web-server": "dist/server/index.js", @@ -27,21 +23,19 @@ "scripts": { "dev": "bash -c 'trap \"kill 0\" EXIT; npm run dev:sessiond & npm run dev:web & npm run dev:client & wait'", "dev:sessiond": "tsx watch src/server/sessiond.ts", - "dev:web": "bash -c 'set -e; npm run build:plugins; npm run build:plugin-packages; trap \"kill 0\" EXIT; npm run dev:plugins & npm run dev:plugin-packages & tsx watch src/server/index.ts & wait'", + "dev:web": "bash -c 'set -e; npm run build:plugins; trap \"kill 0\" EXIT; npm run dev:plugins & tsx watch src/server/index.ts & wait'", "dev:server": "npm run dev:web", "dev:client": "vite --host 0.0.0.0", "dev:plugins": "node scripts/build-plugins.mjs --watch", - "dev:plugin-packages": "node scripts/dev-plugin-packages.mjs", - "build": "tsc -p tsconfig.build.json && npm run build:plugins && npm run build:plugin-packages && vite build", + "build": "tsc -p tsconfig.build.json && npm run build:plugins && vite build", "build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs", - "build:plugin-packages": "bash -c 'set -e; shopt -s nullglob; for package in plugins/*/package.json; do dir=${package%/package.json}; (cd \"$dir\" && npm run build --if-present); done'", "typecheck": "tsc --noEmit", - "lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" \"plugins/**/*.ts\" vite.config.ts vitest.config.ts", + "lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" vite.config.ts vitest.config.ts", "test": "vitest run --config vitest.config.ts", "verify": "npm run typecheck && npm run lint && npm test", "start": "tsx src/server/index.ts", "start:sessiond": "tsx src/server/sessiond.ts", - "clean": "rm -rf dist plugins/*/dist", + "clean": "rm -rf dist", "prepack": "npm run build", "pack:dry": "npm pack --dry-run", "prepublishOnly": "npm run verify", diff --git a/plugins/actions/src/config.test.ts b/pi-web-plugins/workspace-tasks/config.test.ts similarity index 54% rename from plugins/actions/src/config.test.ts rename to pi-web-plugins/workspace-tasks/config.test.ts index aa8a19b..9c3f95d 100644 --- a/plugins/actions/src/config.test.ts +++ b/pi-web-plugins/workspace-tasks/config.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from "vitest"; -import { parseActionsConfigText } from "./config"; +import { parseTasksConfigText } from "./config"; -describe("workspace actions config", () => { +describe("workspace tasks config", () => { it("parses a minimal version 1 config", () => { - expect(parseActionsConfigText(JSON.stringify({ + expect(parseTasksConfigText(JSON.stringify({ version: 1, - actions: [ + tasks: [ { id: "db.reset", title: "Reset DB", command: "go -C klingit-go run ./cli db reset" }, ], }))).toEqual({ ok: true, config: { version: 1, - actions: [ + tasks: [ { id: "db.reset", title: "Reset DB", command: "go -C klingit-go run ./cli db reset", confirm: false }, ], }, @@ -20,9 +20,9 @@ describe("workspace actions config", () => { }); it("parses optional group, description, and confirm fields", () => { - expect(parseActionsConfigText(JSON.stringify({ + expect(parseTasksConfigText(JSON.stringify({ version: 1, - actions: [ + tasks: [ { id: "docker.start", title: "Start Docker", @@ -36,7 +36,7 @@ describe("workspace actions config", () => { ok: true, config: { version: 1, - actions: [ + tasks: [ { id: "docker.start", title: "Start Docker", @@ -50,50 +50,50 @@ describe("workspace actions config", () => { }); }); - it("accepts an empty actions array", () => { - expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [] }))).toEqual({ + it("accepts an empty tasks array", () => { + expect(parseTasksConfigText(JSON.stringify({ version: 1, tasks: [] }))).toEqual({ ok: true, - config: { version: 1, actions: [] }, + config: { version: 1, tasks: [] }, }); }); it("rejects invalid JSON and unsupported versions", () => { - expect(parseActionsConfigText("{")).toMatchObject({ ok: false }); - expect(parseActionsConfigText(JSON.stringify({ version: 2, actions: [] }))).toEqual({ + expect(parseTasksConfigText("{")).toMatchObject({ ok: false }); + expect(parseTasksConfigText(JSON.stringify({ version: 2, tasks: [] }))).toEqual({ ok: false, error: "Config version must be 1", }); }); it("rejects missing, empty, or duplicate required fields", () => { - expect(parseActionsConfigText(JSON.stringify({ version: 1 }))).toEqual({ + expect(parseTasksConfigText(JSON.stringify({ version: 1 }))).toEqual({ ok: false, - error: "Config actions must be an array", + error: "Config tasks must be an array", }); - expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [{ id: "", title: "T", command: "cmd" }] }))).toEqual({ + expect(parseTasksConfigText(JSON.stringify({ version: 1, tasks: [{ id: "", title: "T", command: "cmd" }] }))).toEqual({ ok: false, - error: "Action 1 id must be a non-empty string", + error: "Task 1 id must be a non-empty string", }); - expect(parseActionsConfigText(JSON.stringify({ + expect(parseTasksConfigText(JSON.stringify({ version: 1, - actions: [ + tasks: [ { id: "one", title: "One", command: "cmd" }, { id: "one", title: "Again", command: "cmd" }, ], }))).toEqual({ ok: false, - error: "Duplicate action id: one", + error: "Duplicate task id: one", }); }); it("rejects invalid optional field types", () => { - expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [{ id: "one", title: "One", command: "cmd", confirm: "yes" }] }))).toEqual({ + expect(parseTasksConfigText(JSON.stringify({ version: 1, tasks: [{ id: "one", title: "One", command: "cmd", confirm: "yes" }] }))).toEqual({ ok: false, - error: "Action 1 confirm must be a boolean", + error: "Task 1 confirm must be a boolean", }); - expect(parseActionsConfigText(JSON.stringify({ version: 1, actions: [{ id: "one", title: "One", command: "cmd", group: "" }] }))).toEqual({ + expect(parseTasksConfigText(JSON.stringify({ version: 1, tasks: [{ id: "one", title: "One", command: "cmd", group: "" }] }))).toEqual({ ok: false, - error: "Action 1 group must be a non-empty string when provided", + error: "Task 1 group must be a non-empty string when provided", }); }); }); diff --git a/plugins/actions/src/config.ts b/pi-web-plugins/workspace-tasks/config.ts similarity index 62% rename from plugins/actions/src/config.ts rename to pi-web-plugins/workspace-tasks/config.ts index 8dc57fd..e109e16 100644 --- a/plugins/actions/src/config.ts +++ b/pi-web-plugins/workspace-tasks/config.ts @@ -1,14 +1,14 @@ -export const ACTIONS_CONFIG_PATH = ".pi-web/actions.json"; -export const ACTIONS_CONFIG_VERSION = 1; +export const TASKS_CONFIG_PATH = ".pi-web/tasks.json"; +export const TASKS_CONFIG_VERSION = 1; -const actionIdPattern = /^[a-z][a-z0-9.-]*$/u; +const taskIdPattern = /^[a-z][a-z0-9.-]*$/u; -export interface WorkspaceActionsConfig { - version: typeof ACTIONS_CONFIG_VERSION; - actions: WorkspaceAction[]; +export interface WorkspaceTasksConfig { + version: typeof TASKS_CONFIG_VERSION; + tasks: WorkspaceTask[]; } -export interface WorkspaceAction { +export interface WorkspaceTask { id: string; title: string; command: string; @@ -17,51 +17,51 @@ export interface WorkspaceAction { confirm: boolean; } -export type ParseActionsConfigResult = - | { ok: true; config: WorkspaceActionsConfig } +export type ParseTasksConfigResult = + | { ok: true; config: WorkspaceTasksConfig } | { ok: false; error: string }; -export function parseActionsConfigText(text: string): ParseActionsConfigResult { +export function parseTasksConfigText(text: string): ParseTasksConfigResult { let parsed: unknown; try { parsed = JSON.parse(text); } catch (error) { return { ok: false, error: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}` }; } - return parseActionsConfig(parsed); + return parseTasksConfig(parsed); } -export function parseActionsConfig(value: unknown): ParseActionsConfigResult { +export function parseTasksConfig(value: unknown): ParseTasksConfigResult { if (!isRecord(value)) return invalid("Config must be an object"); - if (value["version"] !== ACTIONS_CONFIG_VERSION) return invalid("Config version must be 1"); + if (value["version"] !== TASKS_CONFIG_VERSION) return invalid("Config version must be 1"); - const actions = value["actions"]; - if (!Array.isArray(actions)) return invalid("Config actions must be an array"); + const tasks = value["tasks"]; + if (!Array.isArray(tasks)) return invalid("Config tasks must be an array"); const ids = new Set(); - const parsedActions: WorkspaceAction[] = []; - for (const [index, action] of actions.entries()) { - const parsedAction = parseAction(action, index); - if (!parsedAction.ok) return parsedAction; - if (ids.has(parsedAction.action.id)) return invalid(`Duplicate action id: ${parsedAction.action.id}`); - ids.add(parsedAction.action.id); - parsedActions.push(parsedAction.action); + const parsedTasks: WorkspaceTask[] = []; + for (const [index, task] of tasks.entries()) { + const parsedTask = parseTask(task, index); + if (!parsedTask.ok) return parsedTask; + if (ids.has(parsedTask.task.id)) return invalid(`Duplicate task id: ${parsedTask.task.id}`); + ids.add(parsedTask.task.id); + parsedTasks.push(parsedTask.task); } - return { ok: true, config: { version: ACTIONS_CONFIG_VERSION, actions: parsedActions } }; + return { ok: true, config: { version: TASKS_CONFIG_VERSION, tasks: parsedTasks } }; } -type ParseActionResult = - | { ok: true; action: WorkspaceAction } +type ParseTaskResult = + | { ok: true; task: WorkspaceTask } | { ok: false; error: string }; -function parseAction(value: unknown, index: number): ParseActionResult { - const label = `Action ${String(index + 1)}`; +function parseTask(value: unknown, index: number): ParseTaskResult { + const label = `Task ${String(index + 1)}`; if (!isRecord(value)) return invalid(`${label} must be an object`); const id = requireNonEmptyString(value, "id", label); if (!id.ok) return id; - if (!actionIdPattern.test(id.value)) return invalid(`${label} id must match ${actionIdPattern.source}`); + if (!taskIdPattern.test(id.value)) return invalid(`${label} id must match ${taskIdPattern.source}`); const title = requireNonEmptyString(value, "title", label); if (!title.ok) return title; @@ -80,7 +80,7 @@ function parseAction(value: unknown, index: number): ParseActionResult { return { ok: true, - action: { + task: { id: id.value, title: title.value, command: command.value, diff --git a/pi-web-plugins/workspace-tasks/package.json b/pi-web-plugins/workspace-tasks/package.json new file mode 100644 index 0000000..075a9d3 --- /dev/null +++ b/pi-web-plugins/workspace-tasks/package.json @@ -0,0 +1,9 @@ +{ + "name": "@pi-web/workspace-tasks-plugin", + "private": true, + "piWeb": { + "plugins": [ + { "id": "workspace-tasks", "module": "pi-web-plugin.js" } + ] + } +} diff --git a/pi-web-plugins/workspace-tasks/pi-web-plugin.ts b/pi-web-plugins/workspace-tasks/pi-web-plugin.ts new file mode 100644 index 0000000..aca7657 --- /dev/null +++ b/pi-web-plugins/workspace-tasks/pi-web-plugin.ts @@ -0,0 +1,41 @@ +import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api"; +import { TASKS_CONFIG_PATH } from "./config.js"; +import { defineTasksPanelElement, tasksPanelBadge } from "./tasksPanelElement.js"; +import { terminalCommandRunsFromContext } from "./piWebInternal.js"; + +const plugin: PiWebPlugin = { + apiVersion: 1, + name: "Workspace Tasks", + activate: ({ pluginId, html }) => { + defineTasksPanelElement(); + + return { + contributions: { + actions: [ + { + id: "workspace.open-tasks", + title: "Open Workspace Tasks", + description: `Open the workspace Tasks tab. Configure tasks in ${TASKS_CONFIG_PATH}.`, + group: "Workspace", + enabled: (context) => context.state.selectedWorkspace !== undefined, + run: (context) => { + if (context.state.selectedWorkspace === undefined) return; + context.selectWorkspaceTool(`${pluginId}:workspace.tasks`); + }, + }, + ], + workspacePanels: [ + { + id: "workspace.tasks", + title: "Tasks", + order: 40, + badge: ({ workspace }) => tasksPanelBadge(workspace), + render: (context) => html``, + }, + ], + }, + }; + }, +}; + +export default plugin; diff --git a/plugins/actions/src/piWebInternal.ts b/pi-web-plugins/workspace-tasks/piWebInternal.ts similarity index 100% rename from plugins/actions/src/piWebInternal.ts rename to pi-web-plugins/workspace-tasks/piWebInternal.ts diff --git a/plugins/actions/src/piWebPrivateUi.ts b/pi-web-plugins/workspace-tasks/piWebPrivateUi.ts similarity index 100% rename from plugins/actions/src/piWebPrivateUi.ts rename to pi-web-plugins/workspace-tasks/piWebPrivateUi.ts diff --git a/plugins/actions/src/actionRunner.test.ts b/pi-web-plugins/workspace-tasks/taskRunner.test.ts similarity index 66% rename from plugins/actions/src/actionRunner.test.ts rename to pi-web-plugins/workspace-tasks/taskRunner.test.ts index 62658af..74fd0b8 100644 --- a/plugins/actions/src/actionRunner.test.ts +++ b/pi-web-plugins/workspace-tasks/taskRunner.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { Workspace } from "@jmfederico/pi-web/plugin-api"; -import { runWorkspaceActionInTerminal } from "./actionRunner"; -import type { WorkspaceAction } from "./config"; +import { runWorkspaceTaskInTerminal } from "./taskRunner"; +import type { WorkspaceTask } from "./config"; import type { InternalTerminalCommandRun, InternalTerminalCommandRunsRuntime } from "./piWebInternal"; const workspace: Workspace = { @@ -16,7 +16,7 @@ const workspace: Workspace = { const run: InternalTerminalCommandRun = { id: "run1", - origin: "actions", + origin: "workspace-tasks", projectId: workspace.projectId, workspaceId: workspace.id, terminalId: "term1", @@ -24,19 +24,19 @@ const run: InternalTerminalCommandRun = { command: "npm run build", status: "running", createdAt: "2026-05-25T00:00:00.000Z", - metadata: { "pi.plugin": "actions", "action.id": "build" }, + metadata: { "pi.plugin": "workspace-tasks", "task.id": "build" }, }; -describe("action runner", () => { - it("starts workspace actions through the internal terminal command-run helper", async () => { - const action: WorkspaceAction = { id: "build", title: "Build", command: "npm run build", confirm: false }; +describe("task runner", () => { + it("starts workspace tasks through the internal terminal command-run helper", async () => { + const task: WorkspaceTask = { id: "build", title: "Build", command: "npm run build", confirm: false }; const runCommand = vi.fn(() => Promise.resolve({ run, completed: Promise.resolve(run) })); const terminal: InternalTerminalCommandRunsRuntime = { runCommand, open: vi.fn(), }; - const handle = await runWorkspaceActionInTerminal(terminal, workspace, action); + const handle = await runWorkspaceTaskInTerminal(terminal, workspace, task); expect(handle.run).toEqual(run); await expect(handle.completed).resolves.toEqual(run); @@ -45,7 +45,7 @@ describe("action runner", () => { title: "Build", command: "npm run build", open: true, - metadata: { "pi.plugin": "actions", "action.id": "build" }, + metadata: { "pi.plugin": "workspace-tasks", "task.id": "build" }, }); }); }); diff --git a/pi-web-plugins/workspace-tasks/taskRunner.ts b/pi-web-plugins/workspace-tasks/taskRunner.ts new file mode 100644 index 0000000..32f7a17 --- /dev/null +++ b/pi-web-plugins/workspace-tasks/taskRunner.ts @@ -0,0 +1,16 @@ +import type { Workspace } from "@jmfederico/pi-web/plugin-api"; +import type { WorkspaceTask } from "./config.js"; +import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js"; + +export function runWorkspaceTaskInTerminal(terminal: InternalTerminalCommandRunsRuntime, workspace: Workspace, task: WorkspaceTask): ReturnType { + return terminal.runCommand({ + workspace, + title: task.title, + command: task.command, + open: true, + metadata: { + "pi.plugin": "workspace-tasks", + "task.id": task.id, + }, + }); +} diff --git a/plugins/actions/src/actionsPanelElement.ts b/pi-web-plugins/workspace-tasks/tasksPanelElement.ts similarity index 61% rename from plugins/actions/src/actionsPanelElement.ts rename to pi-web-plugins/workspace-tasks/tasksPanelElement.ts index 2d8fbf9..d698328 100644 --- a/plugins/actions/src/actionsPanelElement.ts +++ b/pi-web-plugins/workspace-tasks/tasksPanelElement.ts @@ -1,21 +1,21 @@ import type { Workspace } from "@jmfederico/pi-web/plugin-api"; -import { ACTIONS_CONFIG_PATH, type WorkspaceAction } from "./config.js"; -import { runWorkspaceActionInTerminal } from "./actionRunner.js"; +import { TASKS_CONFIG_PATH, type WorkspaceTask } from "./config.js"; +import { runWorkspaceTaskInTerminal } from "./taskRunner.js"; import { requestPiWebRender } from "./piWebPrivateUi.js"; import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js"; -import { actionsConfigRefreshHint, actionsConfigUnavailableMessage, loadWorkspaceActionsConfig, type WorkspaceActionsConfigLoadResult } from "./workspaceActionsClient.js"; +import { loadWorkspaceTasksConfig, tasksConfigRefreshHint, tasksConfigUnavailableMessage, type WorkspaceTasksConfigLoadResult } from "./workspaceTasksClient.js"; -export const actionsPanelTagName = "pi-web-actions-panel"; +export const tasksPanelTagName = "pi-web-workspace-tasks-panel"; export type OpenTerminal = (options?: { terminalId?: string | undefined }) => void; -const configChangedEvent = "pi-web-actions-config-changed"; +const configChangedEvent = "pi-web-workspace-tasks-config-changed"; type ConfigState = | { kind: "loading" } - | WorkspaceActionsConfigLoadResult; + | WorkspaceTasksConfigLoadResult; -interface ActionStatus { +interface TaskStatus { kind: "info" | "success" | "error"; message: string; detail?: string; @@ -23,23 +23,23 @@ interface ActionStatus { const configCache = new Map(); -export function defineActionsPanelElement(): void { - if (!customElements.get(actionsPanelTagName)) customElements.define(actionsPanelTagName, PiWebActionsPanel); +export function defineTasksPanelElement(): void { + if (!customElements.get(tasksPanelTagName)) customElements.define(tasksPanelTagName, PiWebTasksPanel); } -export function actionsPanelBadge(workspace: Workspace): string | number | undefined { +export function tasksPanelBadge(workspace: Workspace): string | number | undefined { const state = getCachedWorkspaceConfig(workspace); if (state?.kind === "unavailable") return "!"; - if (state?.kind === "loaded" && state.config.actions.length > 0) return state.config.actions.length; + if (state?.kind === "loaded" && state.config.tasks.length > 0) return state.config.tasks.length; return undefined; } -class PiWebActionsPanel extends HTMLElement { +class PiWebTasksPanel extends HTMLElement { private workspaceValue: Workspace | undefined; private openTerminalValue: OpenTerminal | undefined; private terminalCommandRunsValue: InternalTerminalCommandRunsRuntime | undefined; - private runningActionId: string | undefined; - private status: ActionStatus | undefined; + private runningTaskId: string | undefined; + private status: TaskStatus | undefined; private readonly root: ShadowRoot; private readonly onConfigChanged = () => { this.render(); @@ -57,7 +57,7 @@ class PiWebActionsPanel extends HTMLElement { // Parent app updates should not rebuild this shadow DOM for the same workspace: // doing so resets the mobile scroll position and can replace buttons mid-click. if (previousKey === nextKey) return; - this.runningActionId = undefined; + this.runningTaskId = undefined; this.status = undefined; this.render(); } @@ -82,22 +82,22 @@ class PiWebActionsPanel extends HTMLElement { private render(): void { const workspace = this.workspaceValue; if (workspace === undefined) { - this.root.innerHTML = `${actionStyles()}

Select a workspace.
`; + this.root.innerHTML = `${taskStyles()}
Select a workspace.
`; return; } const state = getOrLoadWorkspaceConfig(workspace); this.root.innerHTML = ` - ${actionStyles()} + ${taskStyles()}
- Workspace Actions - + Workspace Tasks +
${this.renderStatus()} -
+
${this.renderConfigState(state)}
`; @@ -106,9 +106,9 @@ class PiWebActionsPanel extends HTMLElement { void this.refreshConfig(workspace); }); - for (const button of this.root.querySelectorAll("button[data-action-id]")) { + for (const button of this.root.querySelectorAll("button[data-task-id]")) { button.addEventListener("click", () => { - void this.dispatchActionById(workspace, button.getAttribute("data-action-id")); + void this.dispatchTaskById(workspace, button.getAttribute("data-task-id")); }); } @@ -117,15 +117,15 @@ class PiWebActionsPanel extends HTMLElement { }); } - private dispatchActionById(workspace: Workspace, actionId: string | null): Promise { + private dispatchTaskById(workspace: Workspace, taskId: string | null): Promise { if (!this.isCurrentWorkspace(workspace)) return Promise.resolve(); - const action = actionFromConfigState(getCachedWorkspaceConfig(workspace), actionId); - if (action === undefined) { - this.status = { kind: "error", message: "That action is no longer available. Click Refresh, then try again." }; + const task = taskFromConfigState(getCachedWorkspaceConfig(workspace), taskId); + if (task === undefined) { + this.status = { kind: "error", message: "That task is no longer available. Click Refresh, then try again." }; this.render(); return Promise.resolve(); } - return this.dispatchAction(workspace, action); + return this.dispatchTask(workspace, task); } private isCurrentWorkspace(workspace: Workspace): boolean { @@ -133,13 +133,14 @@ class PiWebActionsPanel extends HTMLElement { } private renderConfigState(state: ConfigState): string { - if (state.kind === "loading") return `

Loading ${escapeHtml(ACTIONS_CONFIG_PATH)}…

`; + if (state.kind === "loading") return `

Loading ${escapeHtml(TASKS_CONFIG_PATH)}…

`; if (state.kind === "missing") return renderMissingState(state); if (state.kind === "unavailable") return renderUnavailableState(state); - if (state.config.actions.length === 0) return `

No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.

`; + + if (state.config.tasks.length === 0) return `

No tasks are defined in ${escapeHtml(state.path)}. Add tasks to the file, then click Refresh.

`; return ` -

Actions run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.

- ${renderActionGroups(state.config.actions, this.runningActionId)} +

Tasks run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(state.path)} and click Refresh to reload.

+ ${renderTaskGroups(state.config.tasks, this.runningTaskId)} `; } @@ -150,26 +151,26 @@ class PiWebActionsPanel extends HTMLElement { } private async refreshConfig(workspace: Workspace): Promise { - this.status = { kind: "info", message: `Refreshing ${ACTIONS_CONFIG_PATH}…` }; + this.status = { kind: "info", message: `Refreshing ${TASKS_CONFIG_PATH}…` }; configCache.set(cacheKeyForWorkspace(workspace), { kind: "loading" }); this.render(); const state = await refreshWorkspaceConfig(workspace); if (!this.isCurrentWorkspace(workspace)) return; this.status = state.kind === "loaded" - ? { kind: "success", message: `Loaded ${String(state.config.actions.length)} action${state.config.actions.length === 1 ? "" : "s"}.` } + ? { kind: "success", message: `Loaded ${String(state.config.tasks.length)} task${state.config.tasks.length === 1 ? "" : "s"}.` } : undefined; this.render(); } - private async dispatchAction(workspace: Workspace, action: WorkspaceAction): Promise { - if (this.runningActionId !== undefined) { - this.status = { kind: "info", message: "Another action is already starting. Wait for it to finish dispatching, then try again." }; + private async dispatchTask(workspace: Workspace, task: WorkspaceTask): Promise { + if (this.runningTaskId !== undefined) { + this.status = { kind: "info", message: "Another task is already starting. Wait for it to finish dispatching, then try again." }; this.render(); return; } - if (action.confirm && !window.confirm(`Run ${action.title}?\n\n${action.command}`)) { - this.status = { kind: "info", message: `Cancelled ${action.title}.` }; + if (task.confirm && !window.confirm(`Run ${task.title}?\n\n${task.command}`)) { + this.status = { kind: "info", message: `Cancelled ${task.title}.` }; this.render(); return; } @@ -181,23 +182,23 @@ class PiWebActionsPanel extends HTMLElement { return; } - this.runningActionId = action.id; - this.status = { kind: "info", message: `Starting ${action.title}…` }; + this.runningTaskId = task.id; + this.status = { kind: "info", message: `Starting ${task.title}…` }; this.render(); try { - const handle = await runWorkspaceActionInTerminal(terminal, workspace, action); + const handle = await runWorkspaceTaskInTerminal(terminal, workspace, task); if (!this.isCurrentWorkspace(workspace)) return; this.status = { kind: "success", message: `Started terminal command “${handle.run.title}”.`, - detail: action.command, + detail: task.command, }; - this.runningActionId = undefined; + this.runningTaskId = undefined; this.render(); } catch (error) { if (!this.isCurrentWorkspace(workspace)) return; - this.runningActionId = undefined; + this.runningTaskId = undefined; this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) }; this.render(); } @@ -234,10 +235,10 @@ function getOrLoadWorkspaceConfig(workspace: Workspace): ConfigState { async function refreshWorkspaceConfig(workspace: Workspace): Promise { const key = cacheKeyForWorkspace(workspace); - const state = await loadWorkspaceActionsConfig(workspace).catch((error: unknown): ConfigState => ({ + const state = await loadWorkspaceTasksConfig(workspace).catch((error: unknown): ConfigState => ({ kind: "unavailable", - message: actionsConfigUnavailableMessage, - hint: actionsConfigRefreshHint, + message: tasksConfigUnavailableMessage, + hint: tasksConfigRefreshHint, detail: error instanceof Error ? error.message : String(error), })); configCache.set(key, state); @@ -259,64 +260,64 @@ function renderUnavailableState(state: Extract${escapeHtml(state.message)}

${escapeHtml(state.hint)}

${detail}`; } -function renderActionGroups(actions: WorkspaceAction[], runningActionId: string | undefined): string { - return `
${groupActions(actions).map((group) => renderActionGroup(group, runningActionId)).join("")}
`; +function renderTaskGroups(tasks: WorkspaceTask[], runningTaskId: string | undefined): string { + return `
${groupTasks(tasks).map((group) => renderTaskGroup(group, runningTaskId)).join("")}
`; } -function groupActions(actions: WorkspaceAction[]): { title: string | undefined; actions: WorkspaceAction[] }[] { - const groups: { title: string | undefined; actions: WorkspaceAction[] }[] = []; - for (const action of actions) { - const title = action.group; +function groupTasks(tasks: WorkspaceTask[]): { title: string | undefined; tasks: WorkspaceTask[] }[] { + const groups: { title: string | undefined; tasks: WorkspaceTask[] }[] = []; + for (const task of tasks) { + const title = task.group; let group = groups.find((candidate) => candidate.title === title); if (group === undefined) { - group = { title, actions: [] }; + group = { title, tasks: [] }; groups.push(group); } - group.actions.push(action); + group.tasks.push(task); } return groups; } -function renderActionGroup(group: { title: string | undefined; actions: WorkspaceAction[] }, runningActionId: string | undefined): string { +function renderTaskGroup(group: { title: string | undefined; tasks: WorkspaceTask[] }, runningTaskId: string | undefined): string { const title = group.title === undefined ? "" : `

${escapeHtml(group.title)}

`; - return `
${title}${group.actions.map((action) => renderAction(action, runningActionId)).join("")}
`; + return `
${title}${group.tasks.map((task) => renderTask(task, runningTaskId)).join("")}
`; } -function renderAction(action: WorkspaceAction, runningActionId: string | undefined): string { - const running = runningActionId === action.id; - const disabled = runningActionId !== undefined; - const description = action.description === undefined ? "" : `${escapeHtml(action.description)}`; +function renderTask(task: WorkspaceTask, runningTaskId: string | undefined): string { + const running = runningTaskId === task.id; + const disabled = runningTaskId !== undefined; + const description = task.description === undefined ? "" : `${escapeHtml(task.description)}`; return ` -
-
- ${escapeHtml(action.title)} +
+
+ ${escapeHtml(task.title)} ${description} - ${escapeHtml(action.command)} + ${escapeHtml(task.command)}
- +
`; } -function actionFromConfigState(state: ConfigState | undefined, actionId: string | null): WorkspaceAction | undefined { - if (state?.kind !== "loaded" || actionId === null) return undefined; - return state.config.actions.find((action) => action.id === actionId); +function taskFromConfigState(state: ConfigState | undefined, taskId: string | null): WorkspaceTask | undefined { + if (state?.kind !== "loaded" || taskId === null) return undefined; + return state.config.tasks.find((task) => task.id === taskId); } -function actionStyles(): string { +function taskStyles(): string { return ` `; diff --git a/pi-web-plugins/workspace-tasks/workspaceTasksClient.test.ts b/pi-web-plugins/workspace-tasks/workspaceTasksClient.test.ts new file mode 100644 index 0000000..e9cd758 --- /dev/null +++ b/pi-web-plugins/workspace-tasks/workspaceTasksClient.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import type { Workspace } from "@jmfederico/pi-web/plugin-api"; +import { TASKS_CONFIG_PATH } from "./config"; +import { loadWorkspaceTasksConfig, parseWorkspaceFileResponse, workspaceFileUrl, type FetchLike } from "./workspaceTasksClient"; + +const workspace: Workspace = { + id: "workspace 1", + projectId: "project/1", + path: "/repo", + label: "repo", + isMain: false, + isGitRepo: true, + isGitWorktree: true, +}; + +describe("workspace tasks client", () => { + it("builds the private workspace file URL", () => { + expect(workspaceFileUrl(workspace, TASKS_CONFIG_PATH)).toBe("/api/projects/project%2F1/workspaces/workspace%201/file?path=.pi-web%2Ftasks.json"); + }); + + it("loads and parses a valid tasks config", async () => { + const fetcher: FetchLike = () => Promise.resolve(jsonResponse({ + content: JSON.stringify({ version: 1, tasks: [{ id: "build", title: "Build", command: "npm run build" }] }), + truncated: false, + binary: false, + })); + + await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toEqual({ + kind: "loaded", + path: TASKS_CONFIG_PATH, + config: { + version: 1, + tasks: [{ id: "build", title: "Build", command: "npm run build", confirm: false }], + }, + }); + }); + + it("treats a missing optional tasks config as unconfigured", async () => { + const fetcher: FetchLike = () => Promise.resolve(missingResponse()); + + await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toEqual({ + kind: "missing", + message: "No workspace tasks configured here.", + hint: `${TASKS_CONFIG_PATH} is optional. Create it in this workspace if you want custom tasks.`, + }); + }); + + it("returns a visible unavailable state instead of throwing on request failures", async () => { + const fetcher: FetchLike = () => Promise.resolve(new Response(JSON.stringify({ error: "nope" }), { status: 400 })); + + await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({ + kind: "unavailable", + message: "Could not load workspace tasks.", + hint: `Fix ${TASKS_CONFIG_PATH}, then click Refresh.`, + detail: `Unable to read ${TASKS_CONFIG_PATH}: HTTP 400: nope`, + }); + }); + + it("returns parser details for invalid config files", async () => { + const fetcher: FetchLike = () => Promise.resolve(jsonResponse({ + content: JSON.stringify({ version: 2, tasks: [] }), + truncated: false, + binary: false, + })); + + await expect(loadWorkspaceTasksConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({ + kind: "unavailable", + detail: "Config version must be 1", + }); + }); + + it("validates workspace file responses", () => { + expect(parseWorkspaceFileResponse({ content: "{}", truncated: false, binary: false })).toEqual({ content: "{}", truncated: false, binary: false }); + expect(parseWorkspaceFileResponse({ content: "{}", truncated: "no", binary: false })).toBeUndefined(); + }); +}); + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } }); +} + +function missingResponse(): Response { + return new Response(JSON.stringify({ error: "Path does not exist" }), { status: 400 }); +} diff --git a/plugins/actions/src/workspaceActionsClient.ts b/pi-web-plugins/workspace-tasks/workspaceTasksClient.ts similarity index 59% rename from plugins/actions/src/workspaceActionsClient.ts rename to pi-web-plugins/workspace-tasks/workspaceTasksClient.ts index 3075433..835ba73 100644 --- a/plugins/actions/src/workspaceActionsClient.ts +++ b/pi-web-plugins/workspace-tasks/workspaceTasksClient.ts @@ -1,17 +1,17 @@ import type { Workspace } from "@jmfederico/pi-web/plugin-api"; -import { ACTIONS_CONFIG_PATH, parseActionsConfigText, type WorkspaceActionsConfig } from "./config.js"; +import { TASKS_CONFIG_PATH, parseTasksConfigText, type WorkspaceTasksConfig } from "./config.js"; -export const actionsConfigMissingMessage = "No workspace actions configured here."; -export const actionsConfigMissingHint = `${ACTIONS_CONFIG_PATH} is optional. Create it in this workspace if you want custom actions.`; -export const actionsConfigUnavailableMessage = "Could not load workspace actions."; -export const actionsConfigRefreshHint = `Fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`; +export const tasksConfigMissingMessage = "No workspace tasks configured here."; +export const tasksConfigMissingHint = `${TASKS_CONFIG_PATH} is optional. Create it in this workspace if you want custom tasks.`; +export const tasksConfigUnavailableMessage = "Could not load workspace tasks."; +export const tasksConfigRefreshHint = `Fix ${TASKS_CONFIG_PATH}, then click Refresh.`; const missingWorkspaceFileError = "Path does not exist"; export type FetchLike = (input: string, init?: RequestInit) => Promise; -export type WorkspaceActionsConfigLoadResult = - | { kind: "loaded"; config: WorkspaceActionsConfig } +export type WorkspaceTasksConfigLoadResult = + | { kind: "loaded"; config: WorkspaceTasksConfig; path: string } | { kind: "missing"; message: string; hint: string } | { kind: "unavailable"; message: string; hint: string; detail?: string }; @@ -21,39 +21,39 @@ interface WorkspaceFileResponse { binary: boolean; } -export async function loadWorkspaceActionsConfig( +export async function loadWorkspaceTasksConfig( workspace: Workspace, deps: { fetch: FetchLike } = { fetch: window.fetch.bind(window) }, -): Promise { +): Promise { let response: Response; try { - response = await deps.fetch(workspaceFileUrl(workspace, ACTIONS_CONFIG_PATH), { cache: "no-store" }); + response = await deps.fetch(workspaceFileUrl(workspace, TASKS_CONFIG_PATH), { cache: "no-store" }); } catch (error) { - return unavailable(`Unable to read ${ACTIONS_CONFIG_PATH}: ${formatUnknownError(error)}`); + return unavailable(`Unable to read ${TASKS_CONFIG_PATH}: ${formatUnknownError(error)}`); } if (!response.ok) { const errorMessage = await readResponseErrorMessage(response); if (errorMessage === missingWorkspaceFileError) return missing(); const responseSummary = errorMessage === undefined ? `HTTP ${String(response.status)}` : `HTTP ${String(response.status)}: ${errorMessage}`; - return unavailable(`Unable to read ${ACTIONS_CONFIG_PATH}: ${responseSummary}`); + return unavailable(`Unable to read ${TASKS_CONFIG_PATH}: ${responseSummary}`); } let body: unknown; try { body = await response.json(); } catch (error) { - return unavailable(`Invalid response while reading ${ACTIONS_CONFIG_PATH}: ${formatUnknownError(error)}`); + return unavailable(`Invalid response while reading ${TASKS_CONFIG_PATH}: ${formatUnknownError(error)}`); } const file = parseWorkspaceFileResponse(body); - if (file === undefined) return unavailable(`Invalid response while reading ${ACTIONS_CONFIG_PATH}`); - if (file.binary) return unavailable(`${ACTIONS_CONFIG_PATH} must be a text file`); - if (file.truncated) return unavailable(`${ACTIONS_CONFIG_PATH} is too large and was truncated`); + if (file === undefined) return unavailable(`Invalid response while reading ${TASKS_CONFIG_PATH}`); + if (file.binary) return unavailable(`${TASKS_CONFIG_PATH} must be a text file`); + if (file.truncated) return unavailable(`${TASKS_CONFIG_PATH} is too large and was truncated`); - const result = parseActionsConfigText(file.content); + const result = parseTasksConfigText(file.content); if (!result.ok) return unavailable(result.error); - return { kind: "loaded", config: result.config }; + return { kind: "loaded", config: result.config, path: TASKS_CONFIG_PATH }; } export function workspaceFileUrl(workspace: Workspace, path: string): string { @@ -69,19 +69,19 @@ export function parseWorkspaceFileResponse(value: unknown): WorkspaceFileRespons return { content, truncated, binary }; } -function missing(): WorkspaceActionsConfigLoadResult { +function missing(): WorkspaceTasksConfigLoadResult { return { kind: "missing", - message: actionsConfigMissingMessage, - hint: actionsConfigMissingHint, + message: tasksConfigMissingMessage, + hint: tasksConfigMissingHint, }; } -function unavailable(detail: string): WorkspaceActionsConfigLoadResult { +function unavailable(detail: string): WorkspaceTasksConfigLoadResult { return { kind: "unavailable", - message: actionsConfigUnavailableMessage, - hint: actionsConfigRefreshHint, + message: tasksConfigUnavailableMessage, + hint: tasksConfigRefreshHint, detail, }; } diff --git a/plugins/actions/CHANGELOG.md b/plugins/actions/CHANGELOG.md deleted file mode 100644 index e9a915d..0000000 --- a/plugins/actions/CHANGELOG.md +++ /dev/null @@ -1,40 +0,0 @@ -# @jmfederico/pi-web-actions - -## 0.1.2 - -### Patch Changes - -- 711c4f3: Run workspace deletion and configurable workspace actions in visible PI WEB terminals with reload-safe command-run tracking, mobile-friendly cancellation, and shell continuation after command completion. -- Updated dependencies [57a6a4a] -- Updated dependencies [34e657d] -- Updated dependencies [8247281] -- Updated dependencies [4bfd4ac] -- Updated dependencies [679008d] -- Updated dependencies [56fa641] -- Updated dependencies [711c4f3] - - @jmfederico/pi-web@1.202605.13 - -## 0.1.1 - -### Patch Changes - -- 698a899: Load and watch first-party workspace plugin packages from the single Pi Web development command without requiring local symlinks. -- fb7903f: Document and harden separate Pi Web plugin package development, including the Actions plugin refresh flow and public terminal navigation helper. -- 73fe658: Treat missing workspace actions configuration as an empty optional state instead of an error, with clearer guidance for invalid configs. -- Updated dependencies [1f06b25] -- Updated dependencies [619840a] -- Updated dependencies [9d4a017] -- Updated dependencies [698a899] -- Updated dependencies [fb7903f] -- Updated dependencies [32182a5] -- Updated dependencies [8fbdd6e] -- Updated dependencies [1f06b25] -- Updated dependencies [2631a63] -- Updated dependencies [3da2fcf] -- Updated dependencies [894c4d0] -- Updated dependencies [cf1b0ed] -- Updated dependencies [ea5d863] -- Updated dependencies [0a086c9] -- Updated dependencies [3cce6d2] -- Updated dependencies [e5bc87b] - - @jmfederico/pi-web@1.202605.11 diff --git a/plugins/actions/README.md b/plugins/actions/README.md deleted file mode 100644 index 81e70d4..0000000 --- a/plugins/actions/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# PI WEB Actions - -Configurable workspace actions for PI WEB. - -The plugin adds an **Actions** workspace tab. Actions run the configured shell command in a dedicated PI WEB terminal and switch to that terminal so the user can monitor progress. - -## Configuration - -Create `.pi-web/actions.json` in the workspace root where you want actions. The file is optional per workspace; workspaces without it simply show no actions. - -```json -{ - "version": 1, - "actions": [ - { - "id": "docker.start", - "title": "Start Docker", - "group": "Docker", - "description": "Start the local Docker Compose environment.", - "command": "./docker/scripts/docker-compose-dev up -d" - }, - { - "id": "db.reset", - "title": "Reset DB", - "group": "Database", - "command": "go -C klingit-go run ./cli db reset", - "confirm": true - } - ] -} -``` - -Fields: - -- `version`: must be `1`. -- `actions`: array of action definitions. -- `id`: stable action id, matching `^[a-z][a-z0-9.-]*$`. -- `title`: button label. -- `command`: literal shell command sent to the terminal. -- `description`: optional explanatory text. -- `group`: optional group heading. -- `confirm`: optional boolean. When true, the browser asks before dispatching the command. - -Commands run in the workspace root because PI WEB creates the terminal for that workspace. - -After editing `.pi-web/actions.json`, click **Refresh** in the Actions tab or reload the browser tab. The plugin does not watch the file automatically. - -## Development in this monorepo - -This package is developed as a separate npm package, not as a bundled PI WEB plugin. From the PI WEB repository, the single root dev command builds, watches, and auto-loads this package without symlinking it into `~/.pi-web/plugins`: - -```bash -npm run dev -``` - -Then reload PI WEB and check discovery: - -```bash -curl http://127.0.0.1:8504/pi-web-plugins/manifest.json -``` - -Build the package before publishing or packing: - -```bash -npm --workspace @jmfederico/pi-web-actions run build -npm pack --workspace @jmfederico/pi-web-actions --dry-run -``` - -## Beta/private API note - -This first-party plugin dogfoods PI WEB's internal terminal command-run helper for command execution while that API incubates. It also reads `.pi-web/actions.json` through PI WEB's private workspace file endpoint. These internals are not stable public plugin APIs yet, so compatibility is best-effort and may require updates alongside PI WEB releases. - -## Notes - -This plugin intentionally keeps v1 simple: - -- static JSON only; -- no variables or templating; -- every action creates a new terminal; -- command prompting/extra input should be handled by the script itself. diff --git a/plugins/actions/package.json b/plugins/actions/package.json deleted file mode 100644 index 78d924b..0000000 --- a/plugins/actions/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "@jmfederico/pi-web-actions", - "version": "0.1.2", - "description": "Configurable workspace actions plugin for PI WEB.", - "license": "MIT", - "type": "module", - "files": [ - "dist", - "README.md" - ], - "scripts": { - "dev": "tsc -w -p tsconfig.json", - "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run --config ../../vitest.config.ts", - "prepack": "npm run build" - }, - "keywords": [ - "pi-package", - "pi-web", - "pi-web-plugin", - "actions", - "workspace" - ], - "peerDependencies": { - "@jmfederico/pi-web": ">=1.202605.14" - }, - "peerDependenciesMeta": { - "@jmfederico/pi-web": { - "optional": true - } - }, - "devDependencies": { - "typescript": "^5.9.3", - "vitest": "^4.1.5" - }, - "piWeb": { - "plugins": [ - { - "id": "actions", - "module": "dist/pi-web-plugin.js" - } - ] - }, - "publishConfig": { - "access": "public" - } -} diff --git a/plugins/actions/src/actionRunner.ts b/plugins/actions/src/actionRunner.ts deleted file mode 100644 index e5d3f1f..0000000 --- a/plugins/actions/src/actionRunner.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Workspace } from "@jmfederico/pi-web/plugin-api"; -import type { WorkspaceAction } from "./config.js"; -import type { InternalTerminalCommandRunsRuntime } from "./piWebInternal.js"; - -export function runWorkspaceActionInTerminal(terminal: InternalTerminalCommandRunsRuntime, workspace: Workspace, action: WorkspaceAction): ReturnType { - return terminal.runCommand({ - workspace, - title: action.title, - command: action.command, - open: true, - metadata: { - "pi.plugin": "actions", - "action.id": action.id, - }, - }); -} diff --git a/plugins/actions/src/pi-web-plugin.ts b/plugins/actions/src/pi-web-plugin.ts deleted file mode 100644 index 22a5874..0000000 --- a/plugins/actions/src/pi-web-plugin.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api"; -import { ACTIONS_CONFIG_PATH } from "./config.js"; -import { actionsPanelBadge, defineActionsPanelElement } from "./actionsPanelElement.js"; -import { terminalCommandRunsFromContext } from "./piWebInternal.js"; - -const plugin: PiWebPlugin = { - apiVersion: 1, - name: "Workspace Actions", - activate: ({ pluginId, html }) => { - defineActionsPanelElement(); - - return { - contributions: { - actions: [ - { - id: "workspace.open-actions", - title: "Open Workspace Actions", - description: `Open the workspace Actions tab. Configure actions in ${ACTIONS_CONFIG_PATH}.`, - group: "Workspace", - enabled: (context) => context.state.selectedWorkspace !== undefined, - run: (context) => { - if (context.state.selectedWorkspace === undefined) return; - context.selectWorkspaceTool(`${pluginId}:workspace.actions`); - }, - }, - ], - workspacePanels: [ - { - id: "workspace.actions", - title: "Actions", - order: 40, - badge: ({ workspace }) => actionsPanelBadge(workspace), - render: (context) => html``, - }, - ], - }, - }; - }, -}; - -export default plugin; diff --git a/plugins/actions/src/workspaceActionsClient.test.ts b/plugins/actions/src/workspaceActionsClient.test.ts deleted file mode 100644 index f6057c9..0000000 --- a/plugins/actions/src/workspaceActionsClient.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { Workspace } from "@jmfederico/pi-web/plugin-api"; -import { ACTIONS_CONFIG_PATH } from "./config"; -import { loadWorkspaceActionsConfig, parseWorkspaceFileResponse, workspaceFileUrl, type FetchLike } from "./workspaceActionsClient"; - -const workspace: Workspace = { - id: "workspace 1", - projectId: "project/1", - path: "/repo", - label: "repo", - isMain: false, - isGitRepo: true, - isGitWorktree: true, -}; - -describe("workspace actions client", () => { - it("builds the private workspace file URL", () => { - expect(workspaceFileUrl(workspace, ACTIONS_CONFIG_PATH)).toBe("/api/projects/project%2F1/workspaces/workspace%201/file?path=.pi-web%2Factions.json"); - }); - - it("loads and parses a valid actions config", async () => { - const fetcher: FetchLike = () => Promise.resolve(jsonResponse({ - content: JSON.stringify({ version: 1, actions: [{ id: "build", title: "Build", command: "npm run build" }] }), - truncated: false, - binary: false, - })); - - await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toEqual({ - kind: "loaded", - config: { - version: 1, - actions: [{ id: "build", title: "Build", command: "npm run build", confirm: false }], - }, - }); - }); - - it("treats a missing optional actions config as unconfigured", async () => { - const fetcher: FetchLike = () => Promise.resolve(new Response(JSON.stringify({ error: "Path does not exist" }), { status: 400 })); - - await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toEqual({ - kind: "missing", - message: "No workspace actions configured here.", - hint: `${ACTIONS_CONFIG_PATH} is optional. Create it in this workspace if you want custom actions.`, - }); - }); - - it("returns a visible unavailable state instead of throwing on request failures", async () => { - const fetcher: FetchLike = () => Promise.resolve(new Response(JSON.stringify({ error: "nope" }), { status: 400 })); - - await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({ - kind: "unavailable", - message: "Could not load workspace actions.", - hint: `Fix ${ACTIONS_CONFIG_PATH}, then click Refresh.`, - detail: `Unable to read ${ACTIONS_CONFIG_PATH}: HTTP 400: nope`, - }); - }); - - it("returns parser details for invalid config files", async () => { - const fetcher: FetchLike = () => Promise.resolve(jsonResponse({ - content: JSON.stringify({ version: 2, actions: [] }), - truncated: false, - binary: false, - })); - - await expect(loadWorkspaceActionsConfig(workspace, { fetch: fetcher })).resolves.toMatchObject({ - kind: "unavailable", - detail: "Config version must be 1", - }); - }); - - it("validates workspace file responses", () => { - expect(parseWorkspaceFileResponse({ content: "{}", truncated: false, binary: false })).toEqual({ content: "{}", truncated: false, binary: false }); - expect(parseWorkspaceFileResponse({ content: "{}", truncated: "no", binary: false })).toBeUndefined(); - }); -}); - -function jsonResponse(value: unknown): Response { - return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } }); -} diff --git a/plugins/actions/tsconfig.json b/plugins/actions/tsconfig.json deleted file mode 100644 index 9423744..0000000 --- a/plugins/actions/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "noEmit": false, - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "sourceMap": true, - "types": [] - }, - "include": ["src/**/*.ts", "../../plugin-api.d.ts"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/scripts/build-plugins.mjs b/scripts/build-plugins.mjs index 1a626b6..1f2c0d8 100644 --- a/scripts/build-plugins.mjs +++ b/scripts/build-plugins.mjs @@ -40,7 +40,7 @@ async function buildDirectory(sourceDir, targetDir) { } if (!entry.isFile()) continue; - if (entry.name.endsWith(".d.ts")) continue; + if (entry.name.endsWith(".d.ts") || isTestSource(entry.name)) continue; if (isPluginSource(entry.name)) { await buildFile(sourcePath, targetPath.replace(/\.ts$/u, ".js")); @@ -94,6 +94,10 @@ function isPluginSource(fileName) { return fileName.endsWith(".ts") && !fileName.endsWith(".d.ts"); } +function isTestSource(fileName) { + return /\.(?:test|spec)\.ts$/u.test(fileName); +} + async function hasTypeScriptSource(javaScriptPath) { const typeScriptPath = javaScriptPath.replace(/\.js$/u, ".ts"); try { diff --git a/scripts/dev-plugin-packages.mjs b/scripts/dev-plugin-packages.mjs deleted file mode 100644 index 7aa3ae9..0000000 --- a/scripts/dev-plugin-packages.mjs +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { readdir, readFile } from "node:fs/promises"; -import { relative, resolve } from "node:path"; - -const cwd = process.cwd(); -const pluginsRoot = resolve(cwd, "plugins"); -const devPackages = await findPluginPackagesWithDevScripts(pluginsRoot); -const children = new Set(); -let stopping = false; - -process.on("SIGINT", () => { stopAndExit(130); }); -process.on("SIGTERM", () => { stopAndExit(143); }); - -if (devPackages.length === 0) { - console.log("[plugin-packages] no plugin package dev scripts found"); - await stayAlive(); -} - -for (const packageInfo of devPackages) startPackageDev(packageInfo); -console.log(`[plugin-packages] watching ${String(devPackages.length)} plugin package${devPackages.length === 1 ? "" : "s"}`); - -await stayAlive(); - -async function findPluginPackagesWithDevScripts(root) { - if (!existsSync(root)) return []; - const entries = await readdir(root, { withFileTypes: true }).catch(() => []); - const packages = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const dir = resolve(root, entry.name); - const packageInfo = await readPluginPackageInfo(dir); - if (packageInfo !== undefined) packages.push(packageInfo); - } - return packages.sort((left, right) => left.name.localeCompare(right.name)); -} - -async function readPluginPackageInfo(dir) { - const packagePath = resolve(dir, "package.json"); - const content = await readFile(packagePath, "utf8").catch(() => undefined); - if (content === undefined) return undefined; - const parsed = JSON.parse(content); - if (!isRecord(parsed)) return undefined; - const scripts = parsed["scripts"]; - if (!isRecord(scripts) || typeof scripts["dev"] !== "string") return undefined; - const rawName = parsed["name"]; - return { dir, name: typeof rawName === "string" && rawName !== "" ? rawName : relative(cwd, dir) }; -} - -function startPackageDev(packageInfo) { - const child = spawn("npm", ["run", "dev"], { - cwd: packageInfo.dir, - stdio: ["ignore", "pipe", "pipe"], - }); - children.add(child); - pipeWithPrefix(child.stdout, process.stdout, `[${packageInfo.name}]`); - pipeWithPrefix(child.stderr, process.stderr, `[${packageInfo.name}]`); - child.on("error", (error) => { - children.delete(child); - if (stopping) return; - console.error(`[plugin-packages] failed to start ${packageInfo.name} dev: ${error instanceof Error ? error.message : String(error)}`); - stopAndExit(1); - }); - child.on("exit", (code, signal) => { - children.delete(child); - if (stopping) return; - const reason = signal === null ? `code ${String(code ?? 0)}` : `signal ${signal}`; - console.error(`[plugin-packages] ${packageInfo.name} dev exited with ${reason}`); - stopAndExit(code === null || code === 0 ? 1 : code); - }); -} - -function pipeWithPrefix(stream, output, prefix) { - let pending = ""; - stream.setEncoding("utf8"); - stream.on("data", (chunk) => { - pending += chunk; - const lines = pending.split(/\r?\n/u); - pending = lines.pop() ?? ""; - for (const line of lines) output.write(`${prefix} ${line}\n`); - }); - stream.on("end", () => { - if (pending !== "") output.write(`${prefix} ${pending}\n`); - }); -} - -function stopAndExit(code) { - if (stopping) return; - stopping = true; - for (const child of children) child.kill("SIGTERM"); - setTimeout(() => { process.exit(code); }, 100); -} - -function isRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -async function stayAlive() { - await new Promise(() => undefined); -} diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 0b69beb..41ae306 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,3 +1,3 @@ -export { activityApi, api, configApi, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; +export { activityApi, api, configApi, filesApi, gitApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; -export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebReleaseStatus, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; +export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index e7abe35..09de448 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -18,6 +18,7 @@ import { parseModelSelectionResponse, parseOAuthFlowState, parsePiWebConfigResponse, + parsePiWebPluginsResponse, parsePiWebStatusResponse, parseProject, parseRestored, @@ -42,6 +43,10 @@ export const configApi = { saveConfig: (config: PiWebConfigValues) => request("/api/config", parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }), }; +export const pluginsApi = { + plugins: () => request("/api/plugins", parsePiWebPluginsResponse), +}; + export const activityApi = { workspaceActivity: () => request("/api/activity", parseWorkspaceActivityResponse), }; @@ -163,6 +168,7 @@ export const gitApi = { export const api = { ...piWebApi, ...configApi, + ...pluginsApi, ...activityApi, ...projectsApi, ...workspacesApi, diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 710119a..1c36ed0 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -1,23 +1,31 @@ import { describe, expect, it } from "vitest"; -import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers"; +import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers"; describe("API parsers", () => { it("parses PI WEB config responses", () => { expect(parsePiWebConfigResponse({ path: "/tmp/config.json", exists: true, - config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null } }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } }, effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true }, envOverrides: { host: true, port: false, allowedHosts: false }, })).toEqual({ path: "/tmp/config.json", exists: true, - config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null } }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } }, effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true }, envOverrides: { host: true, port: false, allowedHosts: false }, }); }); + it("parses PI WEB plugin status responses", () => { + expect(parsePiWebPluginsResponse({ + plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }], + })).toEqual({ + plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }], + }); + }); + it("accepts legacy array message pages and paged message responses", () => { expect(parseMessagePage(["a", "b"])).toEqual({ messages: ["a", "b"], start: 0, total: 2 }); expect(parseMessagePage({ messages: ["c"], start: 3, total: 9 })).toEqual({ messages: ["c"], start: 3, total: 9 }); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 2ca0ea3..0991a2a 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,4 +1,4 @@ -import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; +import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -375,6 +375,7 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues { ...optionalField("port", optionalNumber(record, "port")), ...optionalField("allowedHosts", optionalAllowedHosts(record["allowedHosts"])), ...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])), + ...optionalField("plugins", optionalPlugins(record["plugins"])), }; } @@ -394,11 +395,45 @@ function optionalShortcuts(value: unknown): PiWebShortcutConfig | undefined { })); } +function optionalPlugins(value: unknown): PiWebPluginConfigMap | undefined { + if (value === undefined) return undefined; + if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB plugins field"); + return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => { + if (!isRecord(config) || Array.isArray(config)) throw new Error("Invalid PI WEB plugin config field"); + const enabled = config["enabled"]; + if (enabled !== undefined && typeof enabled !== "boolean") throw new Error("Invalid PI WEB plugin enabled field"); + const settings = config["settings"]; + if (settings !== undefined && (!isRecord(settings) || Array.isArray(settings))) throw new Error("Invalid PI WEB plugin settings field"); + return [pluginId, config]; + })); +} + function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides { const record = requireRecord(value); return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts") }; } +export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse { + const record = requireRecord(value); + return { plugins: arrayOf(parsePiWebPluginInfo)(record["plugins"]) }; +} + +function parsePiWebPluginInfo(value: unknown): PiWebPluginInfo { + const record = requireRecord(value); + return { + id: requireString(record, "id"), + module: requireString(record, "module"), + source: requireString(record, "source"), + scope: parsePiWebPluginScope(record["scope"]), + enabled: requireBoolean(record, "enabled"), + }; +} + +function parsePiWebPluginScope(value: unknown): PiWebPluginScope { + if (value !== "bundled" && value !== "local" && value !== "user" && value !== "project") throw new Error("Invalid PI WEB plugin scope"); + return value; +} + export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse { const record = requireRecord(value); return { diff --git a/src/client/src/components/SettingsDialog.ts b/src/client/src/components/SettingsDialog.ts index 91c583c..3f601f7 100644 --- a/src/client/src/components/SettingsDialog.ts +++ b/src/client/src/components/SettingsDialog.ts @@ -1,9 +1,10 @@ import { css, html, LitElement, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import type { AppAction } from "../actions"; -import { configApi, type PiWebConfigResponse, type PiWebConfigValues } from "../api"; +import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api"; import type { SettingsSection } from "../settingsRoute"; import "./settings/SettingsGeneralPanel"; +import "./settings/SettingsPluginsPanel"; import "./settings/SettingsShortcutsPanel"; @customElement("settings-dialog") @@ -14,6 +15,7 @@ export class SettingsDialog extends LitElement { @property({ attribute: false }) onClose?: () => void; @property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void; @state() private configResponse: PiWebConfigResponse | undefined; + @state() private pluginsResponse: PiWebPluginsResponse | undefined; @state() private loading = true; @state() private saving = false; @state() private error = ""; @@ -45,6 +47,7 @@ export class SettingsDialog extends LitElement {
@@ -60,6 +63,20 @@ export class SettingsDialog extends LitElement { if (this.section === "shortcuts") { return html``; } + if (this.section === "plugins") { + return html` + this.loadConfig()} + .onTogglePlugin=${(pluginId: string, enabled: boolean) => this.togglePlugin(pluginId, enabled)} + > + `; + } return html` { + const baseConfig = this.configResponse?.config ?? {}; + const currentPlugins = baseConfig.plugins ?? {}; + const currentPluginConfig = currentPlugins[pluginId] ?? {}; + await this.saveConfig({ + ...baseConfig, + plugins: { + ...currentPlugins, + [pluginId]: { ...currentPluginConfig, enabled }, + }, + }); + await this.refreshPlugins(); + } + private async saveConfig(config: PiWebConfigValues): Promise { if (this.saving) return; this.saving = true; @@ -116,6 +149,14 @@ export class SettingsDialog extends LitElement { } } + private async refreshPlugins(): Promise { + try { + this.pluginsResponse = await pluginsApi.plugins(); + } catch (error) { + this.error = `Failed to refresh plugins: ${errorMessage(error)}`; + } + } + private showSavedMessage(): void { this.savedMessage = "Config saved."; if (this.savedMessageTimer !== undefined) window.clearTimeout(this.savedMessageTimer); diff --git a/src/client/src/components/settings/SettingsPluginsPanel.ts b/src/client/src/components/settings/SettingsPluginsPanel.ts new file mode 100644 index 0000000..3d5d6b1 --- /dev/null +++ b/src/client/src/components/settings/SettingsPluginsPanel.ts @@ -0,0 +1,99 @@ +import { css, html, LitElement, type TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { PiWebConfigResponse, PiWebPluginInfo, PiWebPluginsResponse } from "../../api"; + +@customElement("settings-plugins-panel") +export class SettingsPluginsPanel extends LitElement { + @property({ attribute: false }) pluginsResponse: PiWebPluginsResponse | undefined; + @property({ attribute: false }) configResponse: PiWebConfigResponse | undefined; + @property({ type: Boolean }) loading = false; + @property({ type: Boolean }) saving = false; + @property() error = ""; + @property() savedMessage = ""; + @property({ attribute: false }) onReload?: () => void | Promise; + @property({ attribute: false }) onTogglePlugin?: (pluginId: string, enabled: boolean) => void | Promise; + + override render(): TemplateResult { + const plugins = this.pluginsResponse?.plugins ?? []; + return html` +
+
+

Plugins

+

Enable or disable discovered PI WEB plugins. Changes apply after reloading the browser tab; already-loaded plugin code is not unloaded from the current page.

+
+ +
+ ${this.renderMessages()} +
Config key: plugins. Plugins are enabled unless their entry sets enabled to false.
+ ${this.loading && plugins.length === 0 ? html`
Loading plugins…
` : plugins.length === 0 ? html`
No external or bundled plugins discovered.
` : html` +
+ ${plugins.map((plugin) => this.renderPlugin(plugin))} +
+ `} + `; + } + + private renderMessages(): TemplateResult | null { + if (this.error !== "") return html`
${this.error}
`; + if (this.savedMessage !== "") return html`
${this.savedMessage} Reload the browser tab to apply plugin changes.
`; + return null; + } + + private renderPlugin(plugin: PiWebPluginInfo): TemplateResult { + const configured = this.configResponse?.config.plugins?.[plugin.id]; + const configuredState = configured?.enabled === false ? "Config disabled" : configured?.enabled === true ? "Config enabled" : "Default enabled"; + return html` +
+
+ ${plugin.id} + ${plugin.source} · ${plugin.scope} + ${configuredState} +
+ +
+ `; + } + + private async togglePlugin(plugin: PiWebPluginInfo, event: Event): Promise { + const enabled = event.target instanceof HTMLInputElement ? event.target.checked : plugin.enabled; + await this.onTogglePlugin?.(plugin.id, enabled); + } + + static override styles = css` + :host { display: block; } + .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } + .section-heading > div { display: grid; gap: 6px; min-width: 0; } + h2, p { margin: 0; } + h2 { font-size: 17px; line-height: 1.25; } + p { color: var(--pi-muted); line-height: 1.45; } + button, input { font: inherit; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } + button:disabled, input:disabled { opacity: .55; cursor: not-allowed; } + .secondary { flex: 0 0 auto; } + .message, .loading-card, .plugin-note, .plugin-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } + .message { margin-bottom: 12px; } + .error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); } + .success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); } + .loading-card, .plugin-note { color: var(--pi-muted); } + .plugin-note { margin-bottom: 14px; } + code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } + .plugin-list { display: grid; gap: 10px; } + .plugin-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; } + .plugin-card.disabled { opacity: .75; } + .plugin-main { min-width: 0; display: grid; gap: 3px; } + .plugin-main strong, .plugin-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .plugin-main small { color: var(--pi-muted); } + .toggle { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; } + .toggle input { width: 18px; height: 18px; accent-color: var(--pi-accent); } + + @media (max-width: 760px) { + .section-heading { display: grid; gap: 12px; } + .section-heading .secondary { justify-self: start; } + .plugin-card { grid-template-columns: minmax(0, 1fr); align-items: start; } + .toggle { justify-self: start; } + } + `; +} diff --git a/src/client/src/components/settings/settingsConfigDraft.test.ts b/src/client/src/components/settings/settingsConfigDraft.test.ts index 5c75c1c..13ee052 100644 --- a/src/client/src/components/settings/settingsConfigDraft.test.ts +++ b/src/client/src/components/settings/settingsConfigDraft.test.ts @@ -12,17 +12,18 @@ describe("settings config drafts", () => { expect(draftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all"); }); - it("converts drafts back to config while preserving shortcut preferences", () => { + it("converts drafts back to config while preserving shortcut and plugin preferences", () => { expect(configFromDraft({ host: " 127.0.0.1 ", port: "9000", allowedHostsMode: "list", allowedHostsText: "example.local, 192.168.1.20\n", - }, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null } })).toEqual({ + }, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } } })).toEqual({ host: "127.0.0.1", port: 9000, allowedHosts: ["example.local", "192.168.1.20"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, + plugins: { info: { enabled: false } }, }); }); }); diff --git a/src/client/src/components/settings/settingsConfigDraft.ts b/src/client/src/components/settings/settingsConfigDraft.ts index 4ef718c..d4aabc7 100644 --- a/src/client/src/components/settings/settingsConfigDraft.ts +++ b/src/client/src/components/settings/settingsConfigDraft.ts @@ -23,6 +23,7 @@ export function draftFromConfig(config: PiWebConfigValues): ConfigDraft { export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues { const config: PiWebConfigValues = { ...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }), + ...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }), }; const host = draft.host.trim(); const port = draft.port.trim(); diff --git a/src/client/src/settingsRoute.test.ts b/src/client/src/settingsRoute.test.ts index 5341d21..0666490 100644 --- a/src/client/src/settingsRoute.test.ts +++ b/src/client/src/settingsRoute.test.ts @@ -35,6 +35,7 @@ function installWindow(href: string): { pushed: string[]; replaced: string[] } { describe("settings route helpers", () => { it("parses supported settings deep links and aliases", () => { expect(parseSettingsSection("general")).toBe("general"); + expect(parseSettingsSection("plugins")).toBe("plugins"); expect(parseSettingsSection("shortcuts")).toBe("shortcuts"); expect(parseSettingsSection("keyboard")).toBe("shortcuts"); expect(parseSettingsSection("unknown")).toBeUndefined(); diff --git a/src/client/src/settingsRoute.ts b/src/client/src/settingsRoute.ts index 3f0e61f..ae05114 100644 --- a/src/client/src/settingsRoute.ts +++ b/src/client/src/settingsRoute.ts @@ -1,4 +1,4 @@ -export type SettingsSection = "general" | "shortcuts"; +export type SettingsSection = "general" | "plugins" | "shortcuts"; export function readSettingsSection(): SettingsSection | undefined { return parseSettingsSection(new URLSearchParams(window.location.search).get("settings")); @@ -17,6 +17,7 @@ export function writeSettingsSection(section: SettingsSection | undefined, optio export function parseSettingsSection(value: string | null): SettingsSection | undefined { if (value === "general") return "general"; + if (value === "plugins") return "plugins"; if (value === "shortcuts" || value === "keyboard" || value === "keyboard-shortcuts") return "shortcuts"; return undefined; } diff --git a/src/config.test.ts b/src/config.test.ts index 2430e5c..551da73 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -18,19 +18,25 @@ afterEach(async () => { describe("PI WEB config persistence", () => { it("writes and reads the configured PI WEB config path", () => { - const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null } }, testOptions()); + const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } } }, testOptions()); - expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null } } }); + expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } } } }); expect(loadPiWebConfig(testOptions())).toEqual(saved); }); it("preserves unrelated config keys while replacing managed keys", async () => { - await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, future: { enabled: true } }, null, 2)}\n`, "utf8"); + await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, future: { enabled: true } }, null, 2)}\n`, "utf8"); savePiWebConfig({ port: 9000, allowedHosts: [] }, testOptions()); expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [] }); }); + + it("rejects invalid plugin config", async () => { + await writeFile(configPath, `${JSON.stringify({ plugins: { info: { enabled: "no" } } }, null, 2)}\n`, "utf8"); + + expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config plugin enabled values must be booleans"); + }); }); function testOptions(): { env: NodeJS.ProcessEnv } { diff --git a/src/config.ts b/src/config.ts index 82c4dbb..5e1d6e3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import type { PiWebConfigValues } from "./shared/apiTypes.js"; +import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js"; export type PiWebConfig = PiWebConfigValues; @@ -75,6 +76,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): delete existing["port"]; delete existing["allowedHosts"]; delete existing["shortcuts"]; + delete existing["plugins"]; const merged = { ...existing, ...piWebConfigRecord(normalized) }; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8"); @@ -94,6 +96,7 @@ function piWebConfigRecord(config: PiWebConfig): Record { ...(config.port !== undefined ? { port: config.port } : {}), ...(config.allowedHosts !== undefined ? { allowedHosts: config.allowedHosts } : {}), ...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}), + ...(config.plugins !== undefined ? { plugins: config.plugins } : {}), }; } @@ -103,6 +106,7 @@ function parsePiWebConfig(value: Record, path: string): PiWebCo ...(value["port"] !== undefined ? { port: parsePort(value["port"], "port", path) } : {}), ...(value["allowedHosts"] !== undefined ? { allowedHosts: parseAllowedHosts(value["allowedHosts"], path) } : {}), ...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}), + ...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}), }; } @@ -140,6 +144,19 @@ function parseShortcuts(value: unknown, path: string): Record { + if (!isRecord(value) || Array.isArray(value)) throw new Error(`PI WEB config plugins must be an object: ${path}`); + return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => { + if (!isPiWebPluginId(pluginId)) throw new Error(`PI WEB config plugin ids must match ${piWebPluginIdPattern.source}: ${path}`); + if (!isRecord(config) || Array.isArray(config)) throw new Error(`PI WEB config plugin entries must be objects: ${path}`); + const enabled = config["enabled"]; + if (enabled !== undefined && typeof enabled !== "boolean") throw new Error(`PI WEB config plugin enabled values must be booleans: ${path}`); + const settings = config["settings"]; + if (settings !== undefined && (!isRecord(settings) || Array.isArray(settings))) throw new Error(`PI WEB config plugin settings must be objects: ${path}`); + return [pluginId, config]; + })); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 57a4375..f5d5afe 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -22,6 +22,7 @@ beforeEach(async () => { workspaces: new WorkspaceService(), piWebPlugins: { manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }), + plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", enabled: true }] }), readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined), }, clientDist: false, @@ -64,6 +65,10 @@ describe("buildApp", () => { 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 pluginsResponse = await app.inject({ method: "GET", url: "/api/plugins" }); + 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 }] }); + 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"); diff --git a/src/server/app.ts b/src/server/app.ts index da9869f..175c2fa 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -20,7 +20,7 @@ import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; export interface AppDependencies { projects?: ProjectService; workspaces?: WorkspaceService; - piWebPlugins?: Pick; + piWebPlugins?: Pick; config?: PiWebConfigService; clientDist?: string | false; logger?: FastifyServerOptions["logger"]; @@ -44,6 +44,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebStatus()); app.get("/api/pi-web/version", async () => getPiWebVersionStatus()); + app.get("/api/plugins", async () => piWebPlugins.plugins()); registerConfigRoutes(app, deps.config); app.get("/api/projects", async () => projects.list()); diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts index 536ba9c..599d2c5 100644 --- a/src/server/configRoutes.test.ts +++ b/src/server/configRoutes.test.ts @@ -37,11 +37,11 @@ describe("config routes", () => { const response = await app.inject({ method: "PUT", url: "/api/config", - payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null } } }, + payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } } }, }); expect(response.statusCode).toBe(200); - expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null } }); + expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } }); expect(response.json().config).toEqual(savedConfig); }); diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts index 22a4ade..46d83a7 100644 --- a/src/server/configRoutes.ts +++ b/src/server/configRoutes.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from "fastify"; import { effectivePiWebConfig, loadPiWebConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js"; import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; +import { isPiWebPluginId } from "../shared/pluginIds.js"; export interface PiWebConfigService { read: () => PiWebConfigResponse | Promise; @@ -56,6 +57,7 @@ function parseConfigRequest(value: unknown): PiWebConfig { const port = value["port"]; const allowedHosts = value["allowedHosts"]; const shortcuts = value["shortcuts"]; + const plugins = value["plugins"]; if (host !== undefined) { if (typeof host !== "string") throw new Error("PI WEB config host must be a string"); config.host = host; @@ -66,6 +68,7 @@ function parseConfigRequest(value: unknown): PiWebConfig { } if (allowedHosts !== undefined) config.allowedHosts = parseAllowedHostsRequest(allowedHosts); if (shortcuts !== undefined) config.shortcuts = parseShortcutsRequest(shortcuts); + if (plugins !== undefined) config.plugins = parsePluginsRequest(plugins); return config; } @@ -85,6 +88,19 @@ function parseShortcutsRequest(value: unknown): Record { })); } +function parsePluginsRequest(value: unknown): NonNullable { + if (!isRecord(value) || Array.isArray(value)) throw new Error("PI WEB config plugins must be an object"); + return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => { + if (!isPiWebPluginId(pluginId)) throw new Error("PI WEB config plugin ids are invalid"); + if (!isRecord(config) || Array.isArray(config)) throw new Error("PI WEB config plugin entries must be objects"); + const enabled = config["enabled"]; + if (enabled !== undefined && typeof enabled !== "boolean") throw new Error("PI WEB config plugin enabled values must be booleans"); + const settings = config["settings"]; + if (settings !== undefined && (!isRecord(settings) || Array.isArray(settings))) throw new Error("PI WEB config plugin settings must be objects"); + return [pluginId, config]; + })); +} + function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides { return { host: isEnvSet(env["PI_WEB_HOST"]), diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 42d7d9e..c28200d 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -88,6 +88,31 @@ describe("PiWebPluginService", () => { await expect(service.readAsset("dev", "pi-web-plugin.js")).resolves.toBeDefined(); }); + it("filters disabled plugins from the manifest while reporting them through plugin status", async () => { + await writePlugin(join(tempDir, "plugins", "enabled"), { + packageJson: { piWeb: { plugins: [{ id: "enabled", module: "pi-web-plugin.js" }] } }, + files: { "pi-web-plugin.js": "export default {};" }, + }); + await writePlugin(join(tempDir, "plugins", "disabled"), { + packageJson: { piWeb: { plugins: [{ id: "disabled", 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, + configProvider: () => ({ plugins: { disabled: { enabled: false, settings: { hidden: true } } } }), + }); + + await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "enabled" }] }); + await expect(service.plugins()).resolves.toMatchObject({ + plugins: [ + { id: "disabled", enabled: false }, + { id: "enabled", enabled: true }, + ], + }); + }); + it("skips duplicate plugin ids", async () => { await writePlugin(join(tempDir, "plugins", "one"), { packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } }, diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index 8590b7c..ce942af 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -3,15 +3,22 @@ import { readdir, readFile, realpath, stat } from "node:fs/promises"; 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"; +import { loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js"; +import type { PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope } from "../shared/apiTypes.js"; +import { isPiWebPluginId } from "../shared/pluginIds.js"; -const pluginIdPattern = /^[a-z][a-z0-9.-]*$/u; +export type { PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope } from "../shared/apiTypes.js"; export interface PiWebPluginManifest { - plugins: { id: string; module: string; source: string; scope: PiWebPluginScope }[]; + plugins: PiWebPluginManifestEntry[]; } -export type PiWebPluginScope = "bundled" | "local" | "user" | "project"; +export interface PiWebPluginManifestEntry { + id: string; + module: string; + source: string; + scope: PiWebPluginScope; +} export interface ConfiguredPiPackage { source: string; @@ -38,6 +45,7 @@ interface PiWebPluginServiceOptions { cwd?: string; agentDir?: string; packageProvider?: PiPackageProvider | false; + configProvider?: () => PiWebConfig; } interface LocalPluginRoot { @@ -80,28 +88,31 @@ export class DefaultPiPackageProvider implements PiPackageProvider { export class PiWebPluginService { private readonly roots: LocalPluginRoot[]; private readonly packageProvider: PiPackageProvider | undefined; + private readonly configProvider: () => PiWebConfig; constructor(options: PiWebPluginServiceOptions = {}) { const cwd = options.cwd ?? process.cwd(); const agentDir = options.agentDir ?? getAgentDir(); this.roots = options.roots ?? defaultPluginRoots(cwd); this.packageProvider = options.packageProvider === false ? undefined : options.packageProvider ?? new DefaultPiPackageProvider(cwd, agentDir); + this.configProvider = options.configProvider ?? (() => loadPiWebConfig({ cwd }).config); } async manifest(): Promise { - const plugins = await this.discoverPlugins(); return { - plugins: plugins.map((plugin) => ({ - id: plugin.id, - module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`, - source: plugin.source, - scope: plugin.scope, - })), + plugins: (await this.plugins()).plugins + .filter((plugin) => plugin.enabled) + .map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope })), }; } + async plugins(): Promise { + const [plugins, config] = await Promise.all([this.discoverPlugins(), Promise.resolve(this.configProvider())]); + return { plugins: plugins.map((plugin) => this.pluginInfo(plugin, config)) }; + } + async readAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> { - if (!pluginIdPattern.test(pluginId)) return undefined; + if (!isPiWebPluginId(pluginId)) return undefined; const plugin = (await this.discoverPlugins()).find((candidate) => candidate.id === pluginId); if (plugin === undefined) return undefined; @@ -118,6 +129,16 @@ export class PiWebPluginService { return { content: await readFile(realAsset), contentType: contentTypeFor(realAsset) }; } + private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo { + return { + id: plugin.id, + module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`, + source: plugin.source, + scope: plugin.scope, + enabled: config.plugins?.[plugin.id]?.enabled !== false, + }; + } + private async discoverPlugins(): Promise { const records = new Map(); for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin); @@ -173,7 +194,7 @@ async function discoverLocalRoot(root: LocalPluginRoot): Promise const entries = await readdir(root.path, { withFileTypes: true }).catch(() => []); const plugins: PluginRecord[] = []; for (const entry of entries) { - if (!pluginIdPattern.test(entry.name)) continue; + if (!isPiWebPluginId(entry.name)) continue; 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; @@ -236,7 +257,7 @@ function parsePluginEntries(piWeb: Record, packagePath: string) 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 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}`); return { id, module }; }); diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index c974b1d..63054d7 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -1,10 +1,33 @@ export type PiWebShortcutConfig = Record; +export type PiWebPluginSettings = Record; +export type PiWebPluginConfigMap = Record; + +export interface PiWebPluginConfig { + enabled?: boolean; + settings?: PiWebPluginSettings; + [key: string]: unknown; +} export interface PiWebConfigValues { host?: string; port?: number; allowedHosts?: string[] | true; shortcuts?: PiWebShortcutConfig; + plugins?: PiWebPluginConfigMap; +} + +export type PiWebPluginScope = "bundled" | "local" | "user" | "project"; + +export interface PiWebPluginInfo { + id: string; + module: string; + source: string; + scope: PiWebPluginScope; + enabled: boolean; +} + +export interface PiWebPluginsResponse { + plugins: PiWebPluginInfo[]; } export interface PiWebConfigEnvOverrides { diff --git a/src/shared/pluginIds.ts b/src/shared/pluginIds.ts new file mode 100644 index 0000000..a693aff --- /dev/null +++ b/src/shared/pluginIds.ts @@ -0,0 +1,5 @@ +export const piWebPluginIdPattern = /^[a-z][a-z0-9.-]*$/u; + +export function isPiWebPluginId(value: string): boolean { + return piWebPluginIdPattern.test(value); +} diff --git a/tsconfig.json b/tsconfig.json index 937bb87..40866c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,7 +36,6 @@ "vitest.config.ts", "extensions/**/*.ts", "pi-web-plugins/**/*.ts", - "plugins/**/*.ts", "plugin-api.d.ts" ] } diff --git a/vitest.config.ts b/vitest.config.ts index 5c2e530..e8f564d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["src/**/*.test.ts", "plugins/**/*.test.ts"], + include: ["src/**/*.test.ts", "pi-web-plugins/**/*.test.ts"], }, });