From 8ade2382285ea6b92a4d183ee44d3eb724bf8989 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 1 Jul 2026 15:34:34 +0200 Subject: [PATCH] feat: add Pi package management settings --- .changeset/manage-pi-packages.md | 5 + README.md | 6 +- docs/config.html | 22 ++- docs/config.md | 7 +- docs/install.html | 3 +- docs/plugins.html | 46 ++++- docs/plugins.md | 24 ++- src/client/src/api.ts | 4 +- src/client/src/api/clients.test.ts | 34 +++- src/client/src/api/clients.ts | 21 +- src/client/src/api/parsers.test.ts | 24 ++- src/client/src/api/parsers.ts | 40 ++++ src/client/src/components/SettingsDialog.ts | 64 ++++++- .../settings/SettingsPackagesPanel.ts | 180 ++++++++++++++++++ .../settings/SettingsPluginsPanel.ts | 12 +- .../settings/piPackageSettings.test.ts | 45 +++++ .../components/settings/piPackageSettings.ts | 58 ++++++ src/client/src/settingsRoute.test.ts | 2 + src/client/src/settingsRoute.ts | 3 +- src/server/app.test.ts | 45 ++++- src/server/app.ts | 5 + src/server/piPackageRoutes.test.ts | 97 ++++++++++ src/server/piPackageRoutes.ts | 85 +++++++++ src/server/piPackageService.test.ts | 61 ++++++ src/server/piPackageService.ts | 80 ++++++++ src/server/piWebPluginService.test.ts | 28 +++ src/server/piWebPluginService.ts | 22 +-- src/shared/apiTypes.ts | 37 ++++ 28 files changed, 1009 insertions(+), 51 deletions(-) create mode 100644 .changeset/manage-pi-packages.md create mode 100644 src/client/src/components/settings/SettingsPackagesPanel.ts create mode 100644 src/client/src/components/settings/piPackageSettings.test.ts create mode 100644 src/client/src/components/settings/piPackageSettings.ts create mode 100644 src/server/piPackageRoutes.test.ts create mode 100644 src/server/piPackageRoutes.ts create mode 100644 src/server/piPackageService.test.ts create mode 100644 src/server/piPackageService.ts diff --git a/.changeset/manage-pi-packages.md b/.changeset/manage-pi-packages.md new file mode 100644 index 0000000..46bd28d --- /dev/null +++ b/.changeset/manage-pi-packages.md @@ -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. diff --git a/README.md b/README.md index 86c68c6..b63902a 100644 --- a/README.md +++ b/README.md @@ -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: /.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) diff --git a/docs/config.html b/docs/config.html index 0062f9c..ffdb75c 100644 --- a/docs/config.html +++ b/docs/config.html @@ -80,8 +80,8 @@

Configure PI WEB where your agents work.

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.

@@ -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.

+

+ 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 @@ -136,7 +142,8 @@

  • maxUploadBytes: restart both the web/API process and the session daemon.
  • spawnSessions / subsessions: restart the session daemon.
  • pathAccess: applies on the next request; existing file views may need a browser 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.
  • @@ -144,9 +151,10 @@

    Global config example

    - pi-web install creates the initial file. You can also save settings from - Settings → General, Settings → Plugins, Settings → Keyboard, - and Settings → Session daemon. + pi-web install creates the initial file. You can also save PI WEB config settings from + Settings → General, Settings → PI WEB plugins, + Settings → Keyboard, and Settings → Session daemon. Pi package operations + live separately under Settings → Pi packages.

    @@ -279,7 +287,7 @@ Restart session daemon - Plugin enablement/settings + PI WEB plugin enablement/settings plugins.<id>.enabled, plugins.<id>.settings — Global diff --git a/docs/config.md b/docs/config.md index 2707a9c..8e7e468 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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..enabled` to `false` to remove a plugin from `/pi-web-plugins/manifest.json` before the browser imports it. ```json diff --git a/docs/install.html b/docs/install.html index a304eb0..22b09cd 100644 --- a/docs/install.html +++ b/docs/install.html @@ -281,7 +281,8 @@

    Use Settings → General for host, port, and external filesystem roots; Settings → Session daemon - for agent-spawn tools; Settings → Plugins for plugin enablement; and Settings → Keyboard + for agent-spawn tools; Settings → Pi packages for Pi package install/remove/update; + Settings → PI WEB plugins for browser plugin enablement; and Settings → Keyboard for shortcut overrides.

    diff --git a/docs/plugins.html b/docs/plugins.html index 8068d5b..f4b8e2e 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -90,10 +90,11 @@
    +
    +

    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 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 extensions, skills, prompt templates, and themes are rediscovered. A + routine session daemon restart is not required. +

    +
    +

    What to ask AI to build

    @@ -204,7 +232,7 @@ After editing, check the manifest endpoint and browser-console failure cases./pi-web-plugins/manifest.json.

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

    @@ -285,12 +313,16 @@ After editing, check the manifest endpoint and browser-console failure cases.
    -

    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 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 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 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. +

    +

    + 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.

    diff --git a/docs/plugins.md b/docs/plugins.md index d2206c0..0cd9e5c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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 `, 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//...` 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//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. diff --git a/src/client/src/api.ts b/src/client/src/api.ts index b929ea7..d1d8e5f 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -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"; diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 943977b..d13a5ed 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -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 } }; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index e3184f3..edcb195 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -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, diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index b9e1902..b351ff1 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -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 }], diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index dd56e2f..a81ffa0 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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 { @@ -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"]) }; diff --git a/src/client/src/components/SettingsDialog.ts b/src/client/src/components/SettingsDialog.ts index 86eaa7d..21e67eb 100644 --- a/src/client/src/components/SettingsDialog.ts +++ b/src/client/src/components/SettingsDialog.ts @@ -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 {
    @@ -89,6 +95,21 @@ export class SettingsDialog extends LitElement { > `; } + if (this.section === "packages") { + return html` + this.loadConfig()} + .onInstallPackage=${(source: string) => this.installPiPackage(source)} + .onRemovePackage=${(source: string, scope: PiPackageScope) => this.removePiPackage(source, scope)} + .onUpdatePackage=${(source?: string) => this.updatePiPackage(source)} + > + `; + } if (this.section === "plugins") { return html` { + await this.runPiPackageMutation({ kind: "install", source }, "install Pi package", () => piPackagesApi.install(source)); + } + + private async removePiPackage(source: string, scope: PiPackageScope): Promise { + await this.runPiPackageMutation({ kind: "remove", source }, "remove Pi package", () => piPackagesApi.remove(source, scope)); + } + + private async updatePiPackage(source?: string): Promise { + 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): Promise { + 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 { 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)}`; } } diff --git a/src/client/src/components/settings/SettingsPackagesPanel.ts b/src/client/src/components/settings/SettingsPackagesPanel.ts new file mode 100644 index 0000000..efe9794 --- /dev/null +++ b/src/client/src/components/settings/SettingsPackagesPanel.ts @@ -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; + @property({ attribute: false }) onInstallPackage?: (source: string) => void | Promise; + @property({ attribute: false }) onRemovePackage?: (source: string, scope: PiPackageScope) => void | Promise; + @property({ attribute: false }) onUpdatePackage?: (source?: string) => void | Promise; + @state() private installSource = ""; + @state() private validationMessage = ""; + + override render(): TemplateResult { + const packages = this.packagesResponse?.packages ?? []; + return html` +
    +
    +

    Pi packages

    +

    Install, remove, and update packages managed by Pi. Pi packages can provide extensions, skills, prompt templates, themes, and PI WEB browser plugins.

    +
    + +
    +
    Trusted code warning: Pi packages and PI WEB plugins can run with your user permissions. Install packages and enable plugins only from sources you trust.
    + ${this.renderMessages()} +
    { void this.installPackage(event); }}> + +
    + { this.updateInstallSource(event); }}> + +
    + ${this.validationMessage === "" ? null : html`
    ${this.validationMessage}
    `} + Installs use Pi's default package location, equivalent to pi install <source>. PI WEB does not ask you to choose an install location. +
    + ${this.renderPackageList(packages)} + `; + } + + private renderMessages(): TemplateResult | null { + if (this.error !== "") return html`
    ${this.error}
    `; + if (this.operationMessage !== "") return html`
    ${this.operationMessage}
    `; + return null; + } + + private renderPackageList(packages: PiPackageInfo[]): TemplateResult { + const updateAllReason = updateAllPiPackagesDisabledReason(packages); + return html` +
    +
    +
    +

    Configured Pi packages

    +

    This list comes from Pi's package manager settings visible to this PI WEB process.

    +
    + +
    + ${updateAllReason === undefined ? null : html`
    ${updateAllReason}
    `} + ${this.loading && packages.length === 0 ? html`
    Loading Pi packages…
    ` : packages.length === 0 ? html`
    No Pi packages configured in Pi settings yet.
    ` : html` +
    + ${packages.map((packageInfo) => this.renderPackage(packageInfo))} +
    + `} +
    + `; + } + + 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` +
    +
    + ${packageInfo.source} + ${piPackageScopeLabel(packageInfo)} · ${piPackageFilteredLabel(packageInfo)} + Installed path: ${piPackageInstalledPathLabel(packageInfo)} + ${updateReason === undefined ? null : html`${updateReason}`} +
    +
    + + +
    +
    + `; + } + + private updateInstallSource(event: Event): void { + this.installSource = event.target instanceof HTMLInputElement ? event.target.value : ""; + this.validationMessage = ""; + } + + private async installPackage(event: Event): Promise { + 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 { + 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 { + 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; } + } + `; +} diff --git a/src/client/src/components/settings/SettingsPluginsPanel.ts b/src/client/src/components/settings/SettingsPluginsPanel.ts index c6930bb..012824d 100644 --- a/src/client/src/components/settings/SettingsPluginsPanel.ts +++ b/src/client/src/components/settings/SettingsPluginsPanel.ts @@ -18,14 +18,15 @@ export class SettingsPluginsPanel extends LitElement { return html`
    -

    Plugins

    -

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

    +

    PI WEB plugins

    +

    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.

    ${this.renderMessages()} +
    Trusted code warning: PI WEB plugins and Pi packages can run with your user permissions. Enable plugins only from sources you trust.
    Config key: plugins. Plugins are enabled unless their entry sets enabled to false.
    - ${this.loading && plugins.length === 0 ? html`
    Loading plugins…
    ` : plugins.length === 0 ? html`
    No external or bundled plugins discovered.
    ` : html` + ${this.loading && plugins.length === 0 ? html`
    Loading PI WEB plugins…
    ` : plugins.length === 0 ? html`
    No PI WEB browser plugins discovered.
    ` : html`
    ${plugins.map((plugin) => this.renderPlugin(plugin))}
    @@ -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; } diff --git a/src/client/src/components/settings/piPackageSettings.test.ts b/src/client/src/components/settings/piPackageSettings.test.ts new file mode 100644 index 0000000..f95189f --- /dev/null +++ b/src/client/src/components/settings/piPackageSettings.test.ts @@ -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"); + }); +}); diff --git a/src/client/src/components/settings/piPackageSettings.ts b/src/client/src/components/settings/piPackageSettings.ts new file mode 100644 index 0000000..7bcad1d --- /dev/null +++ b/src/client/src/components/settings/piPackageSettings.ts @@ -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): string { + return packageInfo.scope === "project" ? "Project scope" : "User scope"; +} + +export function piPackageFilteredLabel(packageInfo: Pick): string { + return packageInfo.filtered ? "Filtered by current Pi package settings" : "Available in this PI WEB process"; +} + +export function piPackageInstalledPathLabel(packageInfo: Pick): string { + return packageInfo.installedPath ?? "Installed path not reported by Pi"; +} + +export function canUpdatePiPackage(packageInfo: Pick): boolean { + return packageInfo.scope === "user"; +} + +export function piPackageUpdateDisabledReason(packageInfo: Pick): 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[]): boolean { + return packages.length > 0 && packages.every(canUpdatePiPackage); +} + +export function updateAllPiPackagesDisabledReason(packages: readonly Pick[]): 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.`; +} diff --git a/src/client/src/settingsRoute.test.ts b/src/client/src/settingsRoute.test.ts index def70d5..4e645e0 100644 --- a/src/client/src/settingsRoute.test.ts +++ b/src/client/src/settingsRoute.test.ts @@ -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"); diff --git a/src/client/src/settingsRoute.ts b/src/client/src/settingsRoute.ts index e648f67..f8f1b4b 100644 --- a/src/client/src/settingsRoute.ts +++ b/src/client/src/settingsRoute.ts @@ -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; diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 955c5f5..618c402 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -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()).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) => { diff --git a/src/server/app.ts b/src/server/app.ts index c0bcf5d..5eec41f 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -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; + piPackages?: PiPackageService; config?: PiWebConfigService; clientDist?: string | false; logger?: FastifyServerOptions["logger"]; @@ -122,6 +125,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebStatus(sessionDaemon), { @@ -145,6 +149,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise 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); diff --git a/src/server/piPackageRoutes.test.ts b/src/server/piPackageRoutes.test.ts new file mode 100644 index 0000000..5f58e96 --- /dev/null +++ b/src/server/piPackageRoutes.test.ts @@ -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; + +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(() => Promise.resolve({ packages: [...packages] })); + const install = vi.fn((source) => Promise.resolve({ action: "install", source, packages: [...packages] })); + const remove = vi.fn((source, scope = "user") => Promise.resolve({ action: "remove", source, scope, removed: true, packages: [...packages] })); + const update = vi.fn((source) => Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages: [...packages] })); + const service: PiPackageService = { list, install, remove, update }; + return { service, list, install, remove, update }; +} diff --git a/src/server/piPackageRoutes.ts b/src/server/piPackageRoutes.ts new file mode 100644 index 0000000..3db8f8e --- /dev/null +++ b/src/server/piPackageRoutes.ts @@ -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 { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/server/piPackageService.test.ts b/src/server/piPackageService.test.ts new file mode 100644 index 0000000..788c071 --- /dev/null +++ b/src/server/piPackageService.test.ts @@ -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(() => packages); + const installAndPersist = vi.fn(() => Promise.resolve()); + const removeAndPersist = vi.fn(() => Promise.resolve(true)); + const update = vi.fn(() => 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"); + }); +}); diff --git a/src/server/piPackageService.ts b/src/server/piPackageService.ts new file mode 100644 index 0000000..64795a5 --- /dev/null +++ b/src/server/piPackageService.ts @@ -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; + removeAndPersist(source: string, options?: { local?: boolean }): Promise; + update(source?: string): Promise; + flush?(): Promise; +} + +export interface PiPackageService { + list(): Promise; + install(source: string): Promise; + remove(source: string, scope?: PiPackageScope): Promise; + update(source?: string): Promise; +} + +export class DefaultPiPackageService implements PiPackageService { + constructor(private readonly manager: PiPackageManagerPort) {} + + list(): Promise { + return Promise.resolve({ packages: this.listPackages() }); + } + + async install(source: string): Promise { + await this.manager.installAndPersist(source); + await this.flushSettings(); + return this.mutationResponse("install", { source }); + } + + async remove(source: string, scope: PiPackageScope = "user"): Promise { + 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 { + 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 { + return { action, ...metadata, packages: this.listPackages() }; + } + + private async flushSettings(): Promise { + 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(), + }); +} diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 7b4ec66..f18ec5d 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -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 { + 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 }): Promise { await mkdir(root, { recursive: true }); await writeFile(join(root, "package.json"), `${JSON.stringify(options.packageJson, null, 2)}\n`); diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index b952d53..e0909a4 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -69,22 +69,22 @@ interface PiWebPluginEntry { type ArraylessPluginRecord = Omit; 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), + }); } } diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 093504b..4975cf4 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -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;