feat: add Pi package management settings

This commit is contained in:
Federico Jaramillo Martinez
2026-07-01 15:34:34 +02:00
parent 3f36394c17
commit 8ade238228
28 changed files with 1009 additions and 51 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add PI WEB settings for managing Pi packages separately from PI WEB plugin enablement, including install/remove/update flows and browser/session reload guidance.
+4 -2
View File
@@ -103,7 +103,9 @@ Read more: [Fleet and machines guide](https://pi-web.dev/machines)
## Plugins
PI WEB supports trusted local browser-side plugins that can add actions, workspace panels, and workspace metadata.
PI WEB supports trusted browser-side PI WEB plugins that can add actions, workspace panels, and workspace metadata.
Pi packages are managed separately through Pi's package manager or **Settings → Pi packages**. Use **Settings → PI WEB plugins** only to enable or disable discovered browser plugins.
Read more: [Plugin API](https://pi-web.dev/plugins)
@@ -122,7 +124,7 @@ Project-local PI WEB config lives at:
<project>/.pi-web/config.json
```
Common configuration includes host/port, path access, uploads, plugins, shortcuts, and session daemon options.
Common configuration includes host/port, path access, uploads, PI WEB plugin enablement, shortcuts, and session daemon options.
Read more: [Configuration reference](https://pi-web.dev/config)
+15 -7
View File
@@ -80,8 +80,8 @@
<h1>Configure PI WEB where your agents work.</h1>
<p>
PI WEB configuration covers the machine-local and project-local settings you usually need: bind address,
trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, upload
limits, and session-daemon tools.
trusted development-host settings, UI preferences, PI WEB plugin enablement, file-explorer path access,
upload limits, and session-daemon tools.
</p>
</div>
</section>
@@ -112,6 +112,12 @@
Each PI WEB machine has its own config. When using Fleet/machine federation, edit a remote machine's
config by opening that machine directly or changing files on that machine.
</p>
<p>
Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings and are
managed by Pi (<code>pi install</code>, <code>pi remove</code>, <code>pi update</code>) or
<strong>Settings → Pi packages</strong>. The PI WEB <code>plugins</code> config key only enables or
disables discovered PI WEB browser plugins.
</p>
<p>
If you installed services with a custom config path, rerun
<code>pi-web install --config /path/to/config.json</code> after changing that path or after upgrading from
@@ -136,7 +142,8 @@
<li><code>maxUploadBytes</code>: restart both the web/API process and the session daemon.</li>
<li><code>spawnSessions</code> / <code>subsessions</code>: restart the session daemon.</li>
<li><code>pathAccess</code>: applies on the next request; existing file views may need a browser refresh.</li>
<li><code>plugins</code>: reload the browser tab after changing plugin enablement.</li>
<li><code>plugins</code>: reload the browser tab after changing PI WEB plugin enablement.</li>
<li>Pi package install/remove/update: not a PI WEB config key; after a mutation, reload the browser page for newly discovered PI WEB browser plugins and reload existing Pi sessions (or use <code>/reload</code> in Pi) for session-runtime resources. A routine session daemon restart is not required.</li>
<li><code>shortcuts</code>: saved settings apply in the browser after config refresh/save.</li>
</ul>
</section>
@@ -144,9 +151,10 @@
<section id="global-config">
<h2>Global config example</h2>
<p>
<code>pi-web install</code> creates the initial file. You can also save settings from
<strong>Settings → General</strong>, <strong>Settings → Plugins</strong>, <strong>Settings → Keyboard</strong>,
and <strong>Settings → Session daemon</strong>.
<code>pi-web install</code> creates the initial file. You can also save PI WEB config settings from
<strong>Settings → General</strong>, <strong>Settings → PI WEB plugins</strong>,
<strong>Settings → Keyboard</strong>, and <strong>Settings → Session daemon</strong>. Pi package operations
live separately under <strong>Settings → Pi packages</strong>.
</p>
<div class="code-card">
<div class="copy-row">
@@ -279,7 +287,7 @@
<td>Restart session daemon</td>
</tr>
<tr>
<td>Plugin enablement/settings</td>
<td>PI WEB plugin enablement/settings</td>
<td><code>plugins.&lt;id&gt;.enabled</code>, <code>plugins.&lt;id&gt;.settings</code></td>
<td></td>
<td>Global</td>
+6 -1
View File
@@ -13,6 +13,8 @@ PI WEB uses two config files:
Each PI WEB machine has its own config. When using Fleet/machine federation, edit a remote machine's config by opening that machine directly or changing files on that machine.
Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings and are managed by Pi (`pi install`, `pi remove`, `pi update`) or **Settings → Pi packages**. The PI WEB `plugins` config key only enables or disables discovered PI WEB browser plugins.
If you installed services with a custom config path, rerun `pi-web install --config /path/to/config.json` after changing that path or after upgrading from a version that only applied the custom path to the web service. This regenerates service files so the web/API and session daemon use the same `PI_WEB_CONFIG`.
## Precedence and reloads
@@ -34,7 +36,8 @@ Process restarts depend on the key:
- `spawnSessions` / `subsessions`: restart the session daemon.
- `pathAccess`: applies on the next request; existing file views may need a browser refresh.
- `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
- `plugins`: reload the browser tab after changing plugin enablement.
- `plugins`: reload the browser tab after changing PI WEB plugin enablement.
- Pi package install/remove/update: not a PI WEB config key; after a mutation, reload the browser page for newly discovered PI WEB browser plugins and reload existing Pi sessions (or use `/reload` in Pi) for session-runtime resources. A routine session daemon restart is not required.
- `shortcuts`: saved settings apply in the browser after config refresh/save.
## Global config example
@@ -170,6 +173,8 @@ Tracked subsessions let an agent delegate work to child sessions, get notified w
### Plugin config
The `plugins` key is only for PI WEB browser plugin enablement/settings. It does not install, remove, or update Pi packages; use **Settings → Pi packages** or Pi's package manager for package operations.
Plugins are enabled by default. Set `plugins.<id>.enabled` to `false` to remove a plugin from `/pi-web-plugins/manifest.json` before the browser imports it.
```json
+2 -1
View File
@@ -281,7 +281,8 @@
</div>
<p>
Use <strong>Settings → General</strong> for host, port, and external filesystem roots; <strong>Settings → Session daemon</strong>
for agent-spawn tools; <strong>Settings → Plugins</strong> for plugin enablement; and <strong>Settings → Keyboard</strong>
for agent-spawn tools; <strong>Settings → Pi packages</strong> for Pi package install/remove/update;
<strong>Settings → PI WEB plugins</strong> for browser plugin enablement; and <strong>Settings → Keyboard</strong>
for shortcut overrides.
</p>
<div class="callout">
+39 -7
View File
@@ -90,10 +90,11 @@
<aside class="toc" aria-label="Plugin page contents">
<strong>On this page</strong>
<a href="#extend">What can be extended</a>
<a href="#packages-vs-plugins">Pi packages vs PI WEB plugins</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="#manage-plugins">Manage PI WEB plugins</a>
<a href="#remote-machine-plugins">Remote machine plugins</a>
<a href="#production">Production usage</a>
<a href="#agent-docs">AI-friendly docs</a>
@@ -125,6 +126,33 @@
</div>
</section>
<section id="packages-vs-plugins">
<h2>Pi packages vs PI WEB plugins</h2>
<p>
<strong>Pi packages</strong> are packages managed by Pi (<code>pi install</code>, <code>pi remove</code>,
<code>pi update</code>). A Pi package can provide extensions, skills, prompt templates, themes, and/or
PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin.
</p>
<p>
<strong>PI WEB plugins</strong> are browser-side UI modules discovered from bundled, local, dev, and
installed Pi-package sources. Enabling or disabling a PI WEB plugin is a PI WEB config task; installing,
removing, or updating a Pi package is a Pi package-manager task.
</p>
<p>
Use <strong>Settings → Pi packages</strong> to view configured Pi packages or install/remove/update a
package. Enter only the package source, such as <code>npm:@scope/package</code>, a git/URL source, or a
local path. PI WEB uses Pi's default package location, equivalent to <code>pi install &lt;source&gt;</code>,
and does not ask for an install location.
</p>
<p>
Use <strong>Settings → PI WEB plugins</strong> to enable or disable discovered PI WEB browser plugins
before the browser imports them. After installing, removing, or updating a Pi package, reload the browser
page to import newly discovered PI WEB browser plugins. Reload existing Pi sessions, or use
<code>/reload</code> in Pi, so extensions, skills, prompt templates, and themes are rediscovered. A
routine session daemon restart is not required.
</p>
</section>
<section id="ask-ai">
<h2>What to ask AI to build</h2>
<p>
@@ -204,7 +232,7 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
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
Built-in plugins can be managed from <strong>Settings → PI WEB plugins</strong> or with the top-level
<code>plugins</code> config key.
</p>
@@ -285,12 +313,16 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
</section>
<section id="manage-plugins">
<h2>Manage plugins</h2>
<h2>Manage PI WEB plugins</h2>
<p>
Open <strong>Settings → Plugins</strong> to review discovered bundled, local, dev, and Pi package plugins
for the PI WEB gateway you opened. 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.
Open <strong>Settings → PI WEB plugins</strong> to review discovered bundled, local, dev, and Pi package
plugins for the PI WEB gateway you opened. 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>
<p>
This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may
provide plugins or other Pi resources, use <strong>Settings → Pi packages</strong>.
</p>
<div class="code-card">
<div class="copy-row">
+18 -6
View File
@@ -13,6 +13,16 @@ Plugins can currently:
They do **not** run in the session daemon, do not get a server-side hook API, and are not sandboxed.
## Pi packages vs PI WEB plugins
**Pi packages** are packages managed by Pi (`pi install`, `pi remove`, `pi update`). A Pi package can provide extensions, skills, prompt templates, themes, and/or PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin.
**PI WEB plugins** are browser-side PI WEB UI modules discovered from bundled, local, dev, and installed Pi-package sources. Enabling or disabling a PI WEB plugin is a PI WEB config task; installing, removing, or updating a Pi package is a Pi package-manager task.
Use **Settings → Pi packages** to view configured Pi packages or install/remove/update a package. Enter only the package source, such as `npm:@scope/package`, a git/URL source, or a local path. PI WEB uses Pi's default package location, equivalent to `pi install <source>`, and does not ask for an install location.
Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. After installing, removing, or updating a Pi package, reload the browser page to import newly discovered PI WEB browser plugins. Reload existing Pi sessions, or use `/reload` in Pi, so session-runtime resources such as extensions, skills, prompt templates, and themes are rediscovered. A routine session daemon restart is not required.
## Trust model
Plugins run as JavaScript in the browser app. Treat them as trusted code:
@@ -155,9 +165,11 @@ const url = new URL("./asset.json", import.meta.url);
If a remote plugin constructs absolute asset URLs, it should use the `pluginId` from `activate()` because PI WEB gives remote plugins a gateway-scoped runtime id. Hard-coded `/pi-web-plugins/<original-id>/...` URLs may point at the gateway instead of the remote machine.
## Manage plugins
## Manage PI WEB plugins
Open **Settings → Plugins** to review discovered bundled, local, dev, and Pi package plugins for the PI WEB gateway you opened. PI WEB can disable any discovered gateway 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.
Open **Settings → PI WEB plugins** to review discovered bundled, local, dev, and Pi package plugins for the PI WEB gateway you opened. PI WEB can disable any discovered gateway 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.
This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may provide plugins or other Pi resources, use **Settings → Pi packages**.
Plugin preferences are stored under the top-level `plugins` config key in the PI WEB config file:
@@ -183,14 +195,14 @@ After changing plugin enablement, reload the PI WEB browser tab. Already-loaded
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.
Built-in plugins can be managed from **Settings → PI WEB plugins** or with the top-level `plugins` config key.
### Updates
**Plugin id:** `updates`
**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance.
Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab only appears for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → Plugins** or set:
Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab only appears for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → PI WEB plugins** or set:
```json
{
@@ -206,7 +218,7 @@ Updates is enabled by default. It declares `machineSpecific: true` so the gatewa
**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:
Workspace Tasks is enabled by default. To hide it, disable `workspace-tasks` in **Settings → PI WEB plugins** or set:
```json
{
@@ -273,7 +285,7 @@ PI WEB builds the gateway `/pi-web-plugins/manifest.json` from these sources:
Entries may be real directories or symlinks. This is the recommended development workflow.
3. Installed Pi packages that expose PI WEB plugin metadata. Pi packages may be user or project scoped.
3. Installed Pi packages that expose PI WEB plugin metadata. Pi packages may be user or project scoped. Installing/removing/updating Pi packages is done from **Settings → Pi packages** (or Pi's package manager), not from the PI WEB plugin enable/disable list.
Remote machines expose their own manifests through the gateway at `/api/machines/<machine-id>/pi-web-plugins/manifest.json`. Those plugin modules are rewritten to gateway-scoped asset URLs and registered under machine-scoped runtime ids so duplicate plugin ids on different machines do not collide.
+2 -2
View File
@@ -1,5 +1,5 @@
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+33 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
import { filesApi, machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
import { filesApi, machinesApi, piPackagesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
const workspace: Workspace = {
id: "w/1",
@@ -60,6 +60,38 @@ describe("machine-scoped runtime API", () => {
});
});
describe("Pi package API", () => {
it("uses the local Pi package-management routes for list and mutations", async () => {
const packages = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
const fetchMock = stubSequenceFetch([
jsonResponse({ packages }),
jsonResponse({ action: "install", source: "npm:@acme/new-tools", packages }),
jsonResponse({ action: "remove", source: "../project-tools", scope: "project", removed: true, packages }),
jsonResponse({ action: "update", source: "npm:@acme/tools", packages }),
jsonResponse({ action: "update", packages }),
]);
await expect(piPackagesApi.packages()).resolves.toEqual({ packages });
await piPackagesApi.install("npm:@acme/new-tools");
await piPackagesApi.remove("../project-tools", "project");
await piPackagesApi.update("npm:@acme/tools");
await piPackagesApi.update();
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/pi-packages",
"/api/pi-packages/install",
"/api/pi-packages/remove",
"/api/pi-packages/update",
"/api/pi-packages/update",
]);
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ source: "npm:@acme/new-tools" });
expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ source: "../project-tools", scope: "project" });
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "npm:@acme/tools" });
expect(fetchCall(fetchMock, 4)[1]?.body).toBeUndefined();
});
});
describe("session API compatibility", () => {
it("posts session cleanup preview and execute requests through the selected machine", async () => {
const preview = { generatedAt: "2026-06-25T12:00:00.000Z", thresholds: { archiveIdleDays: 7 }, projects: [{ cwd: "/repo", archiveCount: 2, deleteCount: 0 }], totals: { archiveCount: 2, deleteCount: 0 } };
+20 -1
View File
@@ -1,4 +1,4 @@
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import { request } from "./http";
import {
arrayOf,
@@ -24,6 +24,8 @@ import {
parseModelSelectionResponse,
parseMoveWorkspaceFileResponse,
parseOAuthFlowState,
parsePiPackageMutationResponse,
parsePiPackagesResponse,
parsePiWebConfigResponse,
parsePiWebPluginsResponse,
parsePiWebRuntimeResponse,
@@ -119,6 +121,22 @@ export const pluginsApi = {
plugins: () => request("/api/plugins", parsePiWebPluginsResponse),
};
export const piPackagesApi = {
packages: () => request("/api/pi-packages", parsePiPackagesResponse),
install: (source: string) => {
const body: PiPackageInstallRequest = { source };
return request("/api/pi-packages/install", parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
},
remove: (source: string, scope?: PiPackageScope) => {
const body: PiPackageRemoveRequest = scope === undefined ? { source } : { source, scope };
return request("/api/pi-packages/remove", parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
},
update: (source?: string) => {
const body: PiPackageUpdateRequest | undefined = source === undefined ? undefined : { source };
return request("/api/pi-packages/update", parsePiPackageMutationResponse, { method: "POST", ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
},
};
export const activityApi = {
workspaceActivity: (machineId = "local") => request(`${machinePrefix(machineId)}/activity`, parseWorkspaceActivityResponse),
};
@@ -285,6 +303,7 @@ export const api = {
...machinesApi,
...configApi,
...pluginsApi,
...piPackagesApi,
...activityApi,
...projectsApi,
...workspacesApi,
+23 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("parses PI WEB config responses", () => {
@@ -31,6 +31,28 @@ describe("API parsers", () => {
})).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
});
it("parses Pi package list and mutation responses", () => {
const packages = [
{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" },
{ source: "../project-tools", scope: "project", filtered: true },
];
expect(parsePiPackagesResponse({ packages })).toEqual({ packages });
expect(parsePiPackageMutationResponse({ action: "remove", source: "../project-tools", scope: "project", removed: true, packages })).toEqual({
action: "remove",
source: "../project-tools",
scope: "project",
removed: true,
packages,
});
});
it("rejects malformed Pi package responses", () => {
expect(() => parsePiPackagesResponse({ packages: [{ source: "npm:@acme/tools", scope: "global", filtered: false }] })).toThrow("Invalid Pi package scope");
expect(() => parsePiPackageMutationResponse({ action: "sync", packages: [] })).toThrow("Invalid Pi package mutation action");
expect(() => parsePiPackagesResponse({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: "no" }] })).toThrow("Expected boolean field: filtered");
});
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", machineSpecific: true, enabled: false }],
+40
View File
@@ -1,4 +1,5 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
import { isPiWebCapability } from "../../../shared/capabilities";
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -628,6 +629,45 @@ function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), subsessions: requireBoolean(record, "subsessions") };
}
export function parsePiPackagesResponse(value: unknown): PiPackagesResponse {
const record = requireRecord(value);
return { packages: arrayOf(parsePiPackageInfo)(record["packages"]) };
}
export function parsePiPackageMutationResponse(value: unknown): PiPackageMutationResponse {
const record = requireRecord(value);
const source = optionalString(record, "source");
const scope = record["scope"] === undefined ? undefined : parsePiPackageScope(record["scope"]);
const removed = parseOptionalBoolean(record["removed"], "removed");
return {
action: parsePiPackageMutationAction(record["action"]),
...optionalField("source", source),
...optionalField("scope", scope),
...optionalField("removed", removed),
packages: arrayOf(parsePiPackageInfo)(record["packages"]),
};
}
function parsePiPackageInfo(value: unknown): PiPackageInfo {
const record = requireRecord(value);
return {
source: requireString(record, "source"),
scope: parsePiPackageScope(record["scope"]),
filtered: requireBoolean(record, "filtered"),
...optionalField("installedPath", optionalString(record, "installedPath")),
};
}
function parsePiPackageScope(value: unknown): PiPackageScope {
if (value !== "user" && value !== "project") throw new Error("Invalid Pi package scope");
return value;
}
function parsePiPackageMutationAction(value: unknown): PiPackageMutationAction {
if (value !== "install" && value !== "remove" && value !== "update") throw new Error("Invalid Pi package mutation action");
return value;
}
export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse {
const record = requireRecord(value);
return { plugins: arrayOf(parsePiWebPluginInfo)(record["plugins"]) };
+60 -4
View File
@@ -1,12 +1,14 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { AppAction } from "../actions";
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
import { configApi, piPackagesApi, pluginsApi, type PiPackageMutationResponse, type PiPackageScope, type PiPackagesResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
import type { SettingsSection } from "../settingsRoute";
import "./settings/SettingsGeneralPanel";
import "./settings/SettingsSessiondPanel";
import "./settings/SettingsPackagesPanel";
import "./settings/SettingsPluginsPanel";
import "./settings/SettingsShortcutsPanel";
import { piPackageMutationFollowUpMessage, type PiPackageOperationState } from "./settings/piPackageSettings";
@customElement("settings-dialog")
export class SettingsDialog extends LitElement {
@@ -17,10 +19,13 @@ export class SettingsDialog extends LitElement {
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
@state() private configResponse: PiWebConfigResponse | undefined;
@state() private pluginsResponse: PiWebPluginsResponse | undefined;
@state() private packagesResponse: PiPackagesResponse | undefined;
@state() private loading = true;
@state() private saving = false;
@state() private packageOperation: PiPackageOperationState | undefined;
@state() private error = "";
@state() private savedMessage = "";
@state() private packageMessage = "";
private savedMessageTimer: number | undefined;
override connectedCallback(): void {
@@ -49,7 +54,8 @@ export class SettingsDialog extends LitElement {
<nav class="settings-nav" aria-label="Settings sections">
${this.renderNavButton("general", "General", "Server config")}
${this.renderNavButton("sessiond", "Session daemon", "Runtime settings")}
${this.renderNavButton("plugins", "Plugins", "Enable and disable")}
${this.renderNavButton("packages", "Pi packages", "Install and manage")}
${this.renderNavButton("plugins", "PI WEB plugins", "Enable and disable")}
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
</nav>
<main class="settings-content">
@@ -89,6 +95,21 @@ export class SettingsDialog extends LitElement {
></settings-shortcuts-panel>
`;
}
if (this.section === "packages") {
return html`
<settings-packages-panel
.packagesResponse=${this.packagesResponse}
.loading=${this.loading}
.operation=${this.packageOperation}
.error=${this.error}
.operationMessage=${this.packageMessage}
.onReload=${() => this.loadConfig()}
.onInstallPackage=${(source: string) => this.installPiPackage(source)}
.onRemovePackage=${(source: string, scope: PiPackageScope) => this.removePiPackage(source, scope)}
.onUpdatePackage=${(source?: string) => this.updatePiPackage(source)}
></settings-packages-panel>
`;
}
if (this.section === "plugins") {
return html`
<settings-plugins-panel
@@ -134,9 +155,10 @@ export class SettingsDialog extends LitElement {
this.loading = true;
this.error = "";
try {
const [config, plugins] = await Promise.all([configApi.config(), pluginsApi.plugins()]);
const [config, plugins, packages] = await Promise.all([configApi.config(), pluginsApi.plugins(), piPackagesApi.packages()]);
this.configResponse = config;
this.pluginsResponse = plugins;
this.packagesResponse = packages;
} catch (error) {
this.error = `Failed to load settings: ${errorMessage(error)}`;
} finally {
@@ -163,6 +185,7 @@ export class SettingsDialog extends LitElement {
this.saving = true;
this.error = "";
this.savedMessage = "";
this.packageMessage = "";
try {
const response = await configApi.saveConfig(config);
this.configResponse = response;
@@ -175,11 +198,44 @@ export class SettingsDialog extends LitElement {
}
}
private async installPiPackage(source: string): Promise<void> {
await this.runPiPackageMutation({ kind: "install", source }, "install Pi package", () => piPackagesApi.install(source));
}
private async removePiPackage(source: string, scope: PiPackageScope): Promise<void> {
await this.runPiPackageMutation({ kind: "remove", source }, "remove Pi package", () => piPackagesApi.remove(source, scope));
}
private async updatePiPackage(source?: string): Promise<void> {
await this.runPiPackageMutation(source === undefined ? { kind: "update-all" } : { kind: "update", source }, "update Pi packages", () => piPackagesApi.update(source));
}
private async runPiPackageMutation(operation: PiPackageOperationState, label: string, mutate: () => Promise<PiPackageMutationResponse>): Promise<void> {
if (this.saving) throw new Error("A settings operation is already running.");
this.saving = true;
this.packageOperation = operation;
this.error = "";
this.savedMessage = "";
this.packageMessage = "";
try {
const response = await mutate();
this.packagesResponse = { packages: response.packages };
await this.refreshPlugins();
this.packageMessage = piPackageMutationFollowUpMessage(response.action);
} catch (error) {
this.error = `Failed to ${label}: ${errorMessage(error)}`;
throw error;
} finally {
this.packageOperation = undefined;
this.saving = false;
}
}
private async refreshPlugins(): Promise<void> {
try {
this.pluginsResponse = await pluginsApi.plugins();
} catch (error) {
this.error = `Failed to refresh plugins: ${errorMessage(error)}`;
this.error = `Failed to refresh PI WEB plugins: ${errorMessage(error)}`;
}
}
@@ -0,0 +1,180 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { PiPackageInfo, PiPackageScope, PiPackagesResponse } from "../../api";
import { isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageInstalledPathLabel, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageUpdateDisabledReason, updateAllPiPackagesDisabledReason, type PiPackageOperationState } from "./piPackageSettings";
@customElement("settings-packages-panel")
export class SettingsPackagesPanel extends LitElement {
@property({ attribute: false }) packagesResponse: PiPackagesResponse | undefined;
@property({ type: Boolean }) loading = false;
@property({ attribute: false }) operation: PiPackageOperationState | undefined;
@property() error = "";
@property() operationMessage = "";
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onInstallPackage?: (source: string) => void | Promise<void>;
@property({ attribute: false }) onRemovePackage?: (source: string, scope: PiPackageScope) => void | Promise<void>;
@property({ attribute: false }) onUpdatePackage?: (source?: string) => void | Promise<void>;
@state() private installSource = "";
@state() private validationMessage = "";
override render(): TemplateResult {
const packages = this.packagesResponse?.packages ?? [];
return html`
<div class="section-heading">
<div>
<h2>Pi packages</h2>
<p>Install, remove, and update packages managed by Pi. Pi packages can provide extensions, skills, prompt templates, themes, and PI WEB browser plugins.</p>
</div>
<button class="secondary" ?disabled=${this.loading || this.isOperating} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
<div class="trust-warning"><strong>Trusted code warning:</strong> Pi packages and PI WEB plugins can run with your user permissions. Install packages and enable plugins only from sources you trust.</div>
${this.renderMessages()}
<form class="install-card" @submit=${(event: Event) => { void this.installPackage(event); }}>
<label for="package-source">Pi package source</label>
<div class="install-row">
<input id="package-source" .value=${this.installSource} ?disabled=${this.isOperating} placeholder="npm:@scope/package, git URL, or local path" @input=${(event: Event) => { this.updateInstallSource(event); }}>
<button type="submit" ?disabled=${this.isOperating}>${isPiPackageOperationPending(this.operation, "install") ? "Installing…" : "Install"}</button>
</div>
${this.validationMessage === "" ? null : html`<div class="field-error">${this.validationMessage}</div>`}
<small>Installs use Pi's default package location, equivalent to <code>pi install &lt;source&gt;</code>. PI WEB does not ask you to choose an install location.</small>
</form>
${this.renderPackageList(packages)}
`;
}
private renderMessages(): TemplateResult | null {
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`;
if (this.operationMessage !== "") return html`<div class="message success-message">${this.operationMessage}</div>`;
return null;
}
private renderPackageList(packages: PiPackageInfo[]): TemplateResult {
const updateAllReason = updateAllPiPackagesDisabledReason(packages);
return html`
<section class="package-section" aria-label="Configured Pi packages">
<div class="package-toolbar">
<div>
<h3>Configured Pi packages</h3>
<p>This list comes from Pi's package manager settings visible to this PI WEB process.</p>
</div>
<button class="secondary" title=${updateAllReason ?? "Update all user-scope Pi packages"} ?disabled=${this.isOperating || updateAllReason !== undefined} @click=${() => { void this.updatePackage(); }}>
${isPiPackageOperationPending(this.operation, "update-all") ? "Updating…" : "Update all"}
</button>
</div>
${updateAllReason === undefined ? null : html`<div class="action-note">${updateAllReason}</div>`}
${this.loading && packages.length === 0 ? html`<div class="loading-card">Loading Pi packages…</div>` : packages.length === 0 ? html`<div class="loading-card">No Pi packages configured in Pi settings yet.</div>` : html`
<div class="package-list">
${packages.map((packageInfo) => this.renderPackage(packageInfo))}
</div>
`}
</section>
`;
}
private renderPackage(packageInfo: PiPackageInfo): TemplateResult {
const updateReason = piPackageUpdateDisabledReason(packageInfo);
const updating = isPiPackageOperationPending(this.operation, "update", packageInfo.source);
const removing = isPiPackageOperationPending(this.operation, "remove", packageInfo.source);
return html`
<article class=${`package-card${packageInfo.filtered ? " filtered" : ""}`}>
<div class="package-main">
<strong>${packageInfo.source}</strong>
<small>${piPackageScopeLabel(packageInfo)} · ${piPackageFilteredLabel(packageInfo)}</small>
<small>Installed path: <code>${piPackageInstalledPathLabel(packageInfo)}</code></small>
${updateReason === undefined ? null : html`<small class="action-note">${updateReason}</small>`}
</div>
<div class="package-actions">
<button class="secondary" title=${updateReason ?? "Update this Pi package"} ?disabled=${this.isOperating || updateReason !== undefined} @click=${() => { void this.updatePackage(packageInfo.source); }}>${updating ? "Updating…" : "Update"}</button>
<button class="danger" ?disabled=${this.isOperating} @click=${() => { void this.removePackage(packageInfo); }}>${removing ? "Removing…" : "Remove"}</button>
</div>
</article>
`;
}
private updateInstallSource(event: Event): void {
this.installSource = event.target instanceof HTMLInputElement ? event.target.value : "";
this.validationMessage = "";
}
private async installPackage(event: Event): Promise<void> {
event.preventDefault();
const validationMessage = piPackageSourceValidationMessage(this.installSource);
if (validationMessage !== undefined) {
this.validationMessage = validationMessage;
return;
}
const source = normalizePiPackageSource(this.installSource);
try {
await this.onInstallPackage?.(source);
this.installSource = "";
this.validationMessage = "";
} catch {
// The parent owns network error presentation so package errors are consistent across Settings.
}
}
private async removePackage(packageInfo: PiPackageInfo): Promise<void> {
try {
await this.onRemovePackage?.(packageInfo.source, packageInfo.scope);
} catch {
// The parent owns network error presentation so package errors are consistent across Settings.
}
}
private async updatePackage(source?: string): Promise<void> {
try {
await this.onUpdatePackage?.(source);
} catch {
// The parent owns network error presentation so package errors are consistent across Settings.
}
}
private get isOperating(): boolean {
return this.operation !== undefined;
}
static override styles = css`
:host { display: block; }
.section-heading, .package-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.section-heading > div, .package-toolbar > div, .package-main { display: grid; gap: 6px; min-width: 0; }
h2, h3, p { margin: 0; }
h2 { font-size: 17px; line-height: 1.25; }
h3 { font-size: 15px; line-height: 1.25; }
p, small { 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; }
input { box-sizing: border-box; width: 100%; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; }
label { font-weight: 700; }
.secondary { flex: 0 0 auto; }
.danger { border-color: color-mix(in srgb, var(--pi-danger) 55%, var(--pi-border)); color: var(--pi-danger); }
.message, .loading-card, .trust-warning, .install-card, .package-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.message, .trust-warning, .install-card { margin-bottom: 12px; }
.trust-warning { border-color: var(--pi-warning-border); color: var(--pi-text); background: var(--pi-warning-surface); line-height: 1.45; }
.error-message, .field-error { color: var(--pi-danger); }
.error-message { border-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); }
.install-card { display: grid; gap: 8px; }
.install-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; }
.field-error { font-size: 12px; }
.package-section { display: block; }
.package-toolbar { margin-top: 16px; }
.loading-card, .action-note { color: var(--pi-muted); }
.action-note { margin-bottom: 10px; font-size: 12px; }
.package-list { display: grid; gap: 10px; }
.package-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; }
.package-card.filtered { opacity: .82; }
.package-main strong, .package-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.package-actions { display: flex; align-items: center; gap: 8px; }
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; }
@media (max-width: 760px) {
.section-heading, .package-toolbar { display: grid; gap: 12px; }
.section-heading .secondary, .package-toolbar .secondary { justify-self: start; }
.install-row, .package-card { grid-template-columns: minmax(0, 1fr); align-items: start; }
.package-actions { justify-self: start; flex-wrap: wrap; }
.package-main strong, .package-main small { white-space: normal; }
}
`;
}
@@ -18,14 +18,15 @@ export class SettingsPluginsPanel extends LitElement {
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>
<h2>PI WEB plugins</h2>
<p>Enable or disable discovered PI WEB browser plugins. This is separate from installing Pi packages. 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="trust-warning"><strong>Trusted code warning:</strong> PI WEB plugins and Pi packages can run with your user permissions. Enable plugins only from sources you trust.</div>
<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`
${this.loading && plugins.length === 0 ? html`<div class="loading-card">Loading PI WEB plugins…</div>` : plugins.length === 0 ? html`<div class="loading-card">No PI WEB browser plugins discovered.</div>` : html`
<div class="plugin-list">
${plugins.map((plugin) => this.renderPlugin(plugin))}
</div>
@@ -73,11 +74,12 @@ export class SettingsPluginsPanel extends LitElement {
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; }
.message, .loading-card, .trust-warning, .plugin-note, .plugin-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.message, .trust-warning { 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); }
.trust-warning { border-color: var(--pi-warning-border); color: var(--pi-text); background: var(--pi-warning-surface); line-height: 1.45; }
.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; }
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import type { PiPackageInfo } from "../../api";
import { canUpdateAllPiPackages, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageMutationFollowUpMessage, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageUpdateDisabledReason, updateAllPiPackagesDisabledReason } from "./piPackageSettings";
const userPackage: PiPackageInfo = { source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" };
const projectPackage: PiPackageInfo = { source: "../project-tools", scope: "project", filtered: true };
describe("Pi package settings helpers", () => {
it("normalizes and validates install sources without adding location choices", () => {
expect(normalizePiPackageSource(" npm:@acme/tools ")).toBe("npm:@acme/tools");
expect(piPackageSourceValidationMessage(" npm:@acme/tools ")).toBeUndefined();
expect(piPackageSourceValidationMessage(" ")).toContain("Pi package source accepted by Pi");
});
it("formats package metadata with Pi package terminology", () => {
expect(piPackageScopeLabel(userPackage)).toBe("User scope");
expect(piPackageScopeLabel(projectPackage)).toBe("Project scope");
expect(piPackageFilteredLabel(userPackage)).toBe("Available in this PI WEB process");
expect(piPackageFilteredLabel(projectPackage)).toBe("Filtered by current Pi package settings");
});
it("allows updates for user-scope packages and explains project-scope limits", () => {
expect(piPackageUpdateDisabledReason(userPackage)).toBeUndefined();
expect(piPackageUpdateDisabledReason(projectPackage)).toContain("user-scope Pi packages");
expect(canUpdateAllPiPackages([userPackage])).toBe(true);
expect(canUpdateAllPiPackages([userPackage, projectPackage])).toBe(false);
expect(updateAllPiPackagesDisabledReason([])).toBe("No Pi packages are configured yet.");
expect(updateAllPiPackagesDisabledReason([userPackage, projectPackage])).toContain("project-scope Pi packages");
});
it("matches pending operations by action and source", () => {
expect(isPiPackageOperationPending({ kind: "remove", source: "npm:@acme/tools" }, "remove", "npm:@acme/tools")).toBe(true);
expect(isPiPackageOperationPending({ kind: "remove", source: "npm:@acme/tools" }, "remove", "npm:@acme/other")).toBe(false);
expect(isPiPackageOperationPending({ kind: "update-all" }, "update-all")).toBe(true);
});
it("describes the browser and session reload follow-up without requiring sessiond restarts", () => {
const message = piPackageMutationFollowUpMessage("install");
expect(message).toContain("Reload the browser page");
expect(message).toContain("Reload existing Pi sessions");
expect(message).not.toContain("session daemon");
expect(message).not.toContain("sessiond");
});
});
@@ -0,0 +1,58 @@
import type { PiPackageInfo, PiPackageMutationAction } from "../../api";
export type PiPackageOperationKind = PiPackageMutationAction | "update-all";
export interface PiPackageOperationState {
kind: PiPackageOperationKind;
source?: string;
}
export function normalizePiPackageSource(source: string): string {
return source.trim();
}
export function piPackageSourceValidationMessage(source: string): string | undefined {
if (normalizePiPackageSource(source) !== "") return undefined;
return "Enter a Pi package source accepted by Pi, such as npm:@scope/package, a git/URL source, or a local path.";
}
export function piPackageScopeLabel(packageInfo: Pick<PiPackageInfo, "scope">): string {
return packageInfo.scope === "project" ? "Project scope" : "User scope";
}
export function piPackageFilteredLabel(packageInfo: Pick<PiPackageInfo, "filtered">): string {
return packageInfo.filtered ? "Filtered by current Pi package settings" : "Available in this PI WEB process";
}
export function piPackageInstalledPathLabel(packageInfo: Pick<PiPackageInfo, "installedPath">): string {
return packageInfo.installedPath ?? "Installed path not reported by Pi";
}
export function canUpdatePiPackage(packageInfo: Pick<PiPackageInfo, "scope">): boolean {
return packageInfo.scope === "user";
}
export function piPackageUpdateDisabledReason(packageInfo: Pick<PiPackageInfo, "scope">): string | undefined {
if (canUpdatePiPackage(packageInfo)) return undefined;
return "Project-scope Pi packages are listed for visibility, but PI WEB only updates user-scope Pi packages safely from this view.";
}
export function canUpdateAllPiPackages(packages: readonly Pick<PiPackageInfo, "scope">[]): boolean {
return packages.length > 0 && packages.every(canUpdatePiPackage);
}
export function updateAllPiPackagesDisabledReason(packages: readonly Pick<PiPackageInfo, "scope">[]): string | undefined {
if (packages.length === 0) return "No Pi packages are configured yet.";
if (canUpdateAllPiPackages(packages)) return undefined;
return "Update all is disabled while project-scope Pi packages are listed; update user-scope packages individually.";
}
export function isPiPackageOperationPending(operation: PiPackageOperationState | undefined, kind: PiPackageOperationKind, source?: string): boolean {
if (operation?.kind !== kind) return false;
return source === undefined || operation.source === source;
}
export function piPackageMutationFollowUpMessage(action: PiPackageMutationAction): string {
const verb = action === "install" ? "installed" : action === "remove" ? "removed" : "updated";
return `Pi package ${verb}. Reload the browser page to import newly discovered PI WEB browser plugins. Reload existing Pi sessions, or use /reload in Pi, so extensions, skills, prompt templates, and themes are rediscovered.`;
}
+2
View File
@@ -37,6 +37,8 @@ describe("settings route helpers", () => {
expect(parseSettingsSection("general")).toBe("general");
expect(parseSettingsSection("sessiond")).toBe("sessiond");
expect(parseSettingsSection("sessions")).toBe("sessiond");
expect(parseSettingsSection("packages")).toBe("packages");
expect(parseSettingsSection("pi-packages")).toBe("packages");
expect(parseSettingsSection("plugins")).toBe("plugins");
expect(parseSettingsSection("shortcuts")).toBe("shortcuts");
expect(parseSettingsSection("keyboard")).toBe("shortcuts");
+2 -1
View File
@@ -1,4 +1,4 @@
export type SettingsSection = "general" | "sessiond" | "plugins" | "shortcuts";
export type SettingsSection = "general" | "sessiond" | "packages" | "plugins" | "shortcuts";
export function readSettingsSection(): SettingsSection | undefined {
return parseSettingsSection(new URLSearchParams(window.location.search).get("settings"));
@@ -18,6 +18,7 @@ export function writeSettingsSection(section: SettingsSection | undefined, optio
export function parseSettingsSection(value: string | null): SettingsSection | undefined {
if (value === "general") return "general";
if (value === "sessiond" || value === "sessions") return "sessiond";
if (value === "packages" || value === "pi-packages") return "packages";
if (value === "plugins") return "plugins";
if (value === "shortcuts" || value === "keyboard" || value === "keyboard-shortcuts") return "shortcuts";
return undefined;
+44 -1
View File
@@ -11,11 +11,12 @@ import { RemoteMachineRequestError, type MachineClient } from "./machines/machin
import { MachineService } from "./machines/machineService.js";
import { MachineStore } from "./machines/machineStore.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
import type { PiPackageService } from "./piPackageService.js";
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import type { Project, Workspace } from "./types.js";
let app: FastifyInstance;
@@ -23,6 +24,7 @@ let tempDir: string;
let projectDir: string;
let remoteClient: MachineClient | undefined;
let sessionDaemonRequests: CapturedSessionDaemonRequest[];
let piPackageRequests: CapturedPiPackageRequest[];
let piWebConfig: PiWebConfigValues;
beforeEach(async () => {
@@ -30,6 +32,7 @@ beforeEach(async () => {
projectDir = join(tempDir, "project");
remoteClient = undefined;
sessionDaemonRequests = [];
piPackageRequests = [];
piWebConfig = {};
app = await buildApp({
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
@@ -52,6 +55,7 @@ beforeEach(async () => {
}),
sessionDaemon: fakeSessionDaemon(),
config: fakeConfigService(),
piPackages: fakePiPackageService(),
piWebPlugins: {
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
@@ -389,6 +393,17 @@ describe("buildApp", () => {
expect(workspacesResponse.json<Workspace[]>()).toEqual([expect.objectContaining({ projectId: project.id, path: projectDir })]);
});
it("serves Pi package management routes through the app wiring", async () => {
const listResponse = await app.inject({ method: "GET", url: "/api/pi-packages" });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }] });
const installResponse = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools" } });
expect(installResponse.statusCode).toBe(200);
expect(installResponse.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" });
expect(piPackageRequests).toEqual([{ action: "list" }, { action: "install", source: "npm:@acme/new-tools" }]);
});
it("serves the PI WEB plugin manifest and plugin assets", async () => {
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
expect(manifestResponse.statusCode).toBe(200);
@@ -885,6 +900,12 @@ interface CapturedSessionDaemonRequest {
body?: unknown;
}
interface CapturedPiPackageRequest {
action: "list" | "install" | "remove" | "update";
source?: string;
scope?: "user" | "project";
}
function fakeConfigService() {
return {
read: () => piWebConfigResponse(piWebConfig),
@@ -905,6 +926,28 @@ function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
};
}
function fakePiPackageService(): PiPackageService {
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }];
return {
list: () => {
piPackageRequests.push({ action: "list" });
return Promise.resolve({ packages });
},
install: (source) => {
piPackageRequests.push({ action: "install", source });
return Promise.resolve({ action: "install", source, packages });
},
remove: (source, scope = "user") => {
piPackageRequests.push({ action: "remove", source, scope });
return Promise.resolve({ action: "remove", source, scope, removed: true, packages });
},
update: (source) => {
piPackageRequests.push({ action: "update", ...(source === undefined ? {} : { source }) });
return Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages });
},
};
}
function fakeSessionDaemon(): SessionProxyDaemon {
return {
request: (method, path, body) => {
+5
View File
@@ -20,6 +20,8 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
import { createFilePiWebConfigService, registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js";
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { MachineService } from "./machines/machineService.js";
@@ -34,6 +36,7 @@ export interface AppDependencies {
machines?: MachineService;
sessionDaemon?: SessionProxyDaemon;
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
piPackages?: PiPackageService;
config?: PiWebConfigService;
clientDist?: string | false;
logger?: FastifyServerOptions["logger"];
@@ -122,6 +125,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const projects = deps.projects ?? new ProjectService(new ProjectStore());
const workspaces = deps.workspaces ?? new WorkspaceService();
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
const piPackages = deps.piPackages ?? createDefaultPiPackageService();
const configService = deps.config ?? createFilePiWebConfigService();
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
@@ -145,6 +149,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
app.get("/api/plugins", async () => piWebPlugins.plugins());
registerPiPackageRoutes(app, piPackages);
registerConfigRoutes(app, configService);
registerMachineRoutes(app, machines);
+97
View File
@@ -0,0 +1,97 @@
import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PiPackageInfo } from "../shared/apiTypes.js";
import type { PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
let app: FastifyInstance;
let service: PiPackageService;
let serviceMocks: ReturnType<typeof fakePiPackageService>;
beforeEach(async () => {
serviceMocks = fakePiPackageService();
service = serviceMocks.service;
app = Fastify({ logger: false });
registerPiPackageRoutes(app, service);
await app.ready();
});
afterEach(async () => {
await app.close();
});
describe("registerPiPackageRoutes", () => {
it("lists configured Pi packages", async () => {
const response = await app.inject({ method: "GET", url: "/api/pi-packages" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }] });
expect(serviceMocks.list).toHaveBeenCalledOnce();
});
it("installs a trimmed Pi package source without accepting a scope", async () => {
const response = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: " npm:@acme/new-tools " } });
const scopedResponse = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools", scope: "project" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" });
expect(scopedResponse.statusCode).toBe(400);
expect(scopedResponse.json()).toEqual({ error: "Pi package install scope is not supported; installs use Pi's default package location" });
expect(serviceMocks.install).toHaveBeenCalledOnce();
expect(serviceMocks.install).toHaveBeenCalledWith("npm:@acme/new-tools");
});
it("removes from an explicitly listed package scope", async () => {
const response = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: "../project-tools", scope: "project" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ action: "remove", source: "../project-tools", scope: "project", removed: true });
expect(serviceMocks.remove).toHaveBeenCalledWith("../project-tools", "project");
});
it("updates all packages when source is omitted and one package when source is provided", async () => {
const allResponse = await app.inject({ method: "POST", url: "/api/pi-packages/update" });
const oneResponse = await app.inject({ method: "POST", url: "/api/pi-packages/update", payload: { source: " npm:@acme/tools " } });
expect(allResponse.statusCode).toBe(200);
expect(oneResponse.statusCode).toBe(200);
expect(serviceMocks.update).toHaveBeenNthCalledWith(1);
expect(serviceMocks.update).toHaveBeenNthCalledWith(2, "npm:@acme/tools");
});
it("returns stable 400 errors for invalid requests before calling the service", async () => {
const missingSource = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: {} });
const blankSource = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: " " } });
const invalidScope = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: "npm:@acme/tools", scope: "temporary" } });
const invalidUpdate = await app.inject({ method: "POST", url: "/api/pi-packages/update", payload: { source: "" } });
expect(missingSource.statusCode).toBe(400);
expect(missingSource.json()).toEqual({ error: "Pi package source must be a non-empty string" });
expect(blankSource.statusCode).toBe(400);
expect(invalidScope.statusCode).toBe(400);
expect(invalidScope.json()).toEqual({ error: "Pi package scope must be \"user\" or \"project\"" });
expect(invalidUpdate.statusCode).toBe(400);
expect(serviceMocks.install).not.toHaveBeenCalled();
expect(serviceMocks.remove).not.toHaveBeenCalled();
expect(serviceMocks.update).not.toHaveBeenCalled();
});
it("returns stable 500 errors for package-manager failures", async () => {
serviceMocks.install.mockRejectedValueOnce(new Error("install failed"));
const response = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/fails" } });
expect(response.statusCode).toBe(500);
expect(response.json()).toEqual({ error: "install failed" });
});
});
function fakePiPackageService() {
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
const list = vi.fn<PiPackageService["list"]>(() => Promise.resolve({ packages: [...packages] }));
const install = vi.fn<PiPackageService["install"]>((source) => Promise.resolve({ action: "install", source, packages: [...packages] }));
const remove = vi.fn<PiPackageService["remove"]>((source, scope = "user") => Promise.resolve({ action: "remove", source, scope, removed: true, packages: [...packages] }));
const update = vi.fn<PiPackageService["update"]>((source) => Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages: [...packages] }));
const service: PiPackageService = { list, install, remove, update };
return { service, list, install, remove, update };
}
+85
View File
@@ -0,0 +1,85 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import type { PiPackageScope } from "../shared/apiTypes.js";
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
class PiPackageRequestValidationError extends Error {}
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService()): void {
app.get("/api/pi-packages", async (_request, reply) => {
try {
return await service.list();
} catch (error) {
return sendPiPackageError(reply, error);
}
});
app.post<{ Body: unknown }>("/api/pi-packages/install", async (request, reply) => {
try {
return await service.install(parseRequiredSourceRequest(request.body));
} catch (error) {
return sendPiPackageError(reply, error);
}
});
app.post<{ Body: unknown }>("/api/pi-packages/remove", async (request, reply) => {
try {
const body = requireRequestObject(request.body);
return await service.remove(parseRequiredSource(body["source"]), parseOptionalScope(body["scope"]));
} catch (error) {
return sendPiPackageError(reply, error);
}
});
app.post<{ Body: unknown }>("/api/pi-packages/update", async (request, reply) => {
try {
const source = parseOptionalUpdateSource(request.body);
return source === undefined ? await service.update() : await service.update(source);
} catch (error) {
return sendPiPackageError(reply, error);
}
});
}
function parseRequiredSourceRequest(body: unknown): string {
const request = requireRequestObject(body);
if (request["scope"] !== undefined || request["local"] !== undefined) {
throw new PiPackageRequestValidationError("Pi package install scope is not supported; installs use Pi's default package location");
}
return parseRequiredSource(request["source"]);
}
function parseRequiredSource(value: unknown): string {
if (typeof value !== "string" || value.trim() === "") throw new PiPackageRequestValidationError("Pi package source must be a non-empty string");
return value.trim();
}
function parseOptionalUpdateSource(body: unknown): string | undefined {
if (body === undefined) return undefined;
const source = requireRequestObject(body)["source"];
if (source === undefined) return undefined;
return parseRequiredSource(source);
}
function parseOptionalScope(value: unknown): PiPackageScope | undefined {
if (value === undefined) return undefined;
if (value !== "user" && value !== "project") throw new PiPackageRequestValidationError("Pi package scope must be \"user\" or \"project\"");
return value;
}
function requireRequestObject(value: unknown): Record<string, unknown> {
if (!isRecord(value)) throw new PiPackageRequestValidationError("Pi package request body must be an object");
return value;
}
function sendPiPackageError(reply: FastifyReply, error: unknown): FastifyReply {
const status = error instanceof PiPackageRequestValidationError ? 400 : 500;
return reply.code(status).send({ error: errorMessage(error) });
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vitest";
import type { PiPackageInfo } from "../shared/apiTypes.js";
import { DefaultPiPackageService, type PiPackageManagerPort } from "./piPackageService.js";
function fakeManager(packages: PiPackageInfo[] = []) {
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => packages);
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(() => Promise.resolve());
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>(() => Promise.resolve(true));
const update = vi.fn<PiPackageManagerPort["update"]>(() => Promise.resolve());
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update };
return { manager, listConfiguredPackages, installAndPersist, removeAndPersist, update };
}
describe("DefaultPiPackageService", () => {
it("lists configured Pi packages with source, scope, filtered status, and installed path", async () => {
const fake = fakeManager([
{ source: "npm:@acme/user-tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/user-tools" },
{ source: "../project-tools", scope: "project", filtered: true },
]);
const service = new DefaultPiPackageService(fake.manager);
await expect(service.list()).resolves.toEqual({
packages: [
{ source: "npm:@acme/user-tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/user-tools" },
{ source: "../project-tools", scope: "project", filtered: true },
],
});
});
it("installs through the default Pi package-manager behavior without a local option", async () => {
const fake = fakeManager([{ source: "npm:@acme/tools", scope: "user", filtered: false }]);
const service = new DefaultPiPackageService(fake.manager);
const response = await service.install("npm:@acme/tools");
expect(fake.installAndPersist).toHaveBeenCalledWith("npm:@acme/tools");
expect(response).toEqual({ action: "install", source: "npm:@acme/tools", packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }] });
});
it("removes user packages by default and project packages only when the known scope is supplied", async () => {
const fake = fakeManager();
const service = new DefaultPiPackageService(fake.manager);
await service.remove("npm:@acme/user-tools");
await service.remove("../project-tools", "project");
expect(fake.removeAndPersist).toHaveBeenNthCalledWith(1, "npm:@acme/user-tools");
expect(fake.removeAndPersist).toHaveBeenNthCalledWith(2, "../project-tools", { local: true });
});
it("updates all configured packages or a single source", async () => {
const fake = fakeManager();
const service = new DefaultPiPackageService(fake.manager);
await service.update();
await service.update("npm:@acme/tools");
expect(fake.update).toHaveBeenNthCalledWith(1);
expect(fake.update).toHaveBeenNthCalledWith(2, "npm:@acme/tools");
});
});
+80
View File
@@ -0,0 +1,80 @@
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../shared/apiTypes.js";
export interface PiPackageManagerPort {
listConfiguredPackages(): PiPackageInfo[];
installAndPersist(source: string, options?: { local?: boolean }): Promise<void>;
removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean>;
update(source?: string): Promise<void>;
flush?(): Promise<void>;
}
export interface PiPackageService {
list(): Promise<PiPackagesResponse>;
install(source: string): Promise<PiPackageMutationResponse>;
remove(source: string, scope?: PiPackageScope): Promise<PiPackageMutationResponse>;
update(source?: string): Promise<PiPackageMutationResponse>;
}
export class DefaultPiPackageService implements PiPackageService {
constructor(private readonly manager: PiPackageManagerPort) {}
list(): Promise<PiPackagesResponse> {
return Promise.resolve({ packages: this.listPackages() });
}
async install(source: string): Promise<PiPackageMutationResponse> {
await this.manager.installAndPersist(source);
await this.flushSettings();
return this.mutationResponse("install", { source });
}
async remove(source: string, scope: PiPackageScope = "user"): Promise<PiPackageMutationResponse> {
const removed = scope === "project"
? await this.manager.removeAndPersist(source, { local: true })
: await this.manager.removeAndPersist(source);
await this.flushSettings();
return this.mutationResponse("remove", { source, scope, removed });
}
async update(source?: string): Promise<PiPackageMutationResponse> {
if (source === undefined) {
await this.manager.update();
await this.flushSettings();
return this.mutationResponse("update", {});
}
await this.manager.update(source);
await this.flushSettings();
return this.mutationResponse("update", { source });
}
private mutationResponse(action: PiPackageMutationAction, metadata: Omit<PiPackageMutationResponse, "action" | "packages">): PiPackageMutationResponse {
return { action, ...metadata, packages: this.listPackages() };
}
private async flushSettings(): Promise<void> {
await this.manager.flush?.();
}
private listPackages(): PiPackageInfo[] {
return this.manager.listConfiguredPackages().map((configuredPackage) => ({
source: configuredPackage.source,
scope: configuredPackage.scope,
filtered: configuredPackage.filtered,
...(configuredPackage.installedPath === undefined ? {} : { installedPath: configuredPackage.installedPath }),
}));
}
}
export function createDefaultPiPackageService(cwd = process.cwd(), agentDir = getAgentDir()): PiPackageService {
const settingsManager = SettingsManager.create(cwd, agentDir);
const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
return new DefaultPiPackageService({
listConfiguredPackages: () => manager.listConfiguredPackages(),
installAndPersist: (source, options) => manager.installAndPersist(source, options),
removeAndPersist: (source, options) => manager.removeAndPersist(source, options),
update: (source) => manager.update(source),
flush: () => settingsManager.flush(),
});
}
+28
View File
@@ -66,6 +66,29 @@ describe("PiWebPluginService", () => {
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u);
});
it("refreshes Pi package plugin discovery after Pi package settings change", async () => {
const agentDir = join(tempDir, "agent");
const firstPackageDir = join(tempDir, "first-package");
const secondPackageDir = join(tempDir, "second-package");
await writePlugin(firstPackageDir, {
packageJson: { piWeb: { plugins: [{ id: "first", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
await writePlugin(secondPackageDir, {
packageJson: { piWeb: { plugins: [{ id: "second", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
await writePiPackageSettings(agentDir, [firstPackageDir]);
const service = new PiWebPluginService({ roots: [], cwd: tempDir, agentDir });
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "first" }] });
await writePiPackageSettings(agentDir, [secondPackageDir]);
const manifest = await service.manifest();
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["second"]);
});
it("discovers source checkout plugin packages without symlinks", async () => {
await mkdir(join(tempDir, "src", "server"), { recursive: true });
await writeFile(join(tempDir, "src", "server", "index.ts"), "export {};\n");
@@ -189,6 +212,11 @@ describe("PiWebPluginService", () => {
});
});
async function writePiPackageSettings(agentDir: string, packages: string[]): Promise<void> {
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages }, null, 2)}\n`);
}
async function writePlugin(root: string, options: { packageJson: unknown; files: Record<string, string> }): Promise<void> {
await mkdir(root, { recursive: true });
await writeFile(join(root, "package.json"), `${JSON.stringify(options.packageJson, null, 2)}\n`);
+11 -11
View File
@@ -69,22 +69,22 @@ interface PiWebPluginEntry {
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
export class DefaultPiPackageProvider implements PiPackageProvider {
private readonly packageManager: DefaultPackageManager;
constructor(cwd = process.cwd(), agentDir = getAgentDir()) {
this.packageManager = new DefaultPackageManager({
cwd,
agentDir,
settingsManager: SettingsManager.create(cwd, agentDir),
});
}
constructor(private readonly cwd = process.cwd(), private readonly agentDir = getAgentDir()) {}
listPackages(): ConfiguredPiPackage[] {
return this.packageManager.listConfiguredPackages();
return this.createPackageManager().listConfiguredPackages();
}
getInstalledPath(source: string, scope: "user" | "project"): string | undefined {
return this.packageManager.getInstalledPath(source, scope);
return this.createPackageManager().getInstalledPath(source, scope);
}
private createPackageManager(): DefaultPackageManager {
return new DefaultPackageManager({
cwd: this.cwd,
agentDir: this.agentDir,
settingsManager: SettingsManager.create(this.cwd, this.agentDir),
});
}
}
+37
View File
@@ -100,6 +100,43 @@ export interface PiWebPluginsResponse {
plugins: PiWebPluginInfo[];
}
export type PiPackageScope = "user" | "project";
export interface PiPackageInfo {
source: string;
scope: PiPackageScope;
filtered: boolean;
installedPath?: string;
}
export interface PiPackagesResponse {
packages: PiPackageInfo[];
}
export interface PiPackageInstallRequest {
source: string;
}
export interface PiPackageRemoveRequest {
source: string;
/** Optional known scope from a listed package; not an install-location picker. */
scope?: PiPackageScope;
}
export interface PiPackageUpdateRequest {
/** Omit to update all configured Pi packages. */
source?: string;
}
export type PiPackageMutationAction = "install" | "remove" | "update";
export interface PiPackageMutationResponse extends PiPackagesResponse {
action: PiPackageMutationAction;
source?: string;
scope?: PiPackageScope;
removed?: boolean;
}
export interface PiWebConfigEnvOverrides {
host: boolean;
port: boolean;