fix: improve PI WEB updates panel commands

This commit is contained in:
Federico Jaramillo Martinez
2026-06-01 17:46:23 +02:00
parent a038da669c
commit 9c80eb0a92
7 changed files with 314 additions and 70 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Avoid suggesting unavailable `pi-web` restart commands for local checkout installs, and show native service commands only when PI WEB can detect matching service files.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Rename the PI WEB status workspace tab to Updates so version and restart guidance is easier to find.
+24 -12
View File
@@ -69,12 +69,30 @@ function renderCommand(html: HtmlTemplateTag, label: string, command: string): T
`;
}
function renderCommands(html: HtmlTemplateTag, status: PiWebStatusResponse): TemplateResult | undefined {
const commands = [
["Update", status.commands.update],
["Restart all", status.commands.restart],
["Restart Web/UI", status.commands.restartWeb],
["Restart session daemon", status.commands.restartSessiond],
["Status", status.commands.status],
].filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] !== "");
if (commands.length === 0) return undefined;
return html`
<section>
<strong>Suggested commands</strong>
${commands.map(([label, command]) => renderCommand(html, label, command))}
</section>
`;
}
function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResult {
const status = statusFor(state);
if (status === undefined) {
return html`
<section class="toolbar"><strong>PI WEB</strong></section>
<section class="viewer"><p class="muted">Checking PI WEB status…</p></section>
<section class="toolbar"><strong>Updates</strong></section>
<section class="viewer"><p class="muted">Checking PI WEB update status…</p></section>
`;
}
@@ -98,7 +116,7 @@ function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResu
.pi-web-command > span { grid-column: 1 / -1; }
}
</style>
<section class="toolbar"><strong>PI WEB</strong><span class="stale">beta</span>${messages.length > 0 ? html`<span class="stale">${String(messages.length)}</span>` : null}</section>
<section class="toolbar"><strong>Updates</strong><span class="stale">beta</span>${messages.length > 0 ? html`<span class="stale">${String(messages.length)}</span>` : null}</section>
<section class="viewer pi-web-status">
<section>
${messages.length === 0 ? html`<p class="muted">No PI WEB update or restart messages.</p>` : messages.map((message) => html`
@@ -116,13 +134,7 @@ function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResu
${renderComponent(html, status.components.sessiond)}
</section>
<section>
<strong>Commands</strong>
${renderCommand(html, "Update", status.commands.update)}
${renderCommand(html, "Restart", status.commands.restart)}
${renderCommand(html, "systemd", status.commands.restartSystemd)}
${renderCommand(html, "dev", status.commands.restartDev)}
</section>
${renderCommands(html, status)}
<section class="pi-web-meta">
<span>Generated ${status.generatedAt}</span>
@@ -136,13 +148,13 @@ function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResu
const plugin: PiWebPlugin = {
apiVersion: 1,
name: "PI WEB Status",
name: "PI WEB Updates",
activate: ({ html }) => ({
contributions: {
workspacePanels: [
{
id: "workspace.status",
title: "PI WEB",
title: "Updates",
order: 100,
visible: (context) => shouldShowStatusPanel(context.state),
badge: (context) => {
+7 -1
View File
@@ -418,7 +418,13 @@ function parsePiWebReleaseStatus(value: unknown): PiWebReleaseStatus {
function parsePiWebCommands(value: unknown): PiWebStatusResponse["commands"] {
const record = requireRecord(value);
return { update: requireString(record, "update"), restart: requireString(record, "restart"), restartSystemd: requireString(record, "restartSystemd"), restartDev: requireString(record, "restartDev") };
return {
...optionalField("update", optionalString(record, "update")),
...optionalField("restart", optionalString(record, "restart")),
...optionalField("restartWeb", optionalString(record, "restartWeb")),
...optionalField("restartSessiond", optionalString(record, "restartSessiond")),
...optionalField("status", optionalString(record, "status")),
};
}
function parsePiWebStatusMessage(value: unknown): PiWebStatusMessage {
+97 -32
View File
@@ -1,12 +1,17 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { comparePackageVersions, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
import type { PiWebComponentStatus } from "../shared/apiTypes.js";
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
const originalHome = process.env["HOME"];
afterEach(() => {
if (originalSkipVersionCheck === undefined) delete process.env["PI_WEB_SKIP_VERSION_CHECK"];
else process.env["PI_WEB_SKIP_VERSION_CHECK"] = originalSkipVersionCheck;
restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck);
restoreEnv("HOME", originalHome);
vi.restoreAllMocks();
});
@@ -18,20 +23,13 @@ describe("PI WEB status", () => {
});
it("returns installed and running version components without release metadata", async () => {
const daemon = new SessionDaemonClient();
vi.spyOn(daemon, "request").mockResolvedValue({
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
version: {
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202605.7",
installedVersion: "1.202605.8",
stale: true,
available: true,
},
}),
const daemon = daemonWithComponent({
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202605.7",
installedVersion: "1.202605.8",
stale: true,
available: true,
});
const status = await getPiWebVersionStatus(daemon);
@@ -44,21 +42,14 @@ describe("PI WEB status", () => {
it("reports stale session daemon versions as messages", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
const daemon = new SessionDaemonClient();
vi.spyOn(daemon, "request").mockResolvedValue({
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
version: {
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202605.7",
installedVersion: "1.202605.8",
stale: true,
available: true,
installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
},
}),
const daemon = daemonWithComponent({
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202605.7",
installedVersion: "1.202605.8",
stale: true,
available: true,
installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
});
const status = await getPiWebStatus(daemon);
@@ -66,7 +57,81 @@ describe("PI WEB status", () => {
expect(status.release.skipped).toBe(true);
expect(status.components.sessiond.stale).toBe(true);
expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user" });
expect(status.commands.update).not.toBe("");
expect(status.messages.map((message) => message.id)).toContain("sessiond-stale");
});
it("suggests native systemd commands for local development services", async () => {
if (process.platform !== "linux") return;
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
const home = await tempHome();
try {
process.env["HOME"] = home;
await installSystemdServiceFiles(home, ["pi-web-sessiond.service", "pi-web-ui-dev.service"]);
const daemon = daemonWithComponent(staleLocalSessiond());
const status = await getPiWebStatus(daemon);
expect(status.commands.restart).toBe("systemctl --user restart pi-web-sessiond.service pi-web-ui-dev.service");
expect(status.commands.restartWeb).toBe("systemctl --user restart pi-web-ui-dev.service");
expect(status.commands.restartSessiond).toBe("systemctl --user restart pi-web-sessiond.service");
expect(status.messages.find((message) => message.id === "sessiond-stale")?.command).toBe("systemctl --user restart pi-web-sessiond.service");
} finally {
await rm(home, { recursive: true, force: true });
}
});
it("omits local restart commands when no native service command is known", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
const home = await tempHome();
try {
process.env["HOME"] = home;
const daemon = daemonWithComponent(staleLocalSessiond());
const status = await getPiWebStatus(daemon);
const staleMessage = status.messages.find((message) => message.id === "sessiond-stale");
expect(status.commands.restart).toBeUndefined();
expect(staleMessage?.command).toBeUndefined();
expect(JSON.stringify(status)).not.toContain("pi-web restart");
} finally {
await rm(home, { recursive: true, force: true });
}
});
});
function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClient {
const daemon = new SessionDaemonClient();
vi.spyOn(daemon, "request").mockResolvedValue({
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ version: component }),
});
return daemon;
}
function staleLocalSessiond(): PiWebComponentStatus {
return {
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202605.7",
installedVersion: "1.202605.8",
stale: true,
available: true,
installation: { kind: "local", path: "/srv/dev/pi-web" },
};
}
async function tempHome(): Promise<string> {
return await mkdtemp(join(tmpdir(), "pi-web-status-"));
}
async function installSystemdServiceFiles(home: string, names: string[]): Promise<void> {
const dir = join(home, ".config", "systemd", "user");
await mkdir(dir, { recursive: true });
await Promise.all(names.map((name) => writeFile(join(dir, name), "")));
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) Reflect.deleteProperty(process.env, key);
else process.env[key] = value;
}
+171 -21
View File
@@ -1,6 +1,7 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { readFile, realpath, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
@@ -14,12 +15,46 @@ const DEFAULT_VERSION = "0.0.0-dev";
const LATEST_RELEASE_CACHE_MS = 6 * 60 * 60 * 1000;
const VERSION_CHECK_TIMEOUT_MS = 5000;
const restartCommands = {
restart: "pi-web restart",
restartSystemd: "pi-web restart",
restartDev: "pi-web restart",
type ServiceId = "sessiond" | "web" | "uiDev";
type NativeServiceBackendKind = "systemd" | "launchd";
interface NativeServiceRef {
id: ServiceId;
systemdName: string;
launchdLabel: string;
launchdPlistName: string;
}
interface NativeServiceCommands {
restart?: string;
restartWeb?: string;
restartSessiond?: string;
status?: string;
}
const serviceRefs: Record<ServiceId, NativeServiceRef> = {
sessiond: {
id: "sessiond",
systemdName: "pi-web-sessiond.service",
launchdLabel: "com.pi-web.sessiond",
launchdPlistName: "com.pi-web.sessiond.plist",
},
web: {
id: "web",
systemdName: "pi-web.service",
launchdLabel: "com.pi-web.web",
launchdPlistName: "com.pi-web.web.plist",
},
uiDev: {
id: "uiDev",
systemdName: "pi-web-ui-dev.service",
launchdLabel: "com.pi-web.ui-dev",
launchdPlistName: "com.pi-web.ui-dev.plist",
},
};
const startServiceOrder: ServiceId[] = ["sessiond", "web", "uiDev"];
interface PackageInfo {
name: string;
version: string;
@@ -65,7 +100,7 @@ export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promis
const { web, sessiond } = versionStatus.components;
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
const components = { web, sessiond };
const commands = commandsFor(web.installation ?? sessiond.installation);
const commands = commandsFor(components);
const messages = buildMessages(components, release, commands);
return {
...versionStatus,
@@ -247,21 +282,122 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
return version;
}
function commandsFor(installation: PiWebInstallationInfo | undefined): PiWebStatusResponse["commands"] {
function commandsFor(components: PiWebStatusResponse["components"]): PiWebStatusResponse["commands"] {
const installation = preferredInstallation(components);
const serviceCommands = nativeServiceCommands();
const cliCommands = piWebCliCommands(installation);
const restart = restartCommandFor(installation, serviceCommands, cliCommands);
const restartWeb = serviceCommands.restartWeb ?? cliCommands.restart;
const restartSessiond = serviceCommands.restartSessiond ?? cliCommands.restart;
const status = serviceCommands.status ?? cliCommands.status;
const update = updateCommandFor(installation, restart);
return {
update: updateCommandFor(installation),
...restartCommands,
...(update === undefined ? {} : { update }),
...(restart === undefined ? {} : { restart }),
...(restartWeb === undefined ? {} : { restartWeb }),
...(restartSessiond === undefined ? {} : { restartSessiond }),
...(status === undefined ? {} : { status }),
};
}
function updateCommandFor(installation: PiWebInstallationInfo | undefined): string {
function preferredInstallation(components: PiWebStatusResponse["components"]): PiWebInstallationInfo | undefined {
const web = components.web.installation;
const sessiond = components.sessiond.installation;
if (web?.kind === "local" || sessiond?.kind === "local") return web?.kind === "local" ? web : sessiond;
return web ?? sessiond;
}
function piWebCliCommands(installation: PiWebInstallationInfo | undefined): NativeServiceCommands {
if (installation?.kind !== "npm-global" || !hasCommand("pi-web")) return {};
return { restart: "pi-web restart", status: "pi-web status" };
}
function restartCommandFor(installation: PiWebInstallationInfo | undefined, serviceCommands: NativeServiceCommands, cliCommands: NativeServiceCommands): string | undefined {
if (installation?.kind === "local" || installation?.kind === "pi-package") return serviceCommands.restart ?? cliCommands.restart;
return cliCommands.restart ?? serviceCommands.restart;
}
function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): string | undefined {
if (restartCommand === undefined) return undefined;
if (installation?.kind === "pi-package") {
return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommands.restart}`;
if (!hasCommand("pi")) return undefined;
return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommand}`;
}
if (installation?.kind === "local" && installation.path !== undefined) {
return `cd ${shellQuote(installation.path)} && git pull && npm install && npm run build && ${restartCommands.restart}`;
if (!hasCommand("npm") || !isGitCheckoutWithUpstream(installation.path)) return undefined;
return `cd ${shellQuote(installation.path)} && git pull --ff-only && npm install && npm run build && ${restartCommand}`;
}
return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommands.restart}`;
if (installation?.kind !== "npm-global" || !hasCommand("npm")) return undefined;
return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommand}`;
}
function nativeServiceCommands(): NativeServiceCommands {
const backend = nativeServiceBackend();
if (backend === undefined) return {};
const installed = installedServiceIds(backend);
if (installed.size === 0) return {};
const web = installedServiceRefs(installed, ["web", "uiDev"]);
const sessiond = installedServiceRefs(installed, ["sessiond"]);
const restartable = web.length === 0 ? [] : installedServiceRefs(installed);
const status = installedServiceRefs(installed);
return {
...(restartable.length === 0 ? {} : { restart: restartNativeServicesCommand(backend, restartable) }),
...(web.length === 0 ? {} : { restartWeb: restartNativeServicesCommand(backend, web) }),
...(sessiond.length === 0 ? {} : { restartSessiond: restartNativeServicesCommand(backend, sessiond) }),
...(status.length === 0 ? {} : { status: statusNativeServicesCommand(backend, status) }),
};
}
function nativeServiceBackend(): NativeServiceBackendKind | undefined {
if (process.platform === "linux" && hasCommand("systemctl")) return "systemd";
if (process.platform === "darwin" && hasCommand("launchctl")) return "launchd";
return undefined;
}
function installedServiceIds(backend: NativeServiceBackendKind): Set<ServiceId> {
return new Set(startServiceOrder.filter((id) => existsSync(serviceFilePath(backend, serviceRefs[id]))));
}
function installedServiceRefs(installed: Set<ServiceId>, candidates: ServiceId[] = startServiceOrder): NativeServiceRef[] {
return startServiceOrder.filter((id) => candidates.includes(id) && installed.has(id)).map((id) => serviceRefs[id]);
}
function serviceFilePath(backend: NativeServiceBackendKind, ref: NativeServiceRef): string {
return backend === "systemd" ? join(systemdServiceDir(), ref.systemdName) : join(launchdServiceDir(), ref.launchdPlistName);
}
function systemdServiceDir(): string {
return join(homedir(), ".config", "systemd", "user");
}
function launchdServiceDir(): string {
return join(homedir(), "Library", "LaunchAgents");
}
function restartNativeServicesCommand(backend: NativeServiceBackendKind, refs: NativeServiceRef[]): string {
if (backend === "systemd") return `systemctl --user restart ${refs.map((ref) => ref.systemdName).join(" ")}`;
return refs.map((ref) => `launchctl kickstart -k gui/$(id -u)/${ref.launchdLabel}`).join(" && ");
}
function statusNativeServicesCommand(backend: NativeServiceBackendKind, refs: NativeServiceRef[]): string {
if (backend === "systemd") return `systemctl --user status ${refs.map((ref) => ref.systemdName).join(" ")}`;
return refs.map((ref) => `launchctl print gui/$(id -u)/${ref.launchdLabel}`).join(" && ");
}
function isGitCheckoutWithUpstream(path: string): boolean {
return hasCommand("git")
&& commandSucceeds("git", ["-C", path, "rev-parse", "--is-inside-work-tree"])
&& commandSucceeds("git", ["-C", path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
}
function hasCommand(command: string): boolean {
return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${command}`]);
}
function commandSucceeds(command: string, args: string[]): boolean {
const result = spawnSync(command, args, { encoding: "utf8" });
return result.status === 0;
}
function shellQuote(value: string): string {
@@ -277,18 +413,23 @@ function buildMessages(components: PiWebStatusResponse["components"], release: P
id: "update-available",
severity: "info",
title: "PI WEB update available",
body: `PI WEB ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Update PI WEB, then restart PI WEB services.`,
command: commands.update,
body: commands.update === undefined
? `PI WEB ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Update PI WEB, then restart the services or processes for this installation.`
: `PI WEB ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Run the update command to update PI WEB and restart its services.`,
...optionalMessageCommand(commands.update),
});
}
if (components.web.stale) {
const command = commands.restartWeb ?? commands.restart;
messages.push({
id: "web-stale",
severity: "warning",
title: "Web/UI service restart needed",
body: `The Web/UI service is running ${formatVersion(components.web.runtimeVersion)}, but ${formatVersion(components.web.installedVersion)} is installed. Restart the service to use the installed version.`,
command: commands.restart,
body: command === undefined
? `The Web/UI service is running ${formatVersion(components.web.runtimeVersion)}, but ${formatVersion(components.web.installedVersion)} is installed. Restart the Web/UI service or process to use the installed version.`
: `The Web/UI service is running ${formatVersion(components.web.runtimeVersion)}, but ${formatVersion(components.web.installedVersion)} is installed. Restart the service to use the installed version.`,
...optionalMessageCommand(command),
});
}
@@ -297,22 +438,31 @@ function buildMessages(components: PiWebStatusResponse["components"], release: P
id: "sessiond-unavailable",
severity: "warning",
title: "Session daemon version unavailable",
body: `PI WEB could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}`,
command: "pi-web status",
body: commands.status === undefined
? `PI WEB could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}. Check the session daemon service or process that runs this installation.`
: `PI WEB could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}`,
...optionalMessageCommand(commands.status),
});
} else if (components.sessiond.stale) {
const command = commands.restartSessiond ?? commands.restart;
messages.push({
id: "sessiond-stale",
severity: "warning",
title: "Session daemon restart needed",
body: `The session daemon is running ${formatVersion(components.sessiond.runtimeVersion)}, but ${formatVersion(components.sessiond.installedVersion)} is installed. Restart the daemon to use the installed version.`,
command: commands.restart,
body: command === undefined
? `The session daemon is running ${formatVersion(components.sessiond.runtimeVersion)}, but ${formatVersion(components.sessiond.installedVersion)} is installed. Restart the session daemon service or process to use the installed version.`
: `The session daemon is running ${formatVersion(components.sessiond.runtimeVersion)}, but ${formatVersion(components.sessiond.installedVersion)} is installed. Restart the daemon to use the installed version.`,
...optionalMessageCommand(command),
});
}
return messages;
}
function optionalMessageCommand(command: string | undefined): Pick<PiWebStatusMessage, "command"> | object {
return command === undefined ? {} : { command };
}
function skipVersionCheck(): boolean {
return ["PI_WEB_SKIP_VERSION_CHECK", "PI_WEB_OFFLINE", "PI_SKIP_VERSION_CHECK", "PI_OFFLINE"].some((key) => {
const value = process.env[key];
+5 -4
View File
@@ -296,10 +296,11 @@ export interface PiWebVersionResponse {
export interface PiWebStatusResponse extends PiWebVersionResponse {
release: PiWebReleaseStatus;
commands: {
update: string;
restart: string;
restartSystemd: string;
restartDev: string;
update?: string;
restart?: string;
restartWeb?: string;
restartSessiond?: string;
status?: string;
};
messages: PiWebStatusMessage[];
}