Archived
fix: guide package settings by runtime capability
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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 <source></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) {
|
||||
|
||||
Reference in New Issue
Block a user