diff --git a/.changeset/local-update-commands.md b/.changeset/local-update-commands.md new file mode 100644 index 0000000..9d0c459 --- /dev/null +++ b/.changeset/local-update-commands.md @@ -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. diff --git a/.changeset/rename-updates-tab.md b/.changeset/rename-updates-tab.md new file mode 100644 index 0000000..d63371e --- /dev/null +++ b/.changeset/rename-updates-tab.md @@ -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. diff --git a/pi-web-plugins/pi-web/pi-web-plugin.ts b/pi-web-plugins/pi-web/pi-web-plugin.ts index fcfff83..bf78596 100644 --- a/pi-web-plugins/pi-web/pi-web-plugin.ts +++ b/pi-web-plugins/pi-web/pi-web-plugin.ts @@ -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` +
+ Suggested commands + ${commands.map(([label, command]) => renderCommand(html, label, command))} +
+ `; +} + function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResult { const status = statusFor(state); if (status === undefined) { return html` -
PI WEB
-

Checking PI WEB status…

+
Updates
+

Checking PI WEB update status…

`; } @@ -98,7 +116,7 @@ function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResu .pi-web-command > span { grid-column: 1 / -1; } } -
PI WEBbeta${messages.length > 0 ? html`${String(messages.length)}` : null}
+
Updatesbeta${messages.length > 0 ? html`${String(messages.length)}` : null}
${messages.length === 0 ? html`

No PI WEB update or restart messages.

` : messages.map((message) => html` @@ -116,13 +134,7 @@ function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResu ${renderComponent(html, status.components.sessiond)}
-
- Commands - ${renderCommand(html, "Update", status.commands.update)} - ${renderCommand(html, "Restart", status.commands.restart)} - ${renderCommand(html, "systemd", status.commands.restartSystemd)} - ${renderCommand(html, "dev", status.commands.restartDev)} -
+ ${renderCommands(html, status)}
Generated ${status.generatedAt} @@ -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) => { diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 28538be..5b41b6d 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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 { diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index d71193e..b31c5da 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -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 { + return await mkdtemp(join(tmpdir(), "pi-web-status-")); +} + +async function installSystemdServiceFiles(home: string, names: string[]): Promise { + 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; +} diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts index ba93d12..b44ee6e 100644 --- a/src/server/piWebStatus.ts +++ b/src/server/piWebStatus.ts @@ -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 = { + 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 { 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 { + return new Set(startServiceOrder.filter((id) => existsSync(serviceFilePath(backend, serviceRefs[id])))); +} + +function installedServiceRefs(installed: Set, 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 | 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]; diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 14a2c76..f55661c 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -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[]; }