fix: guide package settings by runtime capability

This commit is contained in:
Federico Jaramillo Martinez
2026-07-01 22:24:06 +02:00
parent 332db5faeb
commit 95a0ad7509
8 changed files with 140 additions and 23 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch
---
Keep gateway Settings panels responsive while selected-machine Pi packages load or fail separately, and report Pi package-management support through runtime capabilities.
Keep gateway Settings panels responsive while selected-machine Pi packages load or fail separately, report Pi package-management support through runtime capabilities, and use that capability to guide remote Pi package-management UI when support is known unavailable.
+1 -1
View File
@@ -1923,7 +1923,7 @@ export class PiWebApp extends LitElement {
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
${this.sessionCleanupDialog !== undefined ? html`<session-cleanup-dialog .canCleanup=${this.canCleanupSessions()} .unavailableMessage=${this.sessionCleanupUnavailableMessage()} .preview=${this.sessionCleanupDialog.preview} .previewRequest=${this.sessionCleanupDialog.previewRequest} .result=${this.sessionCleanupDialog.result} .loading=${this.sessionCleanupDialog.loading === true} .running=${this.sessionCleanupDialog.running === true} .error=${this.sessionCleanupDialog.error ?? ""} .onPreview=${(request: SessionCleanupRequest) => { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}></session-cleanup-dialog>` : null}
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .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} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
</div>
`;
}
+32 -6
View File
@@ -1,14 +1,14 @@
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 Machine, type PiPackageMutationResponse, type PiPackageScope, type PiPackagesResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
import { configApi, piPackagesApi, pluginsApi, type Machine, type MachineRuntime, 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 { friendlyPiPackageErrorMessage, piPackageMutationFollowUpMessage, piPackageTargetContext, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageManagementSupport, piPackageManagementSupportKey, piPackageMutationFollowUpMessage, piPackageTargetContext, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
import { loadGatewaySettingsData, loadPiPackagesData } from "./settings/settingsDataLoading";
@customElement("settings-dialog")
@@ -16,6 +16,7 @@ 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 }) machineRuntime: MachineRuntime | undefined;
@property({ attribute: false }) onNavigate?: (section: SettingsSection) => void;
@property({ attribute: false }) onClose?: () => void;
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
@@ -48,10 +49,18 @@ export class SettingsDialog extends LitElement {
}
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;
if (changed.has("machine")) {
const previousTarget = piPackageTargetContext(changed.get("machine"));
if (previousTarget.id !== currentTarget.id) {
this.resetPackageStateForTargetChange();
if (this.isConnected) void this.loadPackagesForTarget(currentTarget);
return;
}
}
if (!changed.has("machineRuntime")) return;
if (!this.packageManagementSupportNeedsReload(changed.get("machineRuntime"), currentTarget)) return;
this.resetPackageStateForTargetChange();
if (this.isConnected) void this.loadPackagesForTarget(currentTarget);
}
@@ -118,6 +127,7 @@ export class SettingsDialog extends LitElement {
<settings-packages-panel
.packagesResponse=${this.packagesResponse}
.targetMachine=${this.packageTarget()}
.managementSupport=${this.packageManagementSupport()}
.loading=${this.packageLoading}
.operation=${this.packageOperation}
.error=${this.packageError}
@@ -211,7 +221,7 @@ export class SettingsDialog extends LitElement {
this.packageError = "";
this.packageMessage = "";
try {
const result = await loadPiPackagesData(target, (targetId) => piPackagesApi.packages(targetId));
const result = await loadPiPackagesData(target, (targetId) => piPackagesApi.packages(targetId), this.packageManagementSupport(target));
if (!this.isCurrentPackageLoad(requestSeq, target)) return;
this.packagesResponse = result.packagesResponse;
@@ -269,6 +279,11 @@ export class SettingsDialog extends LitElement {
}
private async runPiPackageMutation(operation: PiPackageOperationState, label: string, target: PiPackageTargetContext, mutate: () => Promise<PiPackageMutationResponse>): Promise<void> {
const support = this.packageManagementSupport(target);
if (isPiPackageManagementUnsupported(support)) {
this.packageError = support.message ?? `Pi package management is not available on ${piPackageTargetLabel(target)}.`;
throw new Error(this.packageError);
}
if (this.saving) throw new Error("A settings operation is already running.");
const requestSeq = ++this.packageMutationSeq;
this.packageLoadRequestSeq += 1;
@@ -309,6 +324,17 @@ export class SettingsDialog extends LitElement {
return piPackageTargetContext(this.machine);
}
private packageManagementSupport(target = this.packageTarget()): PiPackageManagementSupport {
return piPackageManagementSupport(target, this.machineRuntime);
}
private packageManagementSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: PiPackageTargetContext): boolean {
const previousSupport = piPackageManagementSupport(target, previousRuntime);
const currentSupport = this.packageManagementSupport(target);
if (piPackageManagementSupportKey(previousSupport) === piPackageManagementSupportKey(currentSupport)) return false;
return previousSupport.state === "unsupported" || currentSupport.state === "unsupported";
}
private isCurrentLoad(requestSeq: number): boolean {
return requestSeq === this.loadRequestSeq;
}
@@ -1,7 +1,7 @@
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, piPackageTargetContext, piPackageTargetLabel, piPackageUpdateDisabledReason, updateAllPiPackagesDisabledReason, type PiPackageOperationState, type PiPackageTargetContext } from "./piPackageSettings";
import { isPiPackageManagementUnsupported, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageInstalledPathLabel, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageTargetContext, piPackageTargetLabel, piPackageUpdateDisabledReason, updateAllPiPackagesDisabledReason, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./piPackageSettings";
@customElement("settings-packages-panel")
export class SettingsPackagesPanel extends LitElement {
@@ -9,6 +9,7 @@ export class SettingsPackagesPanel extends LitElement {
@property({ type: Boolean }) loading = false;
@property({ attribute: false }) operation: PiPackageOperationState | undefined;
@property({ attribute: false }) targetMachine: PiPackageTargetContext | undefined;
@property({ attribute: false }) managementSupport: PiPackageManagementSupport | undefined;
@property() error = "";
@property() operationMessage = "";
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@@ -22,21 +23,23 @@ export class SettingsPackagesPanel extends LitElement {
const packages = this.packagesResponse?.packages ?? [];
const target = this.packageTarget;
const targetLabel = piPackageTargetLabel(target);
const packageManagementUnavailable = this.packageManagementUnavailable;
return html`
<div class="section-heading">
<div>
<h2>Pi packages</h2>
<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" title=${`Reload Pi packages from ${targetLabel}`} ?disabled=${this.loading || this.isOperating} @click=${() => { void this.onReload?.(); }}>Reload</button>
<button class="secondary" title=${packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : `Reload Pi packages from ${targetLabel}`} ?disabled=${this.loading || this.isOperating || packageManagementUnavailable} @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.renderCompatibilityNote(targetLabel)}
${this.renderMessages()}
<form class="install-card" @submit=${(event: Event) => { void this.installPackage(event); }}>
<label for="package-source">Pi package source</label>
<div class="install-row">
<input id="package-source" .value=${this.installSource} ?disabled=${this.isOperating} placeholder="npm:@scope/package, git URL, or local path" @input=${(event: Event) => { this.updateInstallSource(event); }}>
<button type="submit" ?disabled=${this.isOperating}>${isPiPackageOperationPending(this.operation, "install") ? "Installing…" : "Install"}</button>
<input id="package-source" .value=${this.installSource} ?disabled=${this.isOperating || packageManagementUnavailable} placeholder="npm:@scope/package, git URL, or local path" @input=${(event: Event) => { this.updateInstallSource(event); }}>
<button type="submit" title=${packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : "Install this Pi package"} ?disabled=${this.isOperating || packageManagementUnavailable}>${isPiPackageOperationPending(this.operation, "install") ? "Installing…" : "Install"}</button>
</div>
${this.validationMessage === "" ? null : html`<div class="field-error">${this.validationMessage}</div>`}
<small>Installs run on ${targetLabel} and use Pi's default package location, equivalent to <code>pi install &lt;source&gt;</code>. PI WEB does not ask you to choose an install location.</small>
@@ -51,10 +54,16 @@ export class SettingsPackagesPanel extends LitElement {
return null;
}
private renderCompatibilityNote(targetLabel: string): TemplateResult | null {
if (!this.packageManagementUnavailable || this.error !== "") return null;
return html`<div class="message error-message">Pi package management is not advertised by ${targetLabel}. Update and restart Pi-Web on that machine, then refresh the app before managing Pi packages.</div>`;
}
private renderPackageList(packages: PiPackageInfo[], target: PiPackageTargetContext): TemplateResult {
const targetLabel = piPackageTargetLabel(target);
const packageListUnavailable = this.error !== "" && packages.length === 0;
const updateAllReason = updateAllPiPackagesDisabledReason(packages);
const packageManagementUnavailable = this.packageManagementUnavailable;
const updateAllReason = packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : updateAllPiPackagesDisabledReason(packages);
const showUpdateAllReason = updateAllReason !== undefined && (packages.length > 0 || (!this.loading && !packageListUnavailable));
const updateAllTitle = packageListUnavailable ? `Pi package list unavailable for ${targetLabel}` : updateAllReason ?? "Update all user-scope Pi packages";
return html`
@@ -77,7 +86,10 @@ export class SettingsPackagesPanel extends LitElement {
private renderPackageListContent(packages: PiPackageInfo[], targetLabel: string): TemplateResult {
if (this.loading && packages.length === 0) return html`<div class="loading-card">Loading Pi packages from ${targetLabel}…</div>`;
if (this.error !== "" && packages.length === 0) return html`<div class="loading-card">Pi package list unavailable for ${targetLabel}. Use Reload to try again.</div>`;
if (this.error !== "" && packages.length === 0) {
if (this.packageManagementUnavailable) return html`<div class="loading-card">Pi package management is unavailable for ${targetLabel} until Pi-Web on that machine advertises package-management support.</div>`;
return html`<div class="loading-card">Pi package list unavailable for ${targetLabel}. Use Reload to try again.</div>`;
}
if (packages.length === 0) return html`<div class="loading-card">No Pi packages configured in Pi settings on ${targetLabel} yet.</div>`;
return html`
<div class="package-list">
@@ -87,7 +99,10 @@ export class SettingsPackagesPanel extends LitElement {
}
private renderPackage(packageInfo: PiPackageInfo): TemplateResult {
const updateReason = piPackageUpdateDisabledReason(packageInfo);
const targetLabel = piPackageTargetLabel(this.packageTarget);
const packageManagementUnavailable = this.packageManagementUnavailable;
const updateReason = packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : piPackageUpdateDisabledReason(packageInfo);
const removeReason = packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : "Remove this Pi package";
const updating = isPiPackageOperationPending(this.operation, "update", packageInfo.source);
const removing = isPiPackageOperationPending(this.operation, "remove", packageInfo.source);
return html`
@@ -100,7 +115,7 @@ export class SettingsPackagesPanel extends LitElement {
</div>
<div class="package-actions">
<button class="secondary" title=${updateReason ?? "Update this Pi package"} ?disabled=${this.isOperating || updateReason !== undefined} @click=${() => { void this.updatePackage(packageInfo.source); }}>${updating ? "Updating…" : "Update"}</button>
<button class="danger" ?disabled=${this.isOperating} @click=${() => { void this.removePackage(packageInfo); }}>${removing ? "Removing…" : "Remove"}</button>
<button class="danger" title=${removeReason} ?disabled=${this.isOperating || packageManagementUnavailable} @click=${() => { void this.removePackage(packageInfo); }}>${removing ? "Removing…" : "Remove"}</button>
</div>
</article>
`;
@@ -149,6 +164,14 @@ export class SettingsPackagesPanel extends LitElement {
return this.targetMachine ?? piPackageTargetContext(undefined);
}
private get packageManagementUnavailable(): boolean {
return isPiPackageManagementUnsupported(this.managementSupport);
}
private packageManagementUnavailableMessage(targetLabel: string): string {
return this.managementSupport?.message ?? `Pi package management is not available on ${targetLabel}.`;
}
private get isOperating(): boolean {
return this.operation !== undefined;
}
@@ -1,11 +1,15 @@
import { describe, expect, it } from "vitest";
import type { PiPackageInfo } from "../../api";
import { canUpdateAllPiPackages, friendlyPiPackageErrorMessage, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageMutationFollowUpMessage, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageTargetContext, piPackageTargetLabel, piPackageUpdateDisabledReason, shouldRefreshGatewayPluginsAfterPiPackageMutation, updateAllPiPackagesDisabledReason, type PiPackageTargetContext } from "./piPackageSettings";
import { PI_WEB_CAPABILITIES } from "../../../../shared/capabilities";
import type { MachineRuntime, PiPackageInfo } from "../../api";
import { canUpdateAllPiPackages, friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageManagementSupport, 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" };
const runtimeWithPackageManagement: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.piPackagesManage] };
const runtimeWithoutPackageManagement: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] };
const unavailableRuntime: MachineRuntime = { machineId: "remote-a", ok: false, checkedAt: "now", error: "Remote runtime returned HTTP 404" };
describe("Pi package settings helpers", () => {
it("normalizes and validates install sources without adding location choices", () => {
@@ -44,6 +48,18 @@ describe("Pi package settings helpers", () => {
expect(shouldRefreshGatewayPluginsAfterPiPackageMutation(remoteTarget)).toBe(false);
});
it("uses runtime capabilities as package-management UX guidance without blocking older remotes", () => {
expect(piPackageManagementSupport(localTarget, undefined)).toEqual({ state: "supported" });
expect(piPackageManagementSupport(remoteTarget, runtimeWithPackageManagement)).toEqual({ state: "supported" });
const unsupported = piPackageManagementSupport(remoteTarget, runtimeWithoutPackageManagement);
expect(isPiPackageManagementUnsupported(unsupported)).toBe(true);
expect(unsupported.message).toContain("Update and restart Pi-Web on that machine");
expect(piPackageManagementSupport(remoteTarget, undefined)).toEqual({ state: "unknown" });
expect(piPackageManagementSupport(remoteTarget, unavailableRuntime)).toEqual({ state: "unknown" });
});
it("describes the browser and session reload follow-up without requiring sessiond restarts", () => {
const message = piPackageMutationFollowUpMessage("install");
@@ -1,4 +1,5 @@
import type { Machine, MachineKind, PiPackageInfo, PiPackageMutationAction } from "../../api";
import type { Machine, MachineKind, MachineRuntime, PiPackageInfo, PiPackageMutationAction } from "../../api";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities";
export type PiPackageOperationKind = PiPackageMutationAction | "update-all";
@@ -13,6 +14,13 @@ export interface PiPackageTargetContext {
kind: MachineKind;
}
export type PiPackageManagementSupportState = "supported" | "unsupported" | "unknown";
export interface PiPackageManagementSupport {
state: PiPackageManagementSupportState;
message?: string;
}
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" };
@@ -22,6 +30,25 @@ export function piPackageTargetLabel(target: PiPackageTargetContext): string {
return target.kind === "local" ? `${target.name} (local gateway)` : `${target.name} (remote machine)`;
}
export function piPackageManagementSupport(target: PiPackageTargetContext, runtime: Pick<MachineRuntime, "ok" | "capabilities"> | undefined): PiPackageManagementSupport {
if (target.kind === "local") return { state: "supported" };
if (runtime?.ok !== true) return { state: "unknown" };
if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.piPackagesManage)) return { state: "supported" };
return { state: "unsupported", message: piPackageManagementUnavailableMessage(target) };
}
export function piPackageManagementSupportKey(support: PiPackageManagementSupport): string {
return `${support.state}:${support.message ?? ""}`;
}
export function isPiPackageManagementUnsupported(support: PiPackageManagementSupport | undefined): support is PiPackageManagementSupport & { state: "unsupported" } {
return support?.state === "unsupported";
}
export function piPackageManagementUnavailableMessage(target: PiPackageTargetContext): string {
return `Pi package management is not available on ${target.name}. Update and restart Pi-Web on that machine, then try again.`;
}
export function shouldRefreshGatewayPluginsAfterPiPackageMutation(target: PiPackageTargetContext): boolean {
return target.kind === "local";
}
@@ -83,7 +110,7 @@ export function friendlyPiPackageErrorMessage(message: string, target: PiPackage
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.`;
return piPackageManagementUnavailableMessage(target);
}
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.`;
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { PiPackagesResponse, PiWebConfigResponse, PiWebPluginsResponse } from "../../api";
import { loadGatewaySettingsData, loadPiPackagesData } from "./settingsDataLoading";
import type { PiPackageManagementSupport } from "./piPackageSettings";
const configResponse: PiWebConfigResponse = {
path: "/home/test/.config/pi-web/config.json",
@@ -14,6 +15,10 @@ const pluginsResponse: PiWebPluginsResponse = { plugins: [] };
const packagesResponse: PiPackagesResponse = { packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }] };
const remoteTarget = { id: "remote-a", name: "Lab Mac", kind: "remote" } as const;
const unsupportedPackageManagement: PiPackageManagementSupport = {
state: "unsupported",
message: "Pi package management is not available on Lab Mac. Update and restart Pi-Web on that machine, then try again.",
};
describe("settings data loading helpers", () => {
it("loads gateway settings without depending on Pi package data", async () => {
@@ -52,4 +57,19 @@ describe("settings data loading helpers", () => {
expect(failure.packagesResponse).toBeUndefined();
expect(failure.error).toBe("Failed to load Pi packages from Lab Mac (remote machine): Could not reach Lab Mac for Pi package management. Check the machine connection and try again.");
});
it("skips package listing only when runtime data confirms package management is unsupported", async () => {
const requestedTargets: string[] = [];
const loadPackages = vi.fn((targetId: string) => {
requestedTargets.push(targetId);
return Promise.resolve(packagesResponse);
});
const blocked = await loadPiPackagesData(remoteTarget, loadPackages, unsupportedPackageManagement);
const unknown = await loadPiPackagesData(remoteTarget, loadPackages, { state: "unknown" });
expect(blocked).toEqual({ error: unsupportedPackageManagement.message, skipped: true });
expect(unknown).toEqual({ packagesResponse, error: "" });
expect(requestedTargets).toEqual(["remote-a"]);
});
});
@@ -1,5 +1,5 @@
import type { PiPackagesResponse, PiWebConfigResponse, PiWebPluginsResponse } from "../../api";
import { friendlyPiPackageErrorMessage, piPackageTargetLabel, type PiPackageTargetContext } from "./piPackageSettings";
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageTargetLabel, type PiPackageManagementSupport, type PiPackageTargetContext } from "./piPackageSettings";
export interface GatewaySettingsLoaders {
loadConfig: () => Promise<PiWebConfigResponse>;
@@ -15,6 +15,7 @@ export interface GatewaySettingsLoadResult {
export interface PiPackagesLoadResult {
packagesResponse?: PiPackagesResponse;
error: string;
skipped?: boolean;
}
export async function loadGatewaySettingsData(loaders: GatewaySettingsLoaders): Promise<GatewaySettingsLoadResult> {
@@ -32,7 +33,11 @@ export async function loadGatewaySettingsData(loaders: GatewaySettingsLoaders):
return result;
}
export async function loadPiPackagesData(target: PiPackageTargetContext, loadPackages: (targetId: string) => Promise<PiPackagesResponse>): Promise<PiPackagesLoadResult> {
export async function loadPiPackagesData(target: PiPackageTargetContext, loadPackages: (targetId: string) => Promise<PiPackagesResponse>, support?: PiPackageManagementSupport): Promise<PiPackagesLoadResult> {
if (isPiPackageManagementUnsupported(support)) {
return { error: support.message ?? `Pi package management is not available on ${piPackageTargetLabel(target)}.`, skipped: true };
}
try {
return { packagesResponse: await loadPackages(target.id), error: "" };
} catch (error) {