Archived
feat: bundle workspace tasks plugin
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Document built-in PI WEB plugins, including configuration guidance for Workspace Tasks.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Generated
-29
@@ -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
@@ -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",
|
||||
|
||||
+24
-24
@@ -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;
|
||||
+9
-9
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
+81
-80
@@ -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 });
|
||||
}
|
||||
+24
-24
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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" } });
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 } {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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" }] } },
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const piWebPluginIdPattern = /^[a-z][a-z0-9.-]*$/u;
|
||||
|
||||
export function isPiWebPluginId(value: string): boolean {
|
||||
return piWebPluginIdPattern.test(value);
|
||||
}
|
||||
@@ -36,7 +36,6 @@
|
||||
"vitest.config.ts",
|
||||
"extensions/**/*.ts",
|
||||
"pi-web-plugins/**/*.ts",
|
||||
"plugins/**/*.ts",
|
||||
"plugin-api.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -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"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user