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