Archived
feat: manage Pi packages on selected machines
This commit is contained in:
@@ -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.
|
||||||
@@ -97,7 +97,9 @@ Read more: [Remote-first development](https://pi-web.dev/remote-first)
|
|||||||
|
|
||||||
## Machines and fleets
|
## 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)
|
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 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)
|
Read more: [Plugin API](https://pi-web.dev/plugins)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -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.
|
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`.
|
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.
|
- `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.
|
- `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.
|
- `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.
|
- `shortcuts`: saved settings apply in the browser after config refresh/save.
|
||||||
|
|
||||||
## Global config example
|
## Global config example
|
||||||
@@ -173,7 +173,7 @@ Tracked subsessions let an agent delegate work to child sessions, get notified w
|
|||||||
|
|
||||||
### Plugin config
|
### 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.<id>.enabled` to `false` to remove a plugin from `/pi-web-plugins/manifest.json` before the browser imports it.
|
Plugins are enabled by default. Set `plugins.<id>.enabled` to `false` to remove a plugin from `/pi-web-plugins/manifest.json` before the browser imports it.
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -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 <source>`, and does not ask for an install location.
|
Use **Settings → Pi packages** to view configured Pi packages or install/remove/update a package. Enter only the package source, such as `npm:@scope/package`, a git/URL source, or a local path. PI WEB uses Pi's default package location, equivalent to `pi install <source>`, and does not ask for an install location.
|
||||||
|
|
||||||
Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. After installing, removing, or updating a Pi package, 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
|
## 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.
|
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:
|
Plugin preferences are stored under the top-level `plugins` config key in the PI WEB config file:
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe("machine-scoped runtime API", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Pi package 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 packages = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
|
||||||
const fetchMock = stubSequenceFetch([
|
const fetchMock = stubSequenceFetch([
|
||||||
jsonResponse({ packages }),
|
jsonResponse({ packages }),
|
||||||
@@ -90,6 +90,34 @@ describe("Pi package API", () => {
|
|||||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "npm:@acme/tools" });
|
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "npm:@acme/tools" });
|
||||||
expect(fetchCall(fetchMock, 4)[1]?.body).toBeUndefined();
|
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", () => {
|
describe("session API compatibility", () => {
|
||||||
|
|||||||
@@ -121,19 +121,24 @@ export const pluginsApi = {
|
|||||||
plugins: () => request("/api/plugins", parsePiWebPluginsResponse),
|
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 = {
|
export const piPackagesApi = {
|
||||||
packages: () => request("/api/pi-packages", parsePiPackagesResponse),
|
packages: (machineId?: string) => request(piPackageUrl("", machineId), parsePiPackagesResponse),
|
||||||
install: (source: string) => {
|
install: (source: string, machineId?: string) => {
|
||||||
const body: PiPackageInstallRequest = { source };
|
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 };
|
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 };
|
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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Workspace } from "../../../shared/apiTypes";
|
import type { Workspace } from "../../../shared/apiTypes";
|
||||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
|
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 { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
|
||||||
import { workspaceImagePreviewUrl } from "./urls";
|
import { workspaceImagePreviewUrl } from "./urls";
|
||||||
|
|
||||||
@@ -28,6 +28,10 @@ describe("federated route contract", () => {
|
|||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
ignoreParseFailure(piWebApi.piWebStatus(machineId)),
|
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(activityApi.workspaceActivity(machineId)),
|
||||||
ignoreParseFailure(projectsApi.projects(machineId)),
|
ignoreParseFailure(projectsApi.projects(machineId)),
|
||||||
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
|
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
|
||||||
|
|||||||
@@ -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}
|
${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}
|
${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}
|
${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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { customElement, property, state } from "lit/decorators.js";
|
||||||
import type { AppAction } from "../actions";
|
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 type { SettingsSection } from "../settingsRoute";
|
||||||
import "./settings/SettingsGeneralPanel";
|
import "./settings/SettingsGeneralPanel";
|
||||||
import "./settings/SettingsSessiondPanel";
|
import "./settings/SettingsSessiondPanel";
|
||||||
import "./settings/SettingsPackagesPanel";
|
import "./settings/SettingsPackagesPanel";
|
||||||
import "./settings/SettingsPluginsPanel";
|
import "./settings/SettingsPluginsPanel";
|
||||||
import "./settings/SettingsShortcutsPanel";
|
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")
|
@customElement("settings-dialog")
|
||||||
export class SettingsDialog extends LitElement {
|
export class SettingsDialog extends LitElement {
|
||||||
@property({ attribute: false }) section: SettingsSection = "general";
|
@property({ attribute: false }) section: SettingsSection = "general";
|
||||||
@property({ attribute: false }) actions: AppAction[] = [];
|
@property({ attribute: false }) actions: AppAction[] = [];
|
||||||
|
@property({ attribute: false }) machine: Machine | undefined;
|
||||||
@property({ attribute: false }) onNavigate?: (section: SettingsSection) => void;
|
@property({ attribute: false }) onNavigate?: (section: SettingsSection) => void;
|
||||||
@property({ attribute: false }) onClose?: () => void;
|
@property({ attribute: false }) onClose?: () => void;
|
||||||
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
|
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
|
||||||
@@ -24,9 +25,13 @@ export class SettingsDialog extends LitElement {
|
|||||||
@state() private saving = false;
|
@state() private saving = false;
|
||||||
@state() private packageOperation: PiPackageOperationState | undefined;
|
@state() private packageOperation: PiPackageOperationState | undefined;
|
||||||
@state() private error = "";
|
@state() private error = "";
|
||||||
|
@state() private packageError = "";
|
||||||
@state() private savedMessage = "";
|
@state() private savedMessage = "";
|
||||||
@state() private packageMessage = "";
|
@state() private packageMessage = "";
|
||||||
private savedMessageTimer: number | undefined;
|
private savedMessageTimer: number | undefined;
|
||||||
|
private loadRequestSeq = 0;
|
||||||
|
private packageMutationSeq = 0;
|
||||||
|
private lastRequestedPackageTargetId: string | undefined;
|
||||||
|
|
||||||
override connectedCallback(): void {
|
override connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
@@ -39,6 +44,15 @@ export class SettingsDialog extends LitElement {
|
|||||||
super.disconnectedCallback();
|
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 {
|
override render(): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
<div class="backdrop" @mousedown=${() => this.onClose?.()}>
|
<div class="backdrop" @mousedown=${() => this.onClose?.()}>
|
||||||
@@ -52,13 +66,14 @@ export class SettingsDialog extends LitElement {
|
|||||||
</header>
|
</header>
|
||||||
<div class="settings-body">
|
<div class="settings-body">
|
||||||
<nav class="settings-nav" aria-label="Settings sections">
|
<nav class="settings-nav" aria-label="Settings sections">
|
||||||
${this.renderNavButton("general", "General", "Server config")}
|
${this.renderNavButton("general", "General", "Gateway config")}
|
||||||
${this.renderNavButton("sessiond", "Session daemon", "Runtime settings")}
|
${this.renderNavButton("sessiond", "Session daemon", "Gateway runtime")}
|
||||||
${this.renderNavButton("packages", "Pi packages", "Install and manage")}
|
${this.renderNavButton("packages", "Pi packages", "Selected machine")}
|
||||||
${this.renderNavButton("plugins", "PI WEB plugins", "Enable and disable")}
|
${this.renderNavButton("plugins", "PI WEB plugins", "Gateway plugins")}
|
||||||
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
|
${this.renderNavButton("shortcuts", "Keyboard", "Gateway shortcuts")}
|
||||||
</nav>
|
</nav>
|
||||||
<main class="settings-content">
|
<main class="settings-content">
|
||||||
|
${this.renderScopeNote()}
|
||||||
${this.renderActiveSection()}
|
${this.renderActiveSection()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,9 +114,10 @@ export class SettingsDialog extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<settings-packages-panel
|
<settings-packages-panel
|
||||||
.packagesResponse=${this.packagesResponse}
|
.packagesResponse=${this.packagesResponse}
|
||||||
|
.targetMachine=${this.packageTarget()}
|
||||||
.loading=${this.loading}
|
.loading=${this.loading}
|
||||||
.operation=${this.packageOperation}
|
.operation=${this.packageOperation}
|
||||||
.error=${this.error}
|
.error=${this.packageError}
|
||||||
.operationMessage=${this.packageMessage}
|
.operationMessage=${this.packageMessage}
|
||||||
.onReload=${() => this.loadConfig()}
|
.onReload=${() => this.loadConfig()}
|
||||||
.onInstallPackage=${(source: string) => this.installPiPackage(source)}
|
.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 {
|
private navigate(section: SettingsSection): void {
|
||||||
this.onNavigate?.(section);
|
this.onNavigate?.(section);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadConfig(): Promise<void> {
|
private async loadConfig(): Promise<void> {
|
||||||
|
const target = this.packageTarget();
|
||||||
|
const requestSeq = ++this.loadRequestSeq;
|
||||||
|
this.lastRequestedPackageTargetId = target.id;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = "";
|
this.error = "";
|
||||||
|
this.packageError = "";
|
||||||
try {
|
try {
|
||||||
const [config, plugins, packages] = await Promise.all([configApi.config(), pluginsApi.plugins(), piPackagesApi.packages()]);
|
const [config, plugins, packages] = await Promise.allSettled([configApi.config(), pluginsApi.plugins(), piPackagesApi.packages(target.id)]);
|
||||||
this.configResponse = config;
|
if (!this.isCurrentLoad(requestSeq, target)) return;
|
||||||
this.pluginsResponse = plugins;
|
|
||||||
this.packagesResponse = packages;
|
const errors: string[] = [];
|
||||||
} catch (error) {
|
if (config.status === "fulfilled") this.configResponse = config.value;
|
||||||
this.error = `Failed to load settings: ${errorMessage(error)}`;
|
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 {
|
} finally {
|
||||||
this.loading = false;
|
if (this.isCurrentLoad(requestSeq, target)) this.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +224,8 @@ export class SettingsDialog extends LitElement {
|
|||||||
[pluginId]: { ...currentPluginConfig, enabled },
|
[pluginId]: { ...currentPluginConfig, enabled },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.refreshPlugins();
|
const pluginRefreshError = await this.refreshPlugins();
|
||||||
|
if (pluginRefreshError !== undefined) this.error = pluginRefreshError;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveConfig(config: PiWebConfigValues): Promise<void> {
|
private async saveConfig(config: PiWebConfigValues): Promise<void> {
|
||||||
@@ -186,6 +234,7 @@ export class SettingsDialog extends LitElement {
|
|||||||
this.error = "";
|
this.error = "";
|
||||||
this.savedMessage = "";
|
this.savedMessage = "";
|
||||||
this.packageMessage = "";
|
this.packageMessage = "";
|
||||||
|
this.packageError = "";
|
||||||
try {
|
try {
|
||||||
const response = await configApi.saveConfig(config);
|
const response = await configApi.saveConfig(config);
|
||||||
this.configResponse = response;
|
this.configResponse = response;
|
||||||
@@ -199,46 +248,83 @@ export class SettingsDialog extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async installPiPackage(source: string): Promise<void> {
|
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> {
|
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> {
|
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.");
|
if (this.saving) throw new Error("A settings operation is already running.");
|
||||||
|
const requestSeq = ++this.packageMutationSeq;
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
this.packageOperation = operation;
|
this.packageOperation = operation;
|
||||||
this.error = "";
|
this.error = "";
|
||||||
|
this.packageError = "";
|
||||||
this.savedMessage = "";
|
this.savedMessage = "";
|
||||||
this.packageMessage = "";
|
this.packageMessage = "";
|
||||||
try {
|
try {
|
||||||
const response = await mutate();
|
const response = await mutate();
|
||||||
|
if (!this.isCurrentPackageMutation(requestSeq, target)) return;
|
||||||
this.packagesResponse = { packages: response.packages };
|
this.packagesResponse = { packages: response.packages };
|
||||||
await this.refreshPlugins();
|
const pluginRefreshError = shouldRefreshGatewayPluginsAfterPiPackageMutation(target) ? await this.refreshPlugins() : undefined;
|
||||||
this.packageMessage = piPackageMutationFollowUpMessage(response.action);
|
if (!this.isCurrentPackageMutation(requestSeq, target)) return;
|
||||||
|
if (pluginRefreshError !== undefined) this.packageError = pluginRefreshError;
|
||||||
|
this.packageMessage = piPackageMutationFollowUpMessage(response.action, target);
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
this.packageOperation = undefined;
|
if (this.packageMutationSeq === requestSeq) {
|
||||||
this.saving = false;
|
this.packageOperation = undefined;
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshPlugins(): Promise<void> {
|
private async refreshPlugins(): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
this.pluginsResponse = await pluginsApi.plugins();
|
this.pluginsResponse = await pluginsApi.plugins();
|
||||||
|
return undefined;
|
||||||
} catch (error) {
|
} 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 {
|
private showSavedMessage(): void {
|
||||||
this.savedMessage = "Config saved.";
|
this.savedMessage = "Config saved.";
|
||||||
if (this.savedMessageTimer !== undefined) window.clearTimeout(this.savedMessageTimer);
|
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 button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||||
.settings-nav small { color: var(--pi-muted); }
|
.settings-nav small { color: var(--pi-muted); }
|
||||||
.settings-content { min-width: 0; min-height: 0; overflow: auto; padding: 18px; }
|
.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) {
|
@media (max-width: 760px) {
|
||||||
.backdrop { padding: 0; place-items: stretch; }
|
.backdrop { padding: 0; place-items: stretch; }
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { css, html, LitElement, type TemplateResult } from "lit";
|
import { css, html, LitElement, type TemplateResult } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import type { PiPackageInfo, PiPackageScope, PiPackagesResponse } from "../../api";
|
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")
|
@customElement("settings-packages-panel")
|
||||||
export class SettingsPackagesPanel extends LitElement {
|
export class SettingsPackagesPanel extends LitElement {
|
||||||
@property({ attribute: false }) packagesResponse: PiPackagesResponse | undefined;
|
@property({ attribute: false }) packagesResponse: PiPackagesResponse | undefined;
|
||||||
@property({ type: Boolean }) loading = false;
|
@property({ type: Boolean }) loading = false;
|
||||||
@property({ attribute: false }) operation: PiPackageOperationState | undefined;
|
@property({ attribute: false }) operation: PiPackageOperationState | undefined;
|
||||||
|
@property({ attribute: false }) targetMachine: PiPackageTargetContext | undefined;
|
||||||
@property() error = "";
|
@property() error = "";
|
||||||
@property() operationMessage = "";
|
@property() operationMessage = "";
|
||||||
@property({ attribute: false }) onReload?: () => void | Promise<void>;
|
@property({ attribute: false }) onReload?: () => void | Promise<void>;
|
||||||
@@ -19,13 +20,15 @@ export class SettingsPackagesPanel extends LitElement {
|
|||||||
|
|
||||||
override render(): TemplateResult {
|
override render(): TemplateResult {
|
||||||
const packages = this.packagesResponse?.packages ?? [];
|
const packages = this.packagesResponse?.packages ?? [];
|
||||||
|
const target = this.packageTarget;
|
||||||
|
const targetLabel = piPackageTargetLabel(target);
|
||||||
return html`
|
return html`
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>Pi packages</h2>
|
<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>
|
</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>
|
||||||
<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>
|
<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()}
|
${this.renderMessages()}
|
||||||
@@ -36,9 +39,9 @@ export class SettingsPackagesPanel extends LitElement {
|
|||||||
<button type="submit" ?disabled=${this.isOperating}>${isPiPackageOperationPending(this.operation, "install") ? "Installing…" : "Install"}</button>
|
<button type="submit" ?disabled=${this.isOperating}>${isPiPackageOperationPending(this.operation, "install") ? "Installing…" : "Install"}</button>
|
||||||
</div>
|
</div>
|
||||||
${this.validationMessage === "" ? null : html`<div class="field-error">${this.validationMessage}</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 <source></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 <source></code>. PI WEB does not ask you to choose an install location.</small>
|
||||||
</form>
|
</form>
|
||||||
${this.renderPackageList(packages)}
|
${this.renderPackageList(packages, target)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,21 +51,22 @@ export class SettingsPackagesPanel extends LitElement {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderPackageList(packages: PiPackageInfo[]): TemplateResult {
|
private renderPackageList(packages: PiPackageInfo[], target: PiPackageTargetContext): TemplateResult {
|
||||||
const updateAllReason = updateAllPiPackagesDisabledReason(packages);
|
const updateAllReason = updateAllPiPackagesDisabledReason(packages);
|
||||||
|
const targetLabel = piPackageTargetLabel(target);
|
||||||
return html`
|
return html`
|
||||||
<section class="package-section" aria-label="Configured Pi packages">
|
<section class="package-section" aria-label="Configured Pi packages">
|
||||||
<div class="package-toolbar">
|
<div class="package-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<h3>Configured Pi packages</h3>
|
<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>
|
</div>
|
||||||
<button class="secondary" title=${updateAllReason ?? "Update all user-scope Pi packages"} ?disabled=${this.isOperating || updateAllReason !== undefined} @click=${() => { void this.updatePackage(); }}>
|
<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"}
|
${isPiPackageOperationPending(this.operation, "update-all") ? "Updating…" : "Update all"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
${updateAllReason === undefined ? null : html`<div class="action-note">${updateAllReason}</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">
|
<div class="package-list">
|
||||||
${packages.map((packageInfo) => this.renderPackage(packageInfo))}
|
${packages.map((packageInfo) => this.renderPackage(packageInfo))}
|
||||||
</div>
|
</div>
|
||||||
@@ -130,6 +134,10 @@ export class SettingsPackagesPanel extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get packageTarget(): PiPackageTargetContext {
|
||||||
|
return this.targetMachine ?? piPackageTargetContext(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
private get isOperating(): boolean {
|
private get isOperating(): boolean {
|
||||||
return this.operation !== undefined;
|
return this.operation !== undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { PiPackageInfo } from "../../api";
|
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 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 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", () => {
|
describe("Pi package settings helpers", () => {
|
||||||
it("normalizes and validates install sources without adding location choices", () => {
|
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);
|
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", () => {
|
it("describes the browser and session reload follow-up without requiring sessiond restarts", () => {
|
||||||
const message = piPackageMutationFollowUpMessage("install");
|
const message = piPackageMutationFollowUpMessage("install");
|
||||||
|
|
||||||
@@ -43,4 +53,19 @@ describe("Pi package settings helpers", () => {
|
|||||||
expect(message).not.toContain("session daemon");
|
expect(message).not.toContain("session daemon");
|
||||||
expect(message).not.toContain("sessiond");
|
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";
|
export type PiPackageOperationKind = PiPackageMutationAction | "update-all";
|
||||||
|
|
||||||
@@ -7,6 +7,25 @@ export interface PiPackageOperationState {
|
|||||||
source?: string;
|
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 {
|
export function normalizePiPackageSource(source: string): string {
|
||||||
return source.trim();
|
return source.trim();
|
||||||
}
|
}
|
||||||
@@ -52,7 +71,31 @@ export function isPiPackageOperationPending(operation: PiPackageOperationState |
|
|||||||
return source === undefined || operation.source === source;
|
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";
|
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
@@ -14,6 +14,7 @@ import { WorkspaceService } from "./workspaces/workspaceService.js";
|
|||||||
import type { PiPackageService } from "./piPackageService.js";
|
import type { PiPackageService } from "./piPackageService.js";
|
||||||
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||||
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.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 { machineScopedPluginId } from "../shared/machinePluginIds.js";
|
||||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
||||||
import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.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);
|
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 () => {
|
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 addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||||
const remote = addResponse.json<{ id: string }>();
|
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" } });
|
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.statusCode).toBe(200);
|
||||||
expect(installResponse.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" });
|
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 () => {
|
it("serves the PI WEB plugin manifest and plugin assets", async () => {
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
|||||||
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
|
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
|
||||||
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
||||||
registerPiPackageRoutes(app, piPackages);
|
registerPiPackageRoutes(app, piPackages);
|
||||||
|
registerPiPackageRoutes(app, piPackages, "/api/machines/local");
|
||||||
registerConfigRoutes(app, configService);
|
registerConfigRoutes(app, configService);
|
||||||
|
|
||||||
registerMachineRoutes(app, machines);
|
registerMachineRoutes(app, machines);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||||
import type { WebSocket } from "ws";
|
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 { bridgeSockets } from "../webSocketBridge.js";
|
||||||
import { RemoteMachineRequestError, type MachineRequestOptions } from "./machineClient.js";
|
import { RemoteMachineRequestError, type MachineRequestOptions } from "./machineClient.js";
|
||||||
import { MachineService } from "./machineService.js";
|
import { MachineService } from "./machineService.js";
|
||||||
@@ -23,7 +23,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
|
|||||||
app.route<{ Params: { machineId: string }; Body: unknown }>({
|
app.route<{ Params: { machineId: string }; Body: unknown }>({
|
||||||
method: spec.method,
|
method: spec.method,
|
||||||
url: `/api/machines/:machineId${spec.path}`,
|
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") {
|
if (machineId === "local") {
|
||||||
return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" });
|
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 {
|
try {
|
||||||
const requestOptions = proxyRequestOptions(body, contentType);
|
const requestOptions = proxyRequestOptions(spec, body, contentType);
|
||||||
const upstream = requestOptions === undefined
|
const upstream = requestOptions === undefined
|
||||||
? await client.request(method, remoteApiPath(machineId, requestUrl), body)
|
? await client.request(method, remoteApiPath(machineId, requestUrl), body)
|
||||||
: await client.request(method, remoteApiPath(machineId, requestUrl), body, requestOptions);
|
: await client.request(method, remoteApiPath(machineId, requestUrl), body, requestOptions);
|
||||||
@@ -84,10 +84,14 @@ function remoteApiPath(machineId: string, requestUrl: string): string {
|
|||||||
return `/api${compatPath}`;
|
return `/api${compatPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function proxyRequestOptions(body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
|
function proxyRequestOptions(spec: Pick<FederatedHttpRouteSpec, "timeoutMs">, body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
|
||||||
if (!isRawProxyBody(body)) return undefined;
|
const options: MachineRequestOptions = {};
|
||||||
const value = firstHeaderValue(contentType);
|
if (spec.timeoutMs !== undefined) options.timeoutMs = spec.timeoutMs;
|
||||||
return value === undefined || value === "" ? undefined : { contentType: value };
|
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 {
|
function isRawProxyBody(body: unknown): boolean {
|
||||||
|
|||||||
@@ -29,6 +29,23 @@ describe("registerPiPackageRoutes", () => {
|
|||||||
expect(serviceMocks.list).toHaveBeenCalledOnce();
|
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 () => {
|
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 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" } });
|
const scopedResponse = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools", scope: "project" } });
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { createDefaultPiPackageService, type PiPackageService } from "./piPackag
|
|||||||
|
|
||||||
class PiPackageRequestValidationError extends Error {}
|
class PiPackageRequestValidationError extends Error {}
|
||||||
|
|
||||||
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService()): void {
|
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService(), prefix = "/api"): void {
|
||||||
app.get("/api/pi-packages", async (_request, reply) => {
|
const routePrefix = normalizeRoutePrefix(prefix);
|
||||||
|
|
||||||
|
app.get(`${routePrefix}/pi-packages`, async (_request, reply) => {
|
||||||
try {
|
try {
|
||||||
return await service.list();
|
return await service.list();
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
return await service.install(parseRequiredSourceRequest(request.body));
|
return await service.install(parseRequiredSourceRequest(request.body));
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
const body = requireRequestObject(request.body);
|
const body = requireRequestObject(request.body);
|
||||||
return await service.remove(parseRequiredSource(body["source"]), parseOptionalScope(body["scope"]));
|
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 {
|
try {
|
||||||
const source = parseOptionalUpdateSource(request.body);
|
const source = parseOptionalUpdateSource(request.body);
|
||||||
return source === undefined ? await service.update() : await service.update(source);
|
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 {
|
function parseRequiredSourceRequest(body: unknown): string {
|
||||||
const request = requireRequestObject(body);
|
const request = requireRequestObject(body);
|
||||||
if (request["scope"] !== undefined || request["local"] !== undefined) {
|
if (request["scope"] !== undefined || request["local"] !== undefined) {
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
export type FederatedHttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
export type FederatedHttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
||||||
|
|
||||||
|
export const PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS = 5 * 60_000;
|
||||||
|
|
||||||
export interface FederatedHttpRouteSpec {
|
export interface FederatedHttpRouteSpec {
|
||||||
method: FederatedHttpMethod;
|
method: FederatedHttpMethod;
|
||||||
path: string;
|
path: string;
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FEDERATED_HTTP_ROUTES = [
|
export const FEDERATED_HTTP_ROUTES = [
|
||||||
{ method: "GET", path: "/pi-web/status" },
|
{ 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: "GET", path: "/projects" },
|
||||||
{ method: "POST", path: "/projects" },
|
{ method: "POST", path: "/projects" },
|
||||||
{ method: "DELETE", path: "/projects/:projectId" },
|
{ method: "DELETE", path: "/projects/:projectId" },
|
||||||
|
|||||||
Reference in New Issue
Block a user