feat: manage Pi packages on selected machines

This commit is contained in:
Federico Jaramillo Martinez
2026-07-01 21:54:18 +02:00
parent 889672ff53
commit 3df4041bd7
18 changed files with 348 additions and 72 deletions
+29 -1
View File
@@ -61,7 +61,7 @@ describe("machine-scoped runtime API", () => {
});
describe("Pi package API", () => {
it("uses the local Pi package-management routes for list and mutations", async () => {
it("preserves the legacy local Pi package-management routes by default", async () => {
const packages = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
const fetchMock = stubSequenceFetch([
jsonResponse({ packages }),
@@ -90,6 +90,34 @@ describe("Pi package API", () => {
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "npm:@acme/tools" });
expect(fetchCall(fetchMock, 4)[1]?.body).toBeUndefined();
});
it("uses machine-scoped Pi package-management routes when a machine id is provided", async () => {
const packages = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
const fetchMock = stubSequenceFetch([
jsonResponse({ packages }),
jsonResponse({ packages }),
jsonResponse({ action: "install", source: "npm:@acme/new-tools", packages }),
jsonResponse({ action: "remove", source: "../project-tools", removed: true, packages }),
jsonResponse({ action: "update", packages }),
]);
await expect(piPackagesApi.packages("local")).resolves.toEqual({ packages });
await expect(piPackagesApi.packages("remote a")).resolves.toEqual({ packages });
await piPackagesApi.install("npm:@acme/new-tools", "remote a");
await piPackagesApi.remove("../project-tools", undefined, "remote a");
await piPackagesApi.update(undefined, "remote a");
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/machines/local/pi-packages",
"/api/machines/remote%20a/pi-packages",
"/api/machines/remote%20a/pi-packages/install",
"/api/machines/remote%20a/pi-packages/remove",
"/api/machines/remote%20a/pi-packages/update",
]);
expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ source: "npm:@acme/new-tools" });
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "../project-tools" });
expect(fetchCall(fetchMock, 4)[1]?.body).toBeUndefined();
});
});
describe("session API compatibility", () => {
+12 -7
View File
@@ -121,19 +121,24 @@ export const pluginsApi = {
plugins: () => request("/api/plugins", parsePiWebPluginsResponse),
};
function piPackageUrl(endpoint = "", machineId?: string): string {
const baseUrl = machineId === undefined ? "/api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`;
return endpoint === "" ? baseUrl : `${baseUrl}/${endpoint}`;
}
export const piPackagesApi = {
packages: () => request("/api/pi-packages", parsePiPackagesResponse),
install: (source: string) => {
packages: (machineId?: string) => request(piPackageUrl("", machineId), parsePiPackagesResponse),
install: (source: string, machineId?: string) => {
const body: PiPackageInstallRequest = { source };
return request("/api/pi-packages/install", parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
return request(piPackageUrl("install", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
},
remove: (source: string, scope?: PiPackageScope) => {
remove: (source: string, scope?: PiPackageScope, machineId?: string) => {
const body: PiPackageRemoveRequest = scope === undefined ? { source } : { source, scope };
return request("/api/pi-packages/remove", parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
return request(piPackageUrl("remove", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
},
update: (source?: string) => {
update: (source?: string, machineId?: string) => {
const body: PiPackageUpdateRequest | undefined = source === undefined ? undefined : { source };
return request("/api/pi-packages/update", parsePiPackageMutationResponse, { method: "POST", ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
return request(piPackageUrl("update", machineId), parsePiPackageMutationResponse, { method: "POST", ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
},
};
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Workspace } from "../../../shared/apiTypes";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
import { activityApi, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
import { activityApi, filesApi, gitApi, piPackagesApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
import { workspaceImagePreviewUrl } from "./urls";
@@ -28,6 +28,10 @@ describe("federated route contract", () => {
await Promise.all([
ignoreParseFailure(piWebApi.piWebStatus(machineId)),
ignoreParseFailure(piPackagesApi.packages(machineId)),
ignoreParseFailure(piPackagesApi.install("npm:@acme/tools", machineId)),
ignoreParseFailure(piPackagesApi.remove("npm:@acme/tools", "user", machineId)),
ignoreParseFailure(piPackagesApi.update("npm:@acme/tools", machineId)),
ignoreParseFailure(activityApi.workspaceActivity(machineId)),
ignoreParseFailure(projectsApi.projects(machineId)),
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
+1 -1
View File
@@ -1923,7 +1923,7 @@ export class PiWebApp extends LitElement {
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
${this.sessionCleanupDialog !== undefined ? html`<session-cleanup-dialog .canCleanup=${this.canCleanupSessions()} .unavailableMessage=${this.sessionCleanupUnavailableMessage()} .preview=${this.sessionCleanupDialog.preview} .previewRequest=${this.sessionCleanupDialog.previewRequest} .result=${this.sessionCleanupDialog.result} .loading=${this.sessionCleanupDialog.loading === true} .running=${this.sessionCleanupDialog.running === true} .error=${this.sessionCleanupDialog.error ?? ""} .onPreview=${(request: SessionCleanupRequest) => { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}></session-cleanup-dialog>` : null}
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
</div>
`;
}
+115 -28
View File
@@ -1,19 +1,20 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { AppAction } from "../actions";
import { configApi, piPackagesApi, pluginsApi, type PiPackageMutationResponse, type PiPackageScope, type PiPackagesResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
import { configApi, piPackagesApi, pluginsApi, type Machine, 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";
import { friendlyPiPackageErrorMessage, piPackageMutationFollowUpMessage, piPackageTargetContext, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
@customElement("settings-dialog")
export class SettingsDialog extends LitElement {
@property({ attribute: false }) section: SettingsSection = "general";
@property({ attribute: false }) actions: AppAction[] = [];
@property({ attribute: false }) machine: Machine | undefined;
@property({ attribute: false }) onNavigate?: (section: SettingsSection) => void;
@property({ attribute: false }) onClose?: () => void;
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
@@ -24,9 +25,13 @@ export class SettingsDialog extends LitElement {
@state() private saving = false;
@state() private packageOperation: PiPackageOperationState | undefined;
@state() private error = "";
@state() private packageError = "";
@state() private savedMessage = "";
@state() private packageMessage = "";
private savedMessageTimer: number | undefined;
private loadRequestSeq = 0;
private packageMutationSeq = 0;
private lastRequestedPackageTargetId: string | undefined;
override connectedCallback(): void {
super.connectedCallback();
@@ -39,6 +44,15 @@ export class SettingsDialog extends LitElement {
super.disconnectedCallback();
}
protected override updated(changed: PropertyValues<this>): void {
if (!changed.has("machine")) return;
const previousTarget = piPackageTargetContext(changed.get("machine"));
const currentTarget = this.packageTarget();
if (previousTarget.id === currentTarget.id) return;
this.resetPackageStateForTargetChange();
if (this.isConnected && this.lastRequestedPackageTargetId !== currentTarget.id) void this.loadConfig();
}
override render(): TemplateResult {
return html`
<div class="backdrop" @mousedown=${() => this.onClose?.()}>
@@ -52,13 +66,14 @@ export class SettingsDialog extends LitElement {
</header>
<div class="settings-body">
<nav class="settings-nav" aria-label="Settings sections">
${this.renderNavButton("general", "General", "Server config")}
${this.renderNavButton("sessiond", "Session daemon", "Runtime settings")}
${this.renderNavButton("packages", "Pi packages", "Install and manage")}
${this.renderNavButton("plugins", "PI WEB plugins", "Enable and disable")}
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
${this.renderNavButton("general", "General", "Gateway config")}
${this.renderNavButton("sessiond", "Session daemon", "Gateway runtime")}
${this.renderNavButton("packages", "Pi packages", "Selected machine")}
${this.renderNavButton("plugins", "PI WEB plugins", "Gateway plugins")}
${this.renderNavButton("shortcuts", "Keyboard", "Gateway shortcuts")}
</nav>
<main class="settings-content">
${this.renderScopeNote()}
${this.renderActiveSection()}
</main>
</div>
@@ -99,9 +114,10 @@ export class SettingsDialog extends LitElement {
return html`
<settings-packages-panel
.packagesResponse=${this.packagesResponse}
.targetMachine=${this.packageTarget()}
.loading=${this.loading}
.operation=${this.packageOperation}
.error=${this.error}
.error=${this.packageError}
.operationMessage=${this.packageMessage}
.onReload=${() => this.loadConfig()}
.onInstallPackage=${(source: string) => this.installPiPackage(source)}
@@ -147,22 +163,53 @@ export class SettingsDialog extends LitElement {
`;
}
private renderScopeNote(): TemplateResult {
return html`
<div class="scope-note" role="note">
<strong>This tab edits:</strong> ${this.settingsScopeMessage()}
</div>
`;
}
private settingsScopeMessage(): string {
if (this.section === "packages") return `Selected machine packages: ${piPackageTargetLabel(this.packageTarget())}.`;
if (this.section === "sessiond") return "Local gateway session-daemon config.";
if (this.section === "plugins") return "Local gateway PI WEB plugin enablement.";
if (this.section === "shortcuts") return "Local gateway keyboard shortcuts.";
return "Local gateway config.";
}
private navigate(section: SettingsSection): void {
this.onNavigate?.(section);
}
private async loadConfig(): Promise<void> {
const target = this.packageTarget();
const requestSeq = ++this.loadRequestSeq;
this.lastRequestedPackageTargetId = target.id;
this.loading = true;
this.error = "";
this.packageError = "";
try {
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)}`;
const [config, plugins, packages] = await Promise.allSettled([configApi.config(), pluginsApi.plugins(), piPackagesApi.packages(target.id)]);
if (!this.isCurrentLoad(requestSeq, target)) return;
const errors: string[] = [];
if (config.status === "fulfilled") this.configResponse = config.value;
else errors.push(`config: ${errorMessage(config.reason)}`);
if (plugins.status === "fulfilled") this.pluginsResponse = plugins.value;
else errors.push(`PI WEB plugins: ${errorMessage(plugins.reason)}`);
if (packages.status === "fulfilled") this.packagesResponse = packages.value;
else {
this.packagesResponse = undefined;
this.packageError = `Failed to load Pi packages from ${piPackageTargetLabel(target)}: ${friendlyPiPackageErrorMessage(errorMessage(packages.reason), target)}`;
}
if (errors.length > 0) this.error = `Failed to load settings: ${errors.join("; ")}`;
} finally {
this.loading = false;
if (this.isCurrentLoad(requestSeq, target)) this.loading = false;
}
}
@@ -177,7 +224,8 @@ export class SettingsDialog extends LitElement {
[pluginId]: { ...currentPluginConfig, enabled },
},
});
await this.refreshPlugins();
const pluginRefreshError = await this.refreshPlugins();
if (pluginRefreshError !== undefined) this.error = pluginRefreshError;
}
private async saveConfig(config: PiWebConfigValues): Promise<void> {
@@ -186,6 +234,7 @@ export class SettingsDialog extends LitElement {
this.error = "";
this.savedMessage = "";
this.packageMessage = "";
this.packageError = "";
try {
const response = await configApi.saveConfig(config);
this.configResponse = response;
@@ -199,46 +248,83 @@ export class SettingsDialog extends LitElement {
}
private async installPiPackage(source: string): Promise<void> {
await this.runPiPackageMutation({ kind: "install", source }, "install Pi package", () => piPackagesApi.install(source));
const target = this.packageTarget();
await this.runPiPackageMutation({ kind: "install", source }, "install Pi package", target, () => piPackagesApi.install(source, target.id));
}
private async removePiPackage(source: string, scope: PiPackageScope): Promise<void> {
await this.runPiPackageMutation({ kind: "remove", source }, "remove Pi package", () => piPackagesApi.remove(source, scope));
const target = this.packageTarget();
await this.runPiPackageMutation({ kind: "remove", source }, "remove Pi package", target, () => piPackagesApi.remove(source, scope, target.id));
}
private async updatePiPackage(source?: string): Promise<void> {
await this.runPiPackageMutation(source === undefined ? { kind: "update-all" } : { kind: "update", source }, "update Pi packages", () => piPackagesApi.update(source));
const target = this.packageTarget();
await this.runPiPackageMutation(source === undefined ? { kind: "update-all" } : { kind: "update", source }, "update Pi packages", target, () => piPackagesApi.update(source, target.id));
}
private async runPiPackageMutation(operation: PiPackageOperationState, label: string, mutate: () => Promise<PiPackageMutationResponse>): Promise<void> {
private async runPiPackageMutation(operation: PiPackageOperationState, label: string, target: PiPackageTargetContext, mutate: () => Promise<PiPackageMutationResponse>): Promise<void> {
if (this.saving) throw new Error("A settings operation is already running.");
const requestSeq = ++this.packageMutationSeq;
this.saving = true;
this.packageOperation = operation;
this.error = "";
this.packageError = "";
this.savedMessage = "";
this.packageMessage = "";
try {
const response = await mutate();
if (!this.isCurrentPackageMutation(requestSeq, target)) return;
this.packagesResponse = { packages: response.packages };
await this.refreshPlugins();
this.packageMessage = piPackageMutationFollowUpMessage(response.action);
const pluginRefreshError = shouldRefreshGatewayPluginsAfterPiPackageMutation(target) ? await this.refreshPlugins() : undefined;
if (!this.isCurrentPackageMutation(requestSeq, target)) return;
if (pluginRefreshError !== undefined) this.packageError = pluginRefreshError;
this.packageMessage = piPackageMutationFollowUpMessage(response.action, target);
} catch (error) {
this.error = `Failed to ${label}: ${errorMessage(error)}`;
if (this.isCurrentPackageMutation(requestSeq, target)) this.packageError = `Failed to ${label} on ${piPackageTargetLabel(target)}: ${friendlyPiPackageErrorMessage(errorMessage(error), target)}`;
throw error;
} finally {
this.packageOperation = undefined;
this.saving = false;
if (this.packageMutationSeq === requestSeq) {
this.packageOperation = undefined;
this.saving = false;
}
}
}
private async refreshPlugins(): Promise<void> {
private async refreshPlugins(): Promise<string | undefined> {
try {
this.pluginsResponse = await pluginsApi.plugins();
return undefined;
} catch (error) {
this.error = `Failed to refresh PI WEB plugins: ${errorMessage(error)}`;
return `Failed to refresh PI WEB plugins: ${errorMessage(error)}`;
}
}
private packageTarget(): PiPackageTargetContext {
return piPackageTargetContext(this.machine);
}
private isCurrentLoad(requestSeq: number, target: PiPackageTargetContext): boolean {
return requestSeq === this.loadRequestSeq && this.isCurrentPackageTarget(target);
}
private isCurrentPackageMutation(requestSeq: number, target: PiPackageTargetContext): boolean {
return requestSeq === this.packageMutationSeq && this.isCurrentPackageTarget(target);
}
private isCurrentPackageTarget(target: PiPackageTargetContext): boolean {
return this.packageTarget().id === target.id;
}
private resetPackageStateForTargetChange(): void {
const hadPackageOperation = this.packageOperation !== undefined;
this.packageMutationSeq += 1;
this.packageOperation = undefined;
this.packageMessage = "";
this.packageError = "";
this.packagesResponse = undefined;
if (hadPackageOperation) this.saving = false;
}
private showSavedMessage(): void {
this.savedMessage = "Config saved.";
if (this.savedMessageTimer !== undefined) window.clearTimeout(this.savedMessageTimer);
@@ -272,6 +358,7 @@ export class SettingsDialog extends LitElement {
.settings-nav button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
.settings-nav small { color: var(--pi-muted); }
.settings-content { min-width: 0; min-height: 0; overflow: auto; padding: 18px; }
.scope-note { margin-bottom: 14px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); color: var(--pi-text); padding: 10px 12px; line-height: 1.45; }
@media (max-width: 760px) {
.backdrop { padding: 0; place-items: stretch; }
@@ -1,13 +1,14 @@
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";
import { isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageInstalledPathLabel, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageTargetContext, piPackageTargetLabel, piPackageUpdateDisabledReason, updateAllPiPackagesDisabledReason, type PiPackageOperationState, type PiPackageTargetContext } 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({ attribute: false }) targetMachine: PiPackageTargetContext | undefined;
@property() error = "";
@property() operationMessage = "";
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@@ -19,13 +20,15 @@ export class SettingsPackagesPanel extends LitElement {
override render(): TemplateResult {
const packages = this.packagesResponse?.packages ?? [];
const target = this.packageTarget;
const targetLabel = piPackageTargetLabel(target);
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, context/system prompt files, and PI WEB browser plugins.</p>
<p>Managing Pi packages on <strong>${targetLabel}</strong>. Install, remove, and update packages managed by Pi on the selected machine. Pi packages can provide extensions, skills, prompt templates, themes, context/system prompt files, and PI WEB browser plugins.</p>
</div>
<button class="secondary" ?disabled=${this.loading || this.isOperating} @click=${() => { void this.onReload?.(); }}>Reload</button>
<button class="secondary" title=${`Reload Pi packages from ${targetLabel}`} ?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()}
@@ -36,9 +39,9 @@ export class SettingsPackagesPanel extends LitElement {
<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>
<small>Installs run on ${targetLabel} and 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)}
${this.renderPackageList(packages, target)}
`;
}
@@ -48,21 +51,22 @@ export class SettingsPackagesPanel extends LitElement {
return null;
}
private renderPackageList(packages: PiPackageInfo[]): TemplateResult {
private renderPackageList(packages: PiPackageInfo[], target: PiPackageTargetContext): TemplateResult {
const updateAllReason = updateAllPiPackagesDisabledReason(packages);
const targetLabel = piPackageTargetLabel(target);
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>
<p>This list comes from Pi's package manager settings on ${targetLabel}.</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`
${this.loading && packages.length === 0 ? html`<div class="loading-card">Loading Pi packages from ${targetLabel}…</div>` : packages.length === 0 ? html`<div class="loading-card">No Pi packages configured in Pi settings on ${targetLabel} yet.</div>` : html`
<div class="package-list">
${packages.map((packageInfo) => this.renderPackage(packageInfo))}
</div>
@@ -130,6 +134,10 @@ export class SettingsPackagesPanel extends LitElement {
}
}
private get packageTarget(): PiPackageTargetContext {
return this.targetMachine ?? piPackageTargetContext(undefined);
}
private get isOperating(): boolean {
return this.operation !== undefined;
}
@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import type { PiPackageInfo } from "../../api";
import { canUpdateAllPiPackages, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageMutationFollowUpMessage, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageUpdateDisabledReason, updateAllPiPackagesDisabledReason } from "./piPackageSettings";
import { canUpdateAllPiPackages, friendlyPiPackageErrorMessage, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageMutationFollowUpMessage, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageTargetContext, piPackageTargetLabel, piPackageUpdateDisabledReason, shouldRefreshGatewayPluginsAfterPiPackageMutation, updateAllPiPackagesDisabledReason, type PiPackageTargetContext } 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 };
const localTarget: PiPackageTargetContext = { id: "local", name: "local", kind: "local" };
const remoteTarget: PiPackageTargetContext = { id: "remote-a", name: "Lab Mac", kind: "remote" };
describe("Pi package settings helpers", () => {
it("normalizes and validates install sources without adding location choices", () => {
@@ -34,6 +36,14 @@ describe("Pi package settings helpers", () => {
expect(isPiPackageOperationPending({ kind: "update-all" }, "update-all")).toBe(true);
});
it("labels package targets and gateway plugin refresh scope", () => {
expect(piPackageTargetContext(undefined)).toEqual(localTarget);
expect(piPackageTargetLabel(localTarget)).toBe("local (local gateway)");
expect(piPackageTargetLabel(remoteTarget)).toBe("Lab Mac (remote machine)");
expect(shouldRefreshGatewayPluginsAfterPiPackageMutation(localTarget)).toBe(true);
expect(shouldRefreshGatewayPluginsAfterPiPackageMutation(remoteTarget)).toBe(false);
});
it("describes the browser and session reload follow-up without requiring sessiond restarts", () => {
const message = piPackageMutationFollowUpMessage("install");
@@ -43,4 +53,19 @@ describe("Pi package settings helpers", () => {
expect(message).not.toContain("session daemon");
expect(message).not.toContain("sessiond");
});
it("scopes remote package mutation follow-up copy to the selected machine", () => {
const message = piPackageMutationFollowUpMessage("update", remoteTarget);
expect(message).toContain("Pi package updated on Lab Mac");
expect(message).toContain("each idle PI WEB session on Lab Mac");
expect(message).toContain("PI WEB browser plugin changes served by Lab Mac");
});
it("turns older remote route failures into package-management compatibility guidance", () => {
expect(friendlyPiPackageErrorMessage("Not Found", remoteTarget)).toBe("Pi package management is not available on Lab Mac. Update and restart Pi-Web on that machine, then try again.");
expect(friendlyPiPackageErrorMessage("Remote machine unavailable", remoteTarget)).toBe("Could not reach Lab Mac for Pi package management. Check the machine connection and try again.");
expect(friendlyPiPackageErrorMessage("Remote machine timeout", remoteTarget)).toContain("may still be running remotely");
expect(friendlyPiPackageErrorMessage("Not Found", localTarget)).toBe("Not Found");
});
});
@@ -1,4 +1,4 @@
import type { PiPackageInfo, PiPackageMutationAction } from "../../api";
import type { Machine, MachineKind, PiPackageInfo, PiPackageMutationAction } from "../../api";
export type PiPackageOperationKind = PiPackageMutationAction | "update-all";
@@ -7,6 +7,25 @@ export interface PiPackageOperationState {
source?: string;
}
export interface PiPackageTargetContext {
id: string;
name: string;
kind: MachineKind;
}
export function piPackageTargetContext(machine: Pick<Machine, "id" | "name" | "kind"> | undefined): PiPackageTargetContext {
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
return { id: "local", name: "local", kind: "local" };
}
export function piPackageTargetLabel(target: PiPackageTargetContext): string {
return target.kind === "local" ? `${target.name} (local gateway)` : `${target.name} (remote machine)`;
}
export function shouldRefreshGatewayPluginsAfterPiPackageMutation(target: PiPackageTargetContext): boolean {
return target.kind === "local";
}
export function normalizePiPackageSource(source: string): string {
return source.trim();
}
@@ -52,7 +71,31 @@ export function isPiPackageOperationPending(operation: PiPackageOperationState |
return source === undefined || operation.source === source;
}
export function piPackageMutationFollowUpMessage(action: PiPackageMutationAction): string {
export function piPackageMutationFollowUpMessage(action: PiPackageMutationAction, target = piPackageTargetContext(undefined)): string {
const verb = action === "install" ? "installed" : action === "remove" ? "removed" : "updated";
return `Pi package ${verb}. Type /reload in each idle PI WEB session to rediscover Pi runtime resources: extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for PI WEB browser plugin changes.`;
const targetSuffix = target.kind === "local" ? "" : ` on ${target.name}`;
const sessionScope = target.kind === "local" ? "each idle PI WEB session" : `each idle PI WEB session on ${target.name}`;
const pluginScope = target.kind === "local" ? "PI WEB browser plugin changes" : `PI WEB browser plugin changes served by ${target.name}`;
return `Pi package ${verb}${targetSuffix}. Type /reload in ${sessionScope} to rediscover Pi runtime resources: extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for ${pluginScope}.`;
}
export function friendlyPiPackageErrorMessage(message: string, target: PiPackageTargetContext): string {
const normalized = message.trim();
if (target.kind !== "remote") return normalized;
if (isUnsupportedRemotePiPackageRouteMessage(normalized)) {
return `Pi package management is not available on ${target.name}. Update and restart Pi-Web on that machine, then try again.`;
}
if (normalized === "Remote machine timeout") {
return `Timed out while contacting ${target.name} for Pi package management. The package operation may still be running remotely; reload the package list before retrying.`;
}
if (normalized === "Remote machine unavailable") {
return `Could not reach ${target.name} for Pi package management. Check the machine connection and try again.`;
}
return normalized;
}
function isUnsupportedRemotePiPackageRouteMessage(message: string): boolean {
return message === "Not Found"
|| /route\s+(GET|POST):?\/api\/pi-packages\b.*not found/iu.test(message)
|| /cannot\s+(GET|POST)\s+.*\/api\/pi-packages\b/iu.test(message);
}
+32 -1
View File
@@ -14,6 +14,7 @@ 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 { PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS } from "../shared/federatedRoutes.js";
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
@@ -159,6 +160,28 @@ describe("buildApp", () => {
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
});
it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn<MachineClient["request"]>((method, path, body) => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ method, path, body })]),
}));
remoteClient = fakeRemoteClient({ request });
const listResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-packages` });
const installBody = { source: "npm:@acme/new-tools" };
const installResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/pi-packages/install`, payload: installBody });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json()).toEqual({ method: "GET", path: "/api/pi-packages" });
expect(installResponse.statusCode).toBe(200);
expect(installResponse.json()).toEqual({ method: "POST", path: "/api/pi-packages/install", body: installBody });
expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/pi-packages", undefined);
expect(request).toHaveBeenNthCalledWith(2, "POST", "/api/pi-packages/install", installBody, { timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS });
});
it("proxies remote workspace effective upload config through the existing federated workspace route", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
@@ -401,7 +424,15 @@ describe("buildApp", () => {
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" }]);
const localAliasResponse = await app.inject({ method: "POST", url: "/api/machines/local/pi-packages/remove", payload: { source: "npm:@acme/tools", scope: "user" } });
expect(localAliasResponse.statusCode).toBe(200);
expect(localAliasResponse.json()).toMatchObject({ action: "remove", source: "npm:@acme/tools", scope: "user" });
expect(piPackageRequests).toEqual([
{ action: "list" },
{ action: "install", source: "npm:@acme/new-tools" },
{ action: "remove", source: "npm:@acme/tools", scope: "user" },
]);
});
it("serves the PI WEB plugin manifest and plugin assets", async () => {
+1
View File
@@ -150,6 +150,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
app.get("/api/plugins", async () => piWebPlugins.plugins());
registerPiPackageRoutes(app, piPackages);
registerPiPackageRoutes(app, piPackages, "/api/machines/local");
registerConfigRoutes(app, configService);
registerMachineRoutes(app, machines);
+12 -8
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import type { WebSocket } from "ws";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../shared/federatedRoutes.js";
import { bridgeSockets } from "../webSocketBridge.js";
import { RemoteMachineRequestError, type MachineRequestOptions } from "./machineClient.js";
import { MachineService } from "./machineService.js";
@@ -23,7 +23,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
app.route<{ Params: { machineId: string }; Body: unknown }>({
method: spec.method,
url: `/api/machines/:machineId${spec.path}`,
handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
handler: (request, reply) => proxyHttpRequest(machines, spec, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
});
}
@@ -34,7 +34,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
}
}
async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
async function proxyHttpRequest(machines: MachineService, spec: FederatedHttpRouteSpec, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
if (machineId === "local") {
return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" });
}
@@ -45,7 +45,7 @@ async function proxyHttpRequest(machines: MachineService, machineId: string, met
}
try {
const requestOptions = proxyRequestOptions(body, contentType);
const requestOptions = proxyRequestOptions(spec, body, contentType);
const upstream = requestOptions === undefined
? await client.request(method, remoteApiPath(machineId, requestUrl), body)
: await client.request(method, remoteApiPath(machineId, requestUrl), body, requestOptions);
@@ -84,10 +84,14 @@ function remoteApiPath(machineId: string, requestUrl: string): string {
return `/api${compatPath}`;
}
function proxyRequestOptions(body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
if (!isRawProxyBody(body)) return undefined;
const value = firstHeaderValue(contentType);
return value === undefined || value === "" ? undefined : { contentType: value };
function proxyRequestOptions(spec: Pick<FederatedHttpRouteSpec, "timeoutMs">, body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
const options: MachineRequestOptions = {};
if (spec.timeoutMs !== undefined) options.timeoutMs = spec.timeoutMs;
if (isRawProxyBody(body)) {
const value = firstHeaderValue(contentType);
if (value !== undefined && value !== "") options.contentType = value;
}
return Object.keys(options).length === 0 ? undefined : options;
}
function isRawProxyBody(body: unknown): boolean {
+17
View File
@@ -29,6 +29,23 @@ describe("registerPiPackageRoutes", () => {
expect(serviceMocks.list).toHaveBeenCalledOnce();
});
it("registers package routes under a custom API prefix", async () => {
const prefixedApp = Fastify({ logger: false });
const prefixedMocks = fakePiPackageService();
registerPiPackageRoutes(prefixedApp, prefixedMocks.service, "/api/machines/local");
await prefixedApp.ready();
try {
const response = await prefixedApp.inject({ method: "GET", url: "/api/machines/local/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(prefixedMocks.list).toHaveBeenCalledOnce();
} finally {
await prefixedApp.close();
}
});
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" } });
+12 -5
View File
@@ -4,8 +4,10 @@ import { createDefaultPiPackageService, type PiPackageService } from "./piPackag
class PiPackageRequestValidationError extends Error {}
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService()): void {
app.get("/api/pi-packages", async (_request, reply) => {
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService(), prefix = "/api"): void {
const routePrefix = normalizeRoutePrefix(prefix);
app.get(`${routePrefix}/pi-packages`, async (_request, reply) => {
try {
return await service.list();
} catch (error) {
@@ -13,7 +15,7 @@ export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackage
}
});
app.post<{ Body: unknown }>("/api/pi-packages/install", async (request, reply) => {
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/install`, async (request, reply) => {
try {
return await service.install(parseRequiredSourceRequest(request.body));
} catch (error) {
@@ -21,7 +23,7 @@ export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackage
}
});
app.post<{ Body: unknown }>("/api/pi-packages/remove", async (request, reply) => {
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/remove`, async (request, reply) => {
try {
const body = requireRequestObject(request.body);
return await service.remove(parseRequiredSource(body["source"]), parseOptionalScope(body["scope"]));
@@ -30,7 +32,7 @@ export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackage
}
});
app.post<{ Body: unknown }>("/api/pi-packages/update", async (request, reply) => {
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/update`, async (request, reply) => {
try {
const source = parseOptionalUpdateSource(request.body);
return source === undefined ? await service.update() : await service.update(source);
@@ -40,6 +42,11 @@ export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackage
});
}
function normalizeRoutePrefix(prefix: string): string {
const normalized = prefix.replace(/\/+$/u, "");
return normalized === "" ? "/api" : normalized;
}
function parseRequiredSourceRequest(body: unknown): string {
const request = requireRequestObject(body);
if (request["scope"] !== undefined || request["local"] !== undefined) {
+7
View File
@@ -1,12 +1,19 @@
export type FederatedHttpMethod = "GET" | "POST" | "PUT" | "DELETE";
export const PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS = 5 * 60_000;
export interface FederatedHttpRouteSpec {
method: FederatedHttpMethod;
path: string;
timeoutMs?: number;
}
export const FEDERATED_HTTP_ROUTES = [
{ method: "GET", path: "/pi-web/status" },
{ method: "GET", path: "/pi-packages" },
{ method: "POST", path: "/pi-packages/install", timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS },
{ method: "POST", path: "/pi-packages/remove", timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS },
{ method: "POST", path: "/pi-packages/update", timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS },
{ method: "GET", path: "/projects" },
{ method: "POST", path: "/projects" },
{ method: "DELETE", path: "/projects/:projectId" },