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