diff --git a/.changeset/federated-pi-packages.md b/.changeset/federated-pi-packages.md new file mode 100644 index 0000000..89153f5 --- /dev/null +++ b/.changeset/federated-pi-packages.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Manage Pi packages from Settings on the selected PI WEB machine, including federated remote machines, while keeping gateway-local Settings scopes clear. diff --git a/README.md b/README.md index 7f301b9..26bf2cb 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,9 @@ Read more: [Remote-first development](https://pi-web.dev/remote-first) ## Machines and fleets -PI WEB can register other PI WEB runtimes as remote machines. One browser-facing PI WEB instance can proxy projects, files, git state, sessions, terminals, and activity from trusted remote machines. +PI WEB can register other PI WEB runtimes as remote machines. One browser-facing PI WEB instance can proxy projects, files, git state, sessions, terminals, activity, and Pi package management from trusted remote machines. + +When a remote machine is selected, **Settings → Pi packages** installs, removes, and updates packages on that selected machine. Other Settings sections such as PI WEB plugin enablement and gateway configuration stay gateway-local unless they say otherwise. Read more: [Fleet and machines guide](https://pi-web.dev/machines) @@ -105,9 +107,9 @@ Read more: [Fleet and machines guide](https://pi-web.dev/machines) PI WEB supports trusted browser-side PI WEB plugins that can add actions, workspace panels, and workspace metadata. -Pi packages are managed separately through Pi's package manager or **Settings → Pi packages**. Use **Settings → PI WEB plugins** only to enable or disable discovered browser plugins. +Pi packages are managed separately through Pi's package manager or **Settings → Pi packages**. In a federated setup, the Pi packages panel targets the selected machine and labels where installs, updates, or removals will run. Use **Settings → PI WEB plugins** only to enable or disable discovered browser plugins for the gateway you opened. -After installing, updating, or removing a Pi package, type `/reload` in each idle PI WEB session to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for newly discovered or changed PI WEB plugins. +After installing, updating, or removing a Pi package, type `/reload` in each idle PI WEB session on that machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for newly discovered or changed PI WEB plugins. Read more: [Plugin API](https://pi-web.dev/plugins) diff --git a/docs/config.md b/docs/config.md index ef02890..380a733 100644 --- a/docs/config.md +++ b/docs/config.md @@ -13,7 +13,7 @@ PI WEB uses two config files: Each PI WEB machine has its own config. When using Fleet/machine federation, edit a remote machine's config by opening that machine directly or changing files on that machine. -Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings and are managed by Pi (`pi install`, `pi remove`, `pi update`) or **Settings → Pi packages**. The PI WEB `plugins` config key only enables or disables discovered PI WEB browser plugins. +Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings on the target machine and are managed by Pi (`pi install`, `pi remove`, `pi update`) or **Settings → Pi packages**. In a federated setup, **Settings → Pi packages** targets the currently selected machine; the PI WEB `plugins` config key only enables or disables discovered PI WEB browser plugins for the gateway you opened. If you installed services with a custom config path, rerun `pi-web install --config /path/to/config.json` after changing that path or after upgrading from a version that only applied the custom path to the web service. This regenerates service files so the web/API and session daemon use the same `PI_WEB_CONFIG`. @@ -37,7 +37,7 @@ Process restarts depend on the key: - `pathAccess`: applies on the next request; existing file views may need a browser refresh. - `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh. - `plugins`: reload the browser tab after changing PI WEB plugin enablement. -- Pi package install/remove/update: not a PI WEB config key; after a mutation, type `/reload` in each idle PI WEB session to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required. +- Pi package install/remove/update: not a PI WEB config key; after a mutation, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required. - `shortcuts`: saved settings apply in the browser after config refresh/save. ## Global config example @@ -173,7 +173,7 @@ Tracked subsessions let an agent delegate work to child sessions, get notified w ### Plugin config -The `plugins` key is only for PI WEB browser plugin enablement/settings. It does not install, remove, or update Pi packages; use **Settings → Pi packages** or Pi's package manager for package operations. +The `plugins` key is only for PI WEB browser plugin enablement/settings on the PI WEB gateway you opened. It does not install, remove, or update Pi packages; use **Settings → Pi packages** or Pi's package manager for package operations. When a remote machine is selected, **Settings → Pi packages** operates on that selected machine rather than the gateway. Plugins are enabled by default. Set `plugins..enabled` to `false` to remove a plugin from `/pi-web-plugins/manifest.json` before the browser imports it. diff --git a/docs/plugins.md b/docs/plugins.md index 085049e..f90f430 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -21,7 +21,9 @@ They do **not** run in the session daemon, do not get a server-side hook API, an Use **Settings → Pi packages** to view configured Pi packages or install/remove/update a package. Enter only the package source, such as `npm:@scope/package`, a git/URL source, or a local path. PI WEB uses Pi's default package location, equivalent to `pi install `, and does not ask for an install location. -Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A routine session daemon restart is not required. +When machine federation is enabled, **Settings → Pi packages** targets the currently selected machine. The panel labels whether changes will run on the local/gateway machine or on a selected remote PI WEB machine. If an older or unavailable remote PI WEB server does not expose package-management routes, PI WEB reports the package management operation as unsupported or unavailable instead of silently falling back to the gateway. + +Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. This plugin enablement surface is for the PI WEB gateway you opened; to change remote plugin enablement, open that remote machine directly or edit its PI WEB config on that machine. After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A routine session daemon restart is not required. ## Trust model @@ -169,7 +171,7 @@ If a remote plugin constructs absolute asset URLs, it should use the `pluginId` Open **Settings → PI WEB plugins** to review discovered bundled, local, dev, and Pi package plugins for the PI WEB gateway you opened. PI WEB can disable any discovered gateway plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list. -This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may provide plugins or other Pi resources, use **Settings → Pi packages**. +This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may provide plugins or other Pi resources, use **Settings → Pi packages**. In a federated setup, the Pi packages panel targets the selected machine; the PI WEB plugins panel still controls gateway plugin enablement. Plugin preferences are stored under the top-level `plugins` config key in the PI WEB config file: diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index d13a5ed..d47b57c 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -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", () => { diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index edcb195..bb8d79d 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -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) }) }); }, }; diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 1cde8ff..e704d0f 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -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)), diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 30cc18d..e6b7129 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1923,7 +1923,7 @@ export class PiWebApp extends LitElement { ${state.machineDialogOpen ? html` this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}>` : null} ${this.sessionCleanupDialog !== undefined ? html` { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}>` : null} ${state.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}>` : null} - ${this.settingsSection !== undefined ? html` { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}>` : null} + ${this.settingsSection !== undefined ? html` { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}>` : null} `; } diff --git a/src/client/src/components/SettingsDialog.ts b/src/client/src/components/SettingsDialog.ts index 21e67eb..4883d04 100644 --- a/src/client/src/components/SettingsDialog.ts +++ b/src/client/src/components/SettingsDialog.ts @@ -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): 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`
this.onClose?.()}> @@ -52,13 +66,14 @@ export class SettingsDialog extends LitElement {
+ ${this.renderScopeNote()} ${this.renderActiveSection()}
@@ -99,9 +114,10 @@ export class SettingsDialog extends LitElement { return html` this.loadConfig()} .onInstallPackage=${(source: string) => this.installPiPackage(source)} @@ -147,22 +163,53 @@ export class SettingsDialog extends LitElement { `; } + private renderScopeNote(): TemplateResult { + return html` +
+ This tab edits: ${this.settingsScopeMessage()} +
+ `; + } + + 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 { + 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 { @@ -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 { - 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 { - 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 { - 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): Promise { + private async runPiPackageMutation(operation: PiPackageOperationState, label: string, target: PiPackageTargetContext, mutate: () => Promise): Promise { 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 { + private async refreshPlugins(): Promise { 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; } diff --git a/src/client/src/components/settings/SettingsPackagesPanel.ts b/src/client/src/components/settings/SettingsPackagesPanel.ts index 5ed9577..d8d9969 100644 --- a/src/client/src/components/settings/SettingsPackagesPanel.ts +++ b/src/client/src/components/settings/SettingsPackagesPanel.ts @@ -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; @@ -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`

Pi packages

-

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.

+

Managing Pi packages on ${targetLabel}. 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.

- +
Trusted code warning: Pi packages and PI WEB plugins can run with your user permissions. Install packages and enable plugins only from sources you trust.
${this.renderMessages()} @@ -36,9 +39,9 @@ export class SettingsPackagesPanel extends LitElement {
${this.validationMessage === "" ? null : html`
${this.validationMessage}
`} - Installs use Pi's default package location, equivalent to pi install <source>. PI WEB does not ask you to choose an install location. + Installs run on ${targetLabel} and use Pi's default package location, equivalent to pi install <source>. PI WEB does not ask you to choose an install location. - ${this.renderPackageList(packages)} + ${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`

Configured Pi packages

-

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

+

This list comes from Pi's package manager settings on ${targetLabel}.

${updateAllReason === undefined ? null : html`
${updateAllReason}
`} - ${this.loading && packages.length === 0 ? html`
Loading Pi packages…
` : packages.length === 0 ? html`
No Pi packages configured in Pi settings yet.
` : html` + ${this.loading && packages.length === 0 ? html`
Loading Pi packages from ${targetLabel}…
` : packages.length === 0 ? html`
No Pi packages configured in Pi settings on ${targetLabel} yet.
` : html`
${packages.map((packageInfo) => this.renderPackage(packageInfo))}
@@ -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; } diff --git a/src/client/src/components/settings/piPackageSettings.test.ts b/src/client/src/components/settings/piPackageSettings.test.ts index b699910..da5ea0b 100644 --- a/src/client/src/components/settings/piPackageSettings.test.ts +++ b/src/client/src/components/settings/piPackageSettings.test.ts @@ -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"); + }); }); diff --git a/src/client/src/components/settings/piPackageSettings.ts b/src/client/src/components/settings/piPackageSettings.ts index 3bb0dc4..7356232 100644 --- a/src/client/src/components/settings/piPackageSettings.ts +++ b/src/client/src/components/settings/piPackageSettings.ts @@ -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 | 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); } diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 618c402..a2dc8e1 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -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((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 () => { diff --git a/src/server/app.ts b/src/server/app.ts index 5eec41f..43013c9 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -150,6 +150,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebRuntime(sessionDaemon)); app.get("/api/plugins", async () => piWebPlugins.plugins()); registerPiPackageRoutes(app, piPackages); + registerPiPackageRoutes(app, piPackages, "/api/machines/local"); registerConfigRoutes(app, configService); registerMachineRoutes(app, machines); diff --git a/src/server/machines/machineProxyRoutes.ts b/src/server/machines/machineProxyRoutes.ts index 14a99d5..07c7080 100644 --- a/src/server/machines/machineProxyRoutes.ts +++ b/src/server/machines/machineProxyRoutes.ts @@ -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 { +async function proxyHttpRequest(machines: MachineService, spec: FederatedHttpRouteSpec, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise { 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, 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 { diff --git a/src/server/piPackageRoutes.test.ts b/src/server/piPackageRoutes.test.ts index 5f58e96..a4cd553 100644 --- a/src/server/piPackageRoutes.test.ts +++ b/src/server/piPackageRoutes.test.ts @@ -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" } }); diff --git a/src/server/piPackageRoutes.ts b/src/server/piPackageRoutes.ts index 3db8f8e..f6c01b4 100644 --- a/src/server/piPackageRoutes.ts +++ b/src/server/piPackageRoutes.ts @@ -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) { diff --git a/src/shared/federatedRoutes.ts b/src/shared/federatedRoutes.ts index 8efdc92..a093334 100644 --- a/src/shared/federatedRoutes.ts +++ b/src/shared/federatedRoutes.ts @@ -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" },