feat: bundle workspace tasks plugin

This commit is contained in:
Federico Jaramillo Martinez
2026-06-03 22:31:36 +02:00
parent fda6fb0eca
commit 08f69d09c0
52 changed files with 879 additions and 681 deletions
-5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Document built-in PI WEB plugins, including configuration guidance for Workspace Tasks.
+5
View File
@@ -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.
@@ -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.
+5
View File
@@ -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.
+3 -1
View File
@@ -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.<plugin-id>.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
+106 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PI WEB plugins</title>
<meta name="description" content="Use and develop trusted local PI WEB UI plugins." />
<meta name="description" content="Use built-in PI WEB plugins and develop trusted local UI plugins." />
<meta property="og:title" content="PI WEB plugins" />
<meta property="og:image" content="assets/pi-web-banner.png" />
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
@@ -52,7 +52,7 @@
<section class="page-hero">
<div class="container">
<p class="eyebrow"><span class="pulse"></span> Plugin development</p>
<h1>Customize PI WEB with local UI plugins.</h1>
<h1>Customize PI WEB with UI plugins.</h1>
<p>
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 @@
<a href="#extend">What can be extended</a>
<a href="#ask-ai">What to ask AI to build</a>
<a href="#example">Canonical example</a>
<a href="#built-in-plugins">Built-in plugins</a>
<a href="#manage-plugins">Manage plugins</a>
<a href="#production">Production usage</a>
<a href="#agent-docs">AI-friendly docs</a>
<a href="#develop">Develop and debug</a>
@@ -165,6 +167,108 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
</p>
</section>
<section id="built-in-plugins">
<h2>Built-in plugins</h2>
<p>
PI WEB ships core, discoverable plugins in the main <code>@jmfederico/pi-web</code> npm package. No
separate <code>pi install</code> step is required: update PI WEB, reload the browser tab, and the bundled
plugins appear in <code>/pi-web-plugins/manifest.json</code>.
</p>
<p>
Built-in plugins can be managed from <strong>Settings → Plugins</strong> or with the top-level
<code>plugins</code> config key.
</p>
<h3>Workspace Tasks</h3>
<p>
<strong>Workspace Tasks</strong> adds a <strong>Tasks</strong> workspace tab for running configured shell
commands in dedicated PI WEB terminals. It is built into PI WEB and enabled by default.
</p>
<ul>
<li>Plugin id: <code>workspace-tasks</code></li>
<li>Config file: <code>.pi-web/tasks.json</code></li>
</ul>
<div class="code-card">
<div class="copy-row">
<strong>Disable Workspace Tasks</strong>
<button class="copy-button" data-copy="#workspace-tasks-disable">Copy</button>
</div>
<pre id="workspace-tasks-disable"><code>{
"plugins": {
"workspace-tasks": { "enabled": false }
}
}</code></pre>
</div>
<div class="code-card">
<div class="copy-row">
<strong>Example .pi-web/tasks.json</strong>
<button class="copy-button" data-copy="#workspace-tasks-config">Copy</button>
</div>
<pre id="workspace-tasks-config"><code>{
"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
}
]
}</code></pre>
</div>
<p>
Open a workspace, choose the <strong>Tasks</strong> tab, and click <strong>Run</strong> next to a task.
Commands run in the workspace root because PI WEB creates the terminal for that workspace.
</p>
<p>
Review task configs before running them, especially in shared projects. Workspace Tasks runs trusted
shell commands from your repositories.
</p>
</section>
<section id="manage-plugins">
<h2>Manage plugins</h2>
<p>
Open <strong>Settings → Plugins</strong> 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.
</p>
<div class="code-card">
<div class="copy-row">
<strong>Plugin config shape</strong>
<button class="copy-button" data-copy="#plugin-config-shape">Copy</button>
</div>
<pre id="plugin-config-shape"><code>{
"plugins": {
"workspace-tasks": {
"enabled": true,
"settings": {}
},
"info": {
"enabled": false
}
}
}</code></pre>
</div>
<p>
Plugins are enabled by default. Set <code>enabled</code> to <code>false</code> to remove a plugin from
<code>/pi-web-plugins/manifest.json</code> so it is not imported or activated on the next page load.
The optional <code>settings</code> object is reserved for plugin-specific settings.
</p>
<p>
After changing plugin enablement, reload the PI WEB browser tab. Already-loaded plugin JavaScript is not
unloaded from the current page.
</p>
</section>
<section id="production">
<h2>Production usage</h2>
<p>
+79 -15
View File
@@ -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 <package>` and `npm publish --workspace <package>` produce a usable plugin package;
- use a local symlink into `~/.pi-web/plugins/<plugin-id>` 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
-29
View File
@@ -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
}
}
}
}
}
+4 -10
View File
@@ -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",
@@ -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",
});
});
});
@@ -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<string>();
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,
@@ -0,0 +1,9 @@
{
"name": "@pi-web/workspace-tasks-plugin",
"private": true,
"piWeb": {
"plugins": [
{ "id": "workspace-tasks", "module": "pi-web-plugin.js" }
]
}
}
@@ -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`<pi-web-workspace-tasks-panel .workspace=${context.workspace} .terminalCommandRuns=${terminalCommandRunsFromContext(context)} .openTerminal=${context.openTerminal}></pi-web-workspace-tasks-panel>`,
},
],
},
};
},
};
export default plugin;
@@ -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<InternalTerminalCommandRunsRuntime["runCommand"]>(() => 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" },
});
});
});
@@ -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<InternalTerminalCommandRunsRuntime["runCommand"]> {
return terminal.runCommand({
workspace,
title: task.title,
command: task.command,
open: true,
metadata: {
"pi.plugin": "workspace-tasks",
"task.id": task.id,
},
});
}
@@ -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<string, ConfigState>();
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()}<section class="empty">Select a workspace.</section>`;
this.root.innerHTML = `${taskStyles()}<section class="empty">Select a workspace.</section>`;
return;
}
const state = getOrLoadWorkspaceConfig(workspace);
this.root.innerHTML = `
${actionStyles()}
${taskStyles()}
<section class="toolbar">
<strong>Workspace Actions</strong>
<span class="toolbar-actions">
<strong>Workspace Tasks</strong>
<span class="toolbar-tasks">
<button class="secondary" data-refresh-config ${state.kind === "loading" ? "disabled" : ""}>Refresh</button>
<button class="secondary" data-open-terminal>Open Terminal</button>
</span>
</section>
${this.renderStatus()}
<section class="viewer actions-viewer">
<section class="viewer tasks-viewer">
${this.renderConfigState(state)}
</section>
`;
@@ -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<void> {
private dispatchTaskById(workspace: Workspace, taskId: string | null): Promise<void> {
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 `<p class="muted">Loading ${escapeHtml(ACTIONS_CONFIG_PATH)}…</p>`;
if (state.kind === "loading") return `<p class="muted">Loading ${escapeHtml(TASKS_CONFIG_PATH)}…</p>`;
if (state.kind === "missing") return renderMissingState(state);
if (state.kind === "unavailable") return renderUnavailableState(state);
if (state.config.actions.length === 0) return `<p class="muted">No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.</p>`;
if (state.config.tasks.length === 0) return `<p class="muted">No tasks are defined in ${escapeHtml(state.path)}. Add tasks to the file, then click Refresh.</p>`;
return `
<p class="muted">Actions run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.</p>
${renderActionGroups(state.config.actions, this.runningActionId)}
<p class="muted">Tasks run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(state.path)} and click Refresh to reload.</p>
${renderTaskGroups(state.config.tasks, this.runningTaskId)}
`;
}
@@ -150,26 +151,26 @@ class PiWebActionsPanel extends HTMLElement {
}
private async refreshConfig(workspace: Workspace): Promise<void> {
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<void> {
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<void> {
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<ConfigState> {
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<ConfigState, { kind: "unavailable
return `<div class="status error"><strong>${escapeHtml(state.message)}</strong><p>${escapeHtml(state.hint)}</p>${detail}</div>`;
}
function renderActionGroups(actions: WorkspaceAction[], runningActionId: string | undefined): string {
return `<div class="actions">${groupActions(actions).map((group) => renderActionGroup(group, runningActionId)).join("")}</div>`;
function renderTaskGroups(tasks: WorkspaceTask[], runningTaskId: string | undefined): string {
return `<div class="tasks">${groupTasks(tasks).map((group) => renderTaskGroup(group, runningTaskId)).join("")}</div>`;
}
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 ? "" : `<h3>${escapeHtml(group.title)}</h3>`;
return `<section class="action-group">${title}${group.actions.map((action) => renderAction(action, runningActionId)).join("")}</section>`;
return `<section class="task-group">${title}${group.tasks.map((task) => renderTask(task, runningTaskId)).join("")}</section>`;
}
function renderAction(action: WorkspaceAction, runningActionId: string | undefined): string {
const running = runningActionId === action.id;
const disabled = runningActionId !== undefined;
const description = action.description === undefined ? "" : `<span>${escapeHtml(action.description)}</span>`;
function renderTask(task: WorkspaceTask, runningTaskId: string | undefined): string {
const running = runningTaskId === task.id;
const disabled = runningTaskId !== undefined;
const description = task.description === undefined ? "" : `<span>${escapeHtml(task.description)}</span>`;
return `
<article class="action-card">
<div class="action-copy">
<strong>${escapeHtml(action.title)}</strong>
<article class="task-card">
<div class="task-copy">
<strong>${escapeHtml(task.title)}</strong>
${description}
<code>${escapeHtml(action.command)}</code>
<code>${escapeHtml(task.command)}</code>
</div>
<button data-action-id="${escapeAttr(action.id)}" ${disabled ? "disabled" : ""}>${running ? "Dispatching…" : "Run"}</button>
<button data-task-id="${escapeAttr(task.id)}" ${disabled ? "disabled" : ""}>${running ? "Dispatching…" : "Run"}</button>
</article>
`;
}
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 `
<style>
:host { display: contents; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); }
.toolbar-actions { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.toolbar-tasks { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.viewer { box-sizing: border-box; min-height: 0; overflow: auto; padding: 12px; }
.actions-viewer { display: grid; align-content: start; gap: 12px; }
.actions { display: grid; gap: 14px; }
.action-group { display: grid; gap: 10px; }
.action-group h3 { margin: 4px 0 0; color: var(--pi-text-secondary); font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; }
.action-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.action-copy { display: grid; min-width: 0; gap: 5px; }
.action-copy span, .muted { color: var(--pi-muted); }
.tasks-viewer { display: grid; align-content: start; gap: 12px; }
.tasks { display: grid; gap: 14px; }
.task-group { display: grid; gap: 10px; }
.task-group h3 { margin: 4px 0 0; color: var(--pi-text-secondary); font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; }
.task-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.task-copy { display: grid; min-width: 0; gap: 5px; }
.task-copy span, .muted { color: var(--pi-muted); }
code, pre { border: 1px solid var(--pi-border-muted); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
code { overflow: auto; padding: 5px 7px; white-space: nowrap; }
pre { margin: 8px 0 0; overflow: auto; padding: 8px; white-space: pre-wrap; }
@@ -332,8 +333,8 @@ function actionStyles(): string {
.status.error { border-color: var(--pi-danger); color: var(--pi-danger); }
.empty { padding: 16px; color: var(--pi-muted); }
@media (max-width: 760px) {
.action-card { grid-template-columns: 1fr; }
.action-card button { justify-self: start; }
.task-card { grid-template-columns: 1fr; }
.task-card button { justify-self: start; }
}
</style>
`;
@@ -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 });
}
@@ -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<Response>;
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<WorkspaceActionsConfigLoadResult> {
): Promise<WorkspaceTasksConfigLoadResult> {
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,
};
}
-40
View File
@@ -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
-80
View File
@@ -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.
-48
View File
@@ -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"
}
}
-16
View File
@@ -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<InternalTerminalCommandRunsRuntime["runCommand"]> {
return terminal.runCommand({
workspace,
title: action.title,
command: action.command,
open: true,
metadata: {
"pi.plugin": "actions",
"action.id": action.id,
},
});
}
-41
View File
@@ -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`<pi-web-actions-panel .workspace=${context.workspace} .terminalCommandRuns=${terminalCommandRunsFromContext(context)} .openTerminal=${context.openTerminal}></pi-web-actions-panel>`,
},
],
},
};
},
};
export default plugin;
@@ -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" } });
}
-13
View File
@@ -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"]
}
+5 -1
View File
@@ -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 {
-101
View File
@@ -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);
}
+2 -2
View File
@@ -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";
+6
View File
@@ -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,
+11 -3
View File
@@ -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 });
+36 -1
View File
@@ -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<string, unknown> {
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 {
+44 -3
View File
@@ -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 {
<div class="settings-body">
<nav class="settings-nav" aria-label="Settings sections">
${this.renderNavButton("general", "General", "Server config")}
${this.renderNavButton("plugins", "Plugins", "Enable and disable")}
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
</nav>
<main class="settings-content">
@@ -60,6 +63,20 @@ export class SettingsDialog extends LitElement {
if (this.section === "shortcuts") {
return html`<settings-shortcuts-panel .actions=${this.actions} .configResponse=${this.configResponse}></settings-shortcuts-panel>`;
}
if (this.section === "plugins") {
return html`
<settings-plugins-panel
.configResponse=${this.configResponse}
.pluginsResponse=${this.pluginsResponse}
.loading=${this.loading}
.saving=${this.saving}
.error=${this.error}
.savedMessage=${this.savedMessage}
.onReload=${() => this.loadConfig()}
.onTogglePlugin=${(pluginId: string, enabled: boolean) => this.togglePlugin(pluginId, enabled)}
></settings-plugins-panel>
`;
}
return html`
<settings-general-panel
.configResponse=${this.configResponse}
@@ -91,14 +108,30 @@ export class SettingsDialog extends LitElement {
this.loading = true;
this.error = "";
try {
this.configResponse = await configApi.config();
const [config, plugins] = await Promise.all([configApi.config(), pluginsApi.plugins()]);
this.configResponse = config;
this.pluginsResponse = plugins;
} catch (error) {
this.error = `Failed to load config: ${errorMessage(error)}`;
this.error = `Failed to load settings: ${errorMessage(error)}`;
} finally {
this.loading = false;
}
}
private async togglePlugin(pluginId: string, enabled: boolean): Promise<void> {
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<void> {
if (this.saving) return;
this.saving = true;
@@ -116,6 +149,14 @@ export class SettingsDialog extends LitElement {
}
}
private async refreshPlugins(): Promise<void> {
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);
@@ -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<void>;
@property({ attribute: false }) onTogglePlugin?: (pluginId: string, enabled: boolean) => void | Promise<void>;
override render(): TemplateResult {
const plugins = this.pluginsResponse?.plugins ?? [];
return html`
<div class="section-heading">
<div>
<h2>Plugins</h2>
<p>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.</p>
</div>
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
${this.renderMessages()}
<div class="plugin-note">Config key: <code>plugins</code>. Plugins are enabled unless their entry sets <code>enabled</code> to <code>false</code>.</div>
${this.loading && plugins.length === 0 ? html`<div class="loading-card">Loading plugins…</div>` : plugins.length === 0 ? html`<div class="loading-card">No external or bundled plugins discovered.</div>` : html`
<div class="plugin-list">
${plugins.map((plugin) => this.renderPlugin(plugin))}
</div>
`}
`;
}
private renderMessages(): TemplateResult | null {
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`;
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage} Reload the browser tab to apply plugin changes.</div>`;
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`
<article class=${`plugin-card${plugin.enabled ? "" : " disabled"}`}>
<div class="plugin-main">
<strong>${plugin.id}</strong>
<small>${plugin.source} · ${plugin.scope}</small>
<small>${configuredState}</small>
</div>
<label class="toggle">
<input type="checkbox" .checked=${plugin.enabled} ?disabled=${this.saving} @change=${(event: Event) => { void this.togglePlugin(plugin, event); }}>
<span>${plugin.enabled ? "Enabled" : "Disabled"}</span>
</label>
</article>
`;
}
private async togglePlugin(plugin: PiWebPluginInfo, event: Event): Promise<void> {
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; }
}
`;
}
@@ -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 } },
});
});
});
@@ -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();
+1
View File
@@ -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();
+2 -1
View File
@@ -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;
}
+9 -3
View File
@@ -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 } {
+17
View File
@@ -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<string, unknown> {
...(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<string, unknown>, 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<string, string | n
}));
}
function parsePlugins(value: unknown, path: string): NonNullable<PiWebConfigValues["plugins"]> {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+5
View File
@@ -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");
+2 -1
View File
@@ -20,7 +20,7 @@ import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
export interface AppDependencies {
projects?: ProjectService;
workspaces?: WorkspaceService;
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "readAsset">;
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
config?: PiWebConfigService;
clientDist?: string | false;
logger?: FastifyServerOptions["logger"];
@@ -44,6 +44,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
app.get("/api/pi-web/status", async () => 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());
+2 -2
View File
@@ -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<PiWebConfigResponse>().config).toEqual(savedConfig);
});
+16
View File
@@ -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<PiWebConfigResponse>;
@@ -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<string, string | null> {
}));
}
function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]> {
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"]),
+25
View File
@@ -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" }] } },
+35 -14
View File
@@ -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<PiWebPluginManifest> {
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<PiWebPluginsResponse> {
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<PluginRecord[]> {
const records = new Map<string, PluginRecord>();
for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin);
@@ -173,7 +194,7 @@ async function discoverLocalRoot(root: LocalPluginRoot): Promise<PluginRecord[]>
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<string, unknown>, 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 };
});
+23
View File
@@ -1,10 +1,33 @@
export type PiWebShortcutConfig = Record<string, string | null>;
export type PiWebPluginSettings = Record<string, unknown>;
export type PiWebPluginConfigMap = Record<string, PiWebPluginConfig>;
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 {
+5
View File
@@ -0,0 +1,5 @@
export const piWebPluginIdPattern = /^[a-z][a-z0-9.-]*$/u;
export function isPiWebPluginId(value: string): boolean {
return piWebPluginIdPattern.test(value);
}
-1
View File
@@ -36,7 +36,6 @@
"vitest.config.ts",
"extensions/**/*.ts",
"pi-web-plugins/**/*.ts",
"plugins/**/*.ts",
"plugin-api.d.ts"
]
}
+1 -1
View File
@@ -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"],
},
});