From 3a7472346a8beda771ca85f66e3c0b005eb9b055 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:12:00 -0700 Subject: [PATCH 1/5] Add update-available notifications to the CLI and desktop app The CLI prints an "update available" line under its ready banner, and the web shell's sidebar update card now works via a new /v1/app/npm/dist-tags endpoint that serves the published npm dist-tags. The desktop app shows a native "Restart to update" action wired to electron-updater instead of the npm command. One shared, offline-safe resolver in @executor-js/api backs all surfaces; disable with EXECUTOR_DISABLE_UPDATE_CHECK. --- .changeset/update-available-notifications.md | 8 + apps/cli/src/main.ts | 13 +- apps/desktop/src/main/index.ts | 87 +++++++ apps/desktop/src/preload/index.ts | 25 ++ apps/desktop/src/shared/update.ts | 23 ++ apps/host-selfhost/vite.config.ts | 6 + apps/local/src/serve.ts | 9 + apps/local/vite.config.ts | 12 +- e2e/desktop/update-card.test.ts | 83 +++++++ e2e/local/local-server.ts | 7 +- e2e/local/update-notice.test.ts | 171 ++++++++++++++ packages/app/src/web/shell.tsx | 83 ++++++- packages/core/api/src/index.ts | 10 + packages/core/api/src/server.ts | 1 + packages/core/api/src/server/executor-app.ts | 3 + packages/core/api/src/server/npm-dist-tags.ts | 34 +++ packages/core/api/src/update-check.test.ts | 131 +++++++++++ packages/core/api/src/update-check.ts | 218 ++++++++++++++++++ packages/react/src/hooks/desktop-update.ts | 69 ++++++ 19 files changed, 986 insertions(+), 7 deletions(-) create mode 100644 .changeset/update-available-notifications.md create mode 100644 apps/desktop/src/shared/update.ts create mode 100644 e2e/desktop/update-card.test.ts create mode 100644 e2e/local/update-notice.test.ts create mode 100644 packages/core/api/src/server/npm-dist-tags.ts create mode 100644 packages/core/api/src/update-check.test.ts create mode 100644 packages/core/api/src/update-check.ts create mode 100644 packages/react/src/hooks/desktop-update.ts diff --git a/.changeset/update-available-notifications.md b/.changeset/update-available-notifications.md new file mode 100644 index 000000000..e62a5d624 --- /dev/null +++ b/.changeset/update-available-notifications.md @@ -0,0 +1,8 @@ +--- +"executor": minor +"@executor-js/desktop": minor +"@executor-js/api": minor +"@executor-js/react": minor +--- + +Notify when a newer Executor is published. The CLI now prints an "update available" line under its ready banner, and the web shell's sidebar update card works for real (a new `/v1/app/npm/dist-tags` endpoint backs it). In the desktop app the card shows a native "Restart to update" action wired to the in-app updater instead of the npm command. The check is best-effort and offline-safe, and can be disabled with `EXECUTOR_DISABLE_UPDATE_CHECK`. diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 7162d5f30..afa33cbd0 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -64,7 +64,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Cause from "effect/Cause"; -import { ExecutorApi } from "@executor-js/api"; +import { ExecutorApi, checkForUpdate } from "@executor-js/api"; import { getExecutorServerAuthorizationHeader, normalizeExecutorServerConnection, @@ -989,6 +989,17 @@ const runForegroundSession = (input: { console.log(` Extra CORS origins: ${input.allowedHosts.join(", ")}`); } } + + // Best-effort upgrade nudge. `checkForUpdate` never throws and bounds + // its own registry fetch, so a slow or offline registry just yields no + // notice rather than stalling the prompt. Quiet when up to date or when + // EXECUTOR_DISABLE_UPDATE_CHECK is set. + const update = yield* Effect.promise(() => checkForUpdate(CLI_VERSION)); + if (update.updateAvailable && update.latestVersion) { + console.log(`\nUpdate available: ${update.currentVersion} -> ${update.latestVersion}`); + console.log(`Run ${update.command} to update.`); + } + console.log(`\nPress Ctrl+C to stop.`); yield* waitForShutdownSignal(); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index d4850dee9..4039985e2 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -55,6 +55,12 @@ import { type DesktopServerConnection, type DesktopServerSettings, } from "../shared/server-settings"; +import { + type DesktopUpdateStatus, + UPDATE_INSTALL_CHANNEL, + UPDATE_STATUS_CHANNEL, + UPDATE_STATUS_GET_CHANNEL, +} from "../shared/update"; // Pin userData to a friendly app-name-scoped dir BEFORE app.ready so every // Electron-side consumer (electron-store, electron-log, window-state) lands @@ -633,6 +639,24 @@ const registerIpcHandlers = () => { // fixed upstream. Reuses the menu flow — staged updates prompt to install, // "no updates" / failures surface in their own dialogs. ipcMain.handle("executor:updates:check", () => runUpdateCheck({ alertOnFail: true })); + ipcMain.handle(UPDATE_STATUS_GET_CHANNEL, (): DesktopUpdateStatus => updateStatus); + ipcMain.handle(UPDATE_INSTALL_CHANNEL, async (): Promise => { + const version = "version" in updateStatus ? updateStatus.version : ""; + // Outside a packaged build there is no real bundle to swap, and quitting + // would tear down the e2e harness — reflect "installing" so the renderer + // can prove the wiring instead. + if (!app.isPackaged) { + setUpdateStatus({ state: "installing", version }); + return; + } + // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle, matching + // the native dialog's restart path. + if (connection) { + await stopConnection(connection); + connection = null; + } + autoUpdater.quitAndInstall(false, true); + }); // Crash-screen last resort for damaged state: confirm, move the data dir // aside (never delete), then restart the sidecar against the fresh dir. // Returns false when the user cancelled. @@ -679,6 +703,54 @@ const registerIpcHandlers = () => { let downloadedUpdateVersion: string | null = null; let updateDialogOpen = false; +// Renderer-facing auto-update status. The web shell renders a desktop-native +// "Restart to update" card from this (in place of its npm card) — see +// ../shared/update.ts and packages/react/.../hooks/desktop-update.ts. +let updateStatus: DesktopUpdateStatus = { state: "idle" }; +let pendingUpdateVersion: string | null = null; + +type ProgressInfo = { readonly percent: number }; + +const broadcastUpdateStatus = () => { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(UPDATE_STATUS_CHANNEL, updateStatus); + } +}; + +const setUpdateStatus = (status: DesktopUpdateStatus) => { + updateStatus = status; + broadcastUpdateStatus(); +}; + +// Dev/e2e seam: a packaged build is required for a REAL electron-updater +// release, so `EXECUTOR_DESKTOP_FAKE_UPDATE` (JSON like +// `{"state":"downloaded","version":"99.0.0"}`) seeds a status to exercise the +// renderer card and the install handler without one. Ignored in production. +const applyFakeUpdateFromEnv = () => { + const raw = process.env.EXECUTOR_DESKTOP_FAKE_UPDATE?.trim(); + if (!raw) return; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: dev-only env override is untrusted text; a malformed value is logged and ignored, not fatal + try { + // oxlint-disable-next-line executor/no-json-parse -- boundary: dev-only env override, shape-checked below + const parsed = JSON.parse(raw) as { readonly state?: string; readonly version?: string }; + const version = typeof parsed.version === "string" ? parsed.version : "0.0.0"; + const state = parsed.state ?? "downloaded"; + if ( + state === "available" || + state === "downloaded" || + state === "downloading" || + state === "installing" + ) { + if (state === "downloaded") downloadedUpdateVersion = version; + setUpdateStatus( + state === "downloading" ? { state, version, percent: 100 } : { state, version }, + ); + } + } catch (error) { + log.warn("[updater] bad EXECUTOR_DESKTOP_FAKE_UPDATE", error); + } +}; + const promptInstallUpdate = async (version: string) => { if (updateDialogOpen) return; updateDialogOpen = true; @@ -718,8 +790,22 @@ const setupAutoUpdater = () => { autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = false; + autoUpdater.on("update-available", (info: UpdateInfo) => { + pendingUpdateVersion = info.version; + setUpdateStatus({ state: "available", version: info.version }); + }); + autoUpdater.on("download-progress", (progress: ProgressInfo) => { + if (pendingUpdateVersion) { + setUpdateStatus({ + state: "downloading", + version: pendingUpdateVersion, + percent: Math.round(progress.percent ?? 0), + }); + } + }); autoUpdater.on("update-downloaded", (info: UpdateInfo) => { downloadedUpdateVersion = info.version; + setUpdateStatus({ state: "downloaded", version: info.version }); void promptInstallUpdate(info.version); }); autoUpdater.on("error", (err: Error) => { @@ -860,6 +946,7 @@ const boot = async () => { installDockIcon(); installApplicationMenu(); setupAutoUpdater(); + applyFakeUpdateFromEnv(); registerIpcHandlers(); // A sidecar that dies under a live window would leave the web UI failing // every request with no explanation. Swap in the crash screen — its diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index d29e468ee..8cafcf6bd 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,5 +1,11 @@ import { contextBridge, ipcRenderer } from "electron"; import type { DesktopServerConnection, DesktopServerSettings } from "../shared/server-settings"; +import { + type DesktopUpdateStatus, + UPDATE_INSTALL_CHANNEL, + UPDATE_STATUS_CHANNEL, + UPDATE_STATUS_GET_CHANNEL, +} from "../shared/update"; const api = { /** Read the active Executor server connection backing this desktop window. */ @@ -67,6 +73,25 @@ const api = { checkForUpdates(): Promise { return ipcRenderer.invoke("executor:updates:check"); }, + /** Read the current auto-update status once (renderer mount). */ + getUpdateStatus(): Promise { + return ipcRenderer.invoke(UPDATE_STATUS_GET_CHANNEL); + }, + /** + * Subscribe to auto-update status changes pushed by main (available → + * downloading → downloaded). Returns an unsubscribe function. + */ + onUpdateStatus(callback: (status: DesktopUpdateStatus) => void): () => void { + const listener = (_event: unknown, status: DesktopUpdateStatus) => callback(status); + ipcRenderer.on(UPDATE_STATUS_CHANNEL, listener); + return () => { + ipcRenderer.removeListener(UPDATE_STATUS_CHANNEL, listener); + }; + }, + /** Apply a downloaded update and restart the app. */ + installUpdate(): Promise { + return ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL); + }, /** * Last-resort recovery for damaged executor state: after a native confirm, * back up the data dir (move-aside, never delete), then restart the diff --git a/apps/desktop/src/shared/update.ts b/apps/desktop/src/shared/update.ts new file mode 100644 index 000000000..77cf4052c --- /dev/null +++ b/apps/desktop/src/shared/update.ts @@ -0,0 +1,23 @@ +// Wire contract for the desktop auto-update status the main process pushes to +// the renderer (and the renderer's "install now" request back). The web shell +// shows a desktop-native UpdateCard from this instead of the npm command it +// shows on web/CLI installs, because the desktop updates via electron-updater +// (GitHub releases, swapped in place), not `npm i -g`. +// +// The renderer carries a structurally identical view of this union at +// `@executor-js/react/hooks/desktop-update` (the IPC boundary is plain JSON, so +// each side owns its own copy rather than reaching across package roots). + +export type DesktopUpdateStatus = + | { readonly state: "idle" } + | { readonly state: "available"; readonly version: string } + | { readonly state: "downloading"; readonly version: string; readonly percent: number } + | { readonly state: "downloaded"; readonly version: string } + | { readonly state: "installing"; readonly version: string }; + +/** Push channel: main → renderer, whenever the update status changes. */ +export const UPDATE_STATUS_CHANNEL = "executor:updates:status" as const; +/** Invoke channel: renderer → main, read the current status once on mount. */ +export const UPDATE_STATUS_GET_CHANNEL = "executor:updates:status:get" as const; +/** Invoke channel: renderer → main, apply a downloaded update and restart. */ +export const UPDATE_INSTALL_CHANNEL = "executor:updates:quit-and-install" as const; diff --git a/apps/host-selfhost/vite.config.ts b/apps/host-selfhost/vite.config.ts index 79d25b7e6..c89c5e935 100644 --- a/apps/host-selfhost/vite.config.ts +++ b/apps/host-selfhost/vite.config.ts @@ -76,6 +76,12 @@ function executorApiPlugin(): Plugin { path.startsWith("/mcp/") || path === "/docs" || path.startsWith("/docs/") || + // Un-prefixed app-level routes (e.g. `/v1/app/npm/dist-tags`, which the + // shell's update check fetches). Served by the Effect router in prod; + // without this the SPA index.html fallback answers 200-with-HTML and + // the JSON parse fails, so the UpdateCard never appears. + path === "/v1" || + path.startsWith("/v1/") || // RFC 9728 / RFC 8414 OAuth discovery the MCP client fetches before // auth. Served by the Effect router in prod; without this the SPA // index.html fallback answers 200-with-HTML and breaks discovery. diff --git a/apps/local/src/serve.ts b/apps/local/src/serve.ts index 474cbd411..288c77451 100644 --- a/apps/local/src/serve.ts +++ b/apps/local/src/serve.ts @@ -418,6 +418,15 @@ export async function startServer(opts: StartServerOptions = {}): Promise => + _electron.launch({ + executablePath: electronBinary, + args: [appDir], + cwd: appDir, + env: { + ...process.env, + HOME: home, + // Dev seam: stand in for a downloaded electron-updater release. + EXECUTOR_DESKTOP_FAKE_UPDATE: JSON.stringify({ + state: "downloaded", + version: FORCED_VERSION, + }), + }, + timeout: 120_000, + }); + +scenario( + "Desktop · the sidebar shows a native restart-to-update card, not the npm command", + { timeout: 300_000 }, + Effect.gen(function* () { + const runDir = yield* RunDir; + yield* Effect.promise(() => run(runDir)); + }), +); + +const run = async (runDir: string) => { + const home = mkdtempSync(join(tmpdir(), "executor-update-desktop-")); + const app = await launchDesktop(home); + try { + const page = await app.firstWindow({ timeout: 120_000 }); + await page.waitForLoadState("domcontentloaded"); + await page.locator("aside.desktop-macos-sidebar").waitFor({ timeout: 90_000 }); + + // The desktop-native card: the available version and a Restart action — and + // crucially NOT the npm command the web/CLI card carries. + await page.getByText("Update available").waitFor({ timeout: 60_000 }); + await page.getByText(`v${FORCED_VERSION}`).waitFor({ timeout: 10_000 }); + const restart = page.getByRole("button", { name: "Restart to update" }); + await restart.waitFor({ timeout: 10_000 }); + expect( + await page.getByText("npm i -g", { exact: false }).count(), + "the desktop card shows no npm command", + ).toBe(0); + await page.screenshot({ path: join(runDir, "01-desktop-update-card.png") }); + + // Clicking Restart drives the install IPC. Outside a packaged build that + // reflects "installing" rather than quitting (the real quitAndInstall only + // runs packaged), so the wiring is provable without tearing the app down. + await restart.click(); + await page.getByText("Restarting…").waitFor({ timeout: 10_000 }); + await page.screenshot({ path: join(runDir, "02-restarting.png") }); + } finally { + await app.close().catch(() => {}); + rmSync(home, { recursive: true, force: true }); + } +}; diff --git a/e2e/local/local-server.ts b/e2e/local/local-server.ts index 299cc2980..c85c77866 100644 --- a/e2e/local/local-server.ts +++ b/e2e/local/local-server.ts @@ -39,6 +39,11 @@ export const withLocalServer = ( cli: CliSurface, runDir: string, body: (server: ServerHandle) => Effect.Effect, + options?: { + /** Extra env merged into the `executor web` process, e.g. to force a + * published-version signal for the update check. */ + readonly env?: Record; + }, ): Effect.Effect => Effect.gen(function* () { const dataDir = mkdtempSync(join(tmpdir(), "executor-local-e2e-")); @@ -80,7 +85,7 @@ export const withLocalServer = ( }, { cwd: repoRoot, - env: { EXECUTOR_DATA_DIR: dataDir, EXECUTOR_SCOPE_DIR: dataDir }, + env: { EXECUTOR_DATA_DIR: dataDir, EXECUTOR_SCOPE_DIR: dataDir, ...options?.env }, record: join(runDir, "terminal.cast"), viewport: { cols: 120, rows: 40 }, }, diff --git a/e2e/local/update-notice.test.ts b/e2e/local/update-notice.test.ts new file mode 100644 index 000000000..52a754f22 --- /dev/null +++ b/e2e/local/update-notice.test.ts @@ -0,0 +1,171 @@ +// Local-only: the "a newer Executor is published" nudge, on BOTH surfaces a +// user meets it. A forced dist-tags signal (`EXECUTOR_NPM_DIST_TAGS`) stands in +// for the npm registry so the check is deterministic and offline: +// +// 1. CLI: `executor web --foreground` prints an "Update available" line +// under its ready banner (recorded as terminal.cast). +// 2. Web: the same server's `/v1/app/npm/dist-tags` lights up the shell's +// sidebar UpdateCard (captured as a step screenshot). +// +// Both read the one resolver in `@executor-js/api` (update-check.ts), so the +// terminal line and the sidebar card can never disagree about the verdict. +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Cli, RunDir, Target } from "../src/services"; +import { withLocalServer } from "./local-server"; + +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); + +// A version far above anything we'd really publish, so the running build is +// unambiguously "behind" regardless of the actual package version. +const FORCED_LATEST = "99.0.0"; +const DIST_TAGS = JSON.stringify({ latest: FORCED_LATEST, beta: `${FORCED_LATEST}-beta.1` }); + +scenario( + "Local update · the CLI ready banner nudges to upgrade when a newer version is published", + { timeout: 180_000 }, + Effect.gen(function* () { + const cli = yield* Cli; + const runDir = yield* RunDir; + const dataDir = mkdtempSync(join(tmpdir(), "executor-update-cli-")); + + yield* cli + .session( + ["bun", "run", "apps/cli/src/main.ts", "web", "--foreground", "--port", "0"], + async (term) => { + // The notice prints right under the "Executor is ready." banner, just + // before "Press Ctrl+C to stop.": wait for that line so we know the + // whole banner (notice included) has rendered. + const snapshot = await term.screen.waitUntil( + (current) => current.text.includes("Press Ctrl+C to stop"), + { timeoutMs: 120_000 }, + ); + const screen = snapshot.text; + expect(screen, "the banner announces an available update").toContain("Update available"); + expect(screen, "it names the published version").toContain(FORCED_LATEST); + expect(screen, "it prints the upgrade command").toContain("npm i -g executor@latest"); + // Graceful shutdown so the PTY closes instead of leaking the server. + await term.keyboard.press("Control+C"); + }, + { + cwd: repoRoot, + env: { + EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_SCOPE_DIR: dataDir, + EXECUTOR_NPM_DIST_TAGS: DIST_TAGS, + }, + record: join(runDir, "terminal.cast"), + viewport: { cols: 120, rows: 40 }, + }, + ) + .pipe(Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true })))); + }), +); + +scenario( + "Local update · the web shell sidebar surfaces the update-available card", + { timeout: 180_000 }, + Effect.gen(function* () { + const cli = yield* Cli; + const browser = yield* Browser; + const target = yield* Target; + const runDir = yield* RunDir; + const identity = yield* target.newIdentity(); + + yield* withLocalServer( + cli, + runDir, + ({ url }) => + browser.session(identity, async ({ page, step }) => { + await step("Open the console via the CLI's ?_token URL", async () => { + await page.goto(url, { waitUntil: "domcontentloaded" }); + // The console renders past the auth gate: the Integrations page and + // its built-in Executor source row load (proves auth + data, not + // just the static shell). + await page.getByRole("heading", { name: "Integrations" }).waitFor({ timeout: 60_000 }); + await page.getByText("Tool providers available").waitFor({ timeout: 30_000 }); + }); + + await step("The sidebar surfaces the update-available card", async () => { + // The card renders once useLatestVersion resolves the forced + // dist-tags served by /v1/app/npm/dist-tags. + await page.getByText("Update available").waitFor({ timeout: 30_000 }); + await page.getByText(`v${FORCED_LATEST}`).waitFor({ timeout: 5_000 }); + await page + .getByText("npm i -g executor@latest", { exact: true }) + .first() + .waitFor({ timeout: 5_000 }); + }); + }), + { env: { EXECUTOR_NPM_DIST_TAGS: DIST_TAGS } }, + ); + }), +); + +scenario( + "Local update · the desktop bridge shows a native restart-to-update card, not the npm command", + { timeout: 180_000 }, + Effect.gen(function* () { + const cli = yield* Cli; + const browser = yield* Browser; + const target = yield* Target; + const runDir = yield* RunDir; + const identity = yield* target.newIdentity(); + + // Drives the SAME shell the Electron renderer loads, with a stand-in for the + // desktop preload bridge (window.executor). A real packaged Electron run is + // covered by e2e/desktop/update-card.test.ts; this proves the renderer half + // (native card shown, npm command suppressed) in the browser harness. + yield* withLocalServer(cli, runDir, ({ url }) => + browser.session(identity, async ({ page, step }) => { + await page.addInitScript((version: string) => { + let status = { state: "downloaded", version }; + const subscribers: Array<(next: typeof status) => void> = []; + Object.assign(window, { + executor: { + getUpdateStatus: () => Promise.resolve(status), + onUpdateStatus: (cb: (next: typeof status) => void) => { + subscribers.push(cb); + return () => {}; + }, + installUpdate: () => { + status = { state: "installing", version }; + for (const cb of subscribers) cb(status); + return Promise.resolve(); + }, + }, + }); + }, FORCED_LATEST); + + await step("Open the desktop console", async () => { + await page.goto(url, { waitUntil: "domcontentloaded" }); + await page.getByRole("heading", { name: "Integrations" }).waitFor({ timeout: 60_000 }); + await page.getByText("Tool providers available").waitFor({ timeout: 30_000 }); + }); + + await step("The sidebar shows a native Restart to update card", async () => { + await page.getByText("Update available").waitFor({ timeout: 30_000 }); + await page.getByText(`v${FORCED_LATEST}`).waitFor({ timeout: 5_000 }); + await page.getByRole("button", { name: "Restart to update" }).waitFor({ timeout: 5_000 }); + // The desktop card never shows the npm command the web/CLI card does. + expect( + await page.getByText("npm i -g", { exact: false }).count(), + "the desktop card shows no npm command", + ).toBe(0); + }); + + await step("Clicking Restart drives the native install action", async () => { + await page.getByRole("button", { name: "Restart to update" }).click(); + await page.getByText("Restarting…").waitFor({ timeout: 5_000 }); + }); + }), + ); + }), +); diff --git a/packages/app/src/web/shell.tsx b/packages/app/src/web/shell.tsx index cb0849781..7f70532d0 100644 --- a/packages/app/src/web/shell.tsx +++ b/packages/app/src/web/shell.tsx @@ -17,6 +17,7 @@ import { integrationPresetIconUrl } from "@executor-js/react/components/integrat import { IntegrationIconWithAccount } from "@executor-js/react/components/integration-icon-with-account"; import { CommandPalette } from "@executor-js/react/components/command-palette"; import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; +import { type DesktopUpdate, useDesktopUpdate } from "@executor-js/react/hooks/desktop-update"; import { ServerConnectionMenu } from "./server-connection-menu"; // ── Env ───────────────────────────────────────────────────────────────── @@ -209,6 +210,70 @@ function UpdateCard(props: { latestVersion: string; channel: UpdateChannel }) { ); } +// ── DesktopUpdateCard ───────────────────────────────────────────────────── + +// Desktop builds update via electron-updater (a new bundle, swapped in place), +// not `npm i -g`, so the desktop shows a native action wired to the main +// process instead of UpdateCard's copyable command. Driven by the auto-update +// status main pushes over IPC (see hooks/desktop-update). +function DesktopUpdateCard(props: { update: DesktopUpdate }) { + const { status, install } = props.update; + const version = "version" in status ? status.version : null; + + const action = (() => { + if (status.state === "downloaded") { + return ( + + ); + } + if (status.state === "downloading") { + return ( +

+ Downloading… {status.percent}% +

+ ); + } + if (status.state === "available") { + return

Preparing update…

; + } + if (status.state === "installing") { + return

Restarting…

; + } + return null; + })(); + + return ( +
+
+
+ + + + +
+
+

Update available

+ {version &&

v{version}

} +
+
+ {action} +
+ ); +} + // ── NavItem ────────────────────────────────────────────────────────────── function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) { @@ -338,6 +403,7 @@ function SidebarContent(props: { updateAvailable: boolean; latestVersion: string | null; channel: UpdateChannel; + desktopUpdate: DesktopUpdate | null; }) { const isHome = props.pathname === "/"; const isSecrets = props.pathname === "/secrets"; @@ -401,9 +467,17 @@ function SidebarContent(props: { - {props.updateAvailable && props.latestVersion && ( - - )} + {/* In the desktop app electron-updater owns updates, so show a native + "Restart to update" card and never the npm command. Web and + CLI-served installs keep the npm UpdateCard. */} + {props.desktopUpdate + ? props.desktopUpdate.status.state !== "idle" && ( + + ) + : props.updateAvailable && + props.latestVersion && ( + + )} {/* Footer */}
@@ -449,6 +523,7 @@ export function Shell() { const refreshSources = useAtomRefresh(integrationsAtom); const refreshTools = useAtomRefresh(toolsAllAtom); const { latestVersion, updateAvailable, channel } = useLatestVersion(VITE_APP_VERSION); + const desktopUpdate = useDesktopUpdate(); const lastPathname = useRef(pathname); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); if (lastPathname.current !== pathname) { @@ -493,6 +568,7 @@ export function Shell() { updateAvailable={updateAvailable} latestVersion={latestVersion} channel={channel} + desktopUpdate={desktopUpdate} /> @@ -540,6 +616,7 @@ export function Shell() { updateAvailable={updateAvailable} latestVersion={latestVersion} channel={channel} + desktopUpdate={desktopUpdate} />
diff --git a/packages/core/api/src/index.ts b/packages/core/api/src/index.ts index 22bac494c..344be92f3 100644 --- a/packages/core/api/src/index.ts +++ b/packages/core/api/src/index.ts @@ -1,4 +1,14 @@ export { ExecutorApi, CoreExecutorApi, addGroup } from "./api"; +export { + checkForUpdate, + resolveDistTags, + resolveUpdateChannel, + compareVersions, + EXECUTOR_PACKAGE_NAME, + type UpdateStatus, + type UpdateChannel, + type DistTags, +} from "./update-check"; export { composePluginApi, composePluginHandlers, diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index 10c876421..a909a8130 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -107,6 +107,7 @@ export { OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, OAUTH_CLIENT_ID_METADATA_DOCUMENT_TARGET_PATH_PREFIX, } from "./server/oauth-client-metadata"; +export { makeNpmDistTagsRoute, NPM_DIST_TAGS_PATH } from "./server/npm-dist-tags"; export * as ExecutorApp from "./server/executor-app"; export type { ExecutorAppOptions, diff --git a/packages/core/api/src/server/executor-app.ts b/packages/core/api/src/server/executor-app.ts index 27efc08b0..8c7282eb9 100644 --- a/packages/core/api/src/server/executor-app.ts +++ b/packages/core/api/src/server/executor-app.ts @@ -56,6 +56,7 @@ import { } from "@executor-js/host-mcp"; import { composePluginApi } from "../plugin-routes"; +import { makeNpmDistTagsRoute } from "./npm-dist-tags"; import type { ErrorCapture } from "../observability"; import { EngineDecoratorNoop, @@ -565,6 +566,8 @@ export const make = < // the same — every route requirement is provided. const routeLayers: AppRouteLayer[] = [ makeOAuthClientIdMetadataRoute(config.mountPrefix) as AppRouteLayer, + // Un-prefixed `/v1/app/npm/dist-tags` powering the web shell's UpdateCard. + makeNpmDistTagsRoute() as AppRouteLayer, apiLive, ]; if (mcpRouteLive) routeLayers.push(mcpRouteLive); diff --git a/packages/core/api/src/server/npm-dist-tags.ts b/packages/core/api/src/server/npm-dist-tags.ts new file mode 100644 index 000000000..1d74e5fe4 --- /dev/null +++ b/packages/core/api/src/server/npm-dist-tags.ts @@ -0,0 +1,34 @@ +// --------------------------------------------------------------------------- +// `/v1/app/npm/dist-tags`: the published dist-tags for the `executor` package. +// +// The web/desktop shell's `useLatestVersion` hook fetches this un-prefixed path +// and compares the returned `latest`/`beta` against its own build version to +// decide whether to show the sidebar UpdateCard. It is intentionally NOT mounted +// under the API's `mountPrefix` (`/api`). The client hardcodes `/v1/app/...`, +// the same on every host, so it registers on the ambient router directly. +// +// The body is `resolveDistTags()` verbatim, so the CLI's notice and the web +// card resolve against identical data (see `../update-check.ts`). +// --------------------------------------------------------------------------- + +import { Effect, Layer } from "effect"; +import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; + +import { resolveDistTags } from "../update-check"; + +export const NPM_DIST_TAGS_PATH = "/v1/app/npm/dist-tags" as const; + +export const makeNpmDistTagsRoute = (): Layer.Layer => { + const handler = Effect.gen(function* () { + const tags = yield* Effect.promise(() => resolveDistTags()); + return HttpServerResponse.jsonUnsafe(tags, { + headers: { + // Short cache: the registry rarely moves, but a stale 5-minute window + // is fine for an upgrade nudge and spares the origin a fetch per load. + "cache-control": "public, max-age=300", + }, + }); + }); + + return HttpRouter.add("GET", NPM_DIST_TAGS_PATH, handler); +}; diff --git a/packages/core/api/src/update-check.test.ts b/packages/core/api/src/update-check.test.ts new file mode 100644 index 000000000..24839238c --- /dev/null +++ b/packages/core/api/src/update-check.test.ts @@ -0,0 +1,131 @@ +// --------------------------------------------------------------------------- +// Unit tests for the shared update-check resolver. Pins the resolution order +// (disable, JSON override, single-value override, registry, failure) and the +// semver verdict that both the CLI notice and the web UpdateCard depend on. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; + +import { + __resetDistTagsCache, + checkForUpdate, + compareVersions, + resolveDistTags, + resolveUpdateChannel, +} from "./update-check"; + +const jsonResponse = (body: unknown, ok = true): Response => + new Response(JSON.stringify(body), { + status: ok ? 200 : 500, + headers: { "content-type": "application/json" }, + }); + +const fetchReturning = (body: unknown, ok = true): typeof fetch => + (async () => jsonResponse(body, ok)) as typeof fetch; + +// A fetch impl whose body is not valid JSON, exercising resolveDistTags' +// best-effort catch: any registry misbehaviour collapses to {}. +const fetchThatFails = (): typeof fetch => + (async () => new Response("", { status: 200 })) as typeof fetch; + +describe("compareVersions", () => { + it("orders by major, minor, patch", () => { + expect(compareVersions("1.0.0", "2.0.0")).toBe(-1); + expect(compareVersions("1.5.22", "1.5.21")).toBe(1); + expect(compareVersions("1.5.22", "1.5.22")).toBe(0); + }); + + it("treats a prerelease as older than its release", () => { + expect(compareVersions("1.6.0-beta.1", "1.6.0")).toBe(-1); + expect(compareVersions("1.6.0-beta.1", "1.6.0-beta.2")).toBe(-1); + }); + + it("returns null for unparseable input", () => { + expect(compareVersions("not-a-version", "1.0.0")).toBeNull(); + }); +}); + +describe("resolveUpdateChannel", () => { + it("routes beta builds to the beta channel", () => { + expect(resolveUpdateChannel("1.6.0-beta.3")).toBe("beta"); + expect(resolveUpdateChannel("1.5.22")).toBe("latest"); + expect(resolveUpdateChannel("0.0.0-dev")).toBe("latest"); + }); +}); + +describe("resolveDistTags", () => { + it("returns nothing when the check is disabled", async () => { + const tags = await resolveDistTags({ + env: { EXECUTOR_DISABLE_UPDATE_CHECK: "1", EXECUTOR_FORCE_LATEST_VERSION: "9.9.9" }, + fetchImpl: fetchThatFails(), + }); + expect(tags).toEqual({}); + }); + + it("honours a JSON dist-tags override", async () => { + const tags = await resolveDistTags({ + env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "9.9.9", beta: "9.9.9-beta.1" }) }, + fetchImpl: fetchThatFails(), + }); + expect(tags).toEqual({ latest: "9.9.9", beta: "9.9.9-beta.1" }); + }); + + it("honours a single-value override on both channels", async () => { + const tags = await resolveDistTags({ + env: { EXECUTOR_FORCE_LATEST_VERSION: "2.0.0" }, + fetchImpl: fetchThatFails(), + }); + expect(tags).toEqual({ latest: "2.0.0", beta: "2.0.0" }); + }); + + it("falls back to the registry, keeping only string tags", async () => { + __resetDistTagsCache(); + const tags = await resolveDistTags({ + env: {}, + fetchImpl: fetchReturning({ latest: "1.5.22", beta: "1.6.0-beta.1", next: 5 }), + }); + expect(tags).toEqual({ latest: "1.5.22", beta: "1.6.0-beta.1" }); + }); + + it("swallows a failing registry into an empty result", async () => { + __resetDistTagsCache(); + const tags = await resolveDistTags({ env: {}, fetchImpl: fetchThatFails() }); + expect(tags).toEqual({}); + }); +}); + +describe("checkForUpdate", () => { + it("flags an available update on the resolved channel", async () => { + const status = await checkForUpdate("1.5.22", { + env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.6.0" }) }, + }); + expect(status.updateAvailable).toBe(true); + expect(status.latestVersion).toBe("1.6.0"); + expect(status.channel).toBe("latest"); + expect(status.command).toBe("npm i -g executor@latest"); + }); + + it("stays quiet when already current", async () => { + const status = await checkForUpdate("1.6.0", { + env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.6.0" }) }, + }); + expect(status.updateAvailable).toBe(false); + }); + + it("compares a beta build against the beta tag", async () => { + const status = await checkForUpdate("1.6.0-beta.1", { + env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.5.22", beta: "1.6.0-beta.2" }) }, + }); + expect(status.channel).toBe("beta"); + expect(status.updateAvailable).toBe(true); + expect(status.latestVersion).toBe("1.6.0-beta.2"); + expect(status.command).toBe("npm i -g executor@beta"); + }); + + it("treats the dev build as upgradeable to any release", async () => { + const status = await checkForUpdate("0.0.0-dev", { + env: { EXECUTOR_FORCE_LATEST_VERSION: "1.5.22" }, + }); + expect(status.updateAvailable).toBe(true); + }); +}); diff --git a/packages/core/api/src/update-check.ts b/packages/core/api/src/update-check.ts new file mode 100644 index 000000000..244d417ce --- /dev/null +++ b/packages/core/api/src/update-check.ts @@ -0,0 +1,218 @@ +// --------------------------------------------------------------------------- +// Update check: one source of truth for "is a newer Executor published?" +// +// Both surfaces that tell a user to upgrade read from here: +// - the CLI prints an "update available" line under its ready banner, and +// - the web/desktop shell lights up its sidebar UpdateCard, +// the latter by fetching the `/v1/app/npm/dist-tags` route (see +// `server/npm-dist-tags.ts`), which serves `resolveDistTags()` verbatim. +// +// The semver helpers are a deliberate port of the ones the web shell already +// carries (`packages/app/src/web/shell.tsx`) so the client- and server-side +// "is this newer?" verdicts can never disagree. +// +// Resolution order (see `resolveDistTags`): +// 1. `EXECUTOR_DISABLE_UPDATE_CHECK` set -> {} (no check, both surfaces quiet) +// 2. `EXECUTOR_NPM_DIST_TAGS` JSON override -> parsed tags (tests / air-gapped) +// 3. `EXECUTOR_FORCE_LATEST_VERSION` single value -> { latest, beta } = value +// 4. npm registry dist-tags endpoint, cached in-process +// 5. any failure -> {} (the check is best-effort; never a hard error) +// --------------------------------------------------------------------------- + +export type UpdateChannel = "latest" | "beta"; + +/** The published npm package the CLI ships as. */ +export const EXECUTOR_PACKAGE_NAME = "executor"; + +/** Lightweight dist-tags-only registry endpoint (not the full packument). */ +const NPM_DIST_TAGS_URL = `https://registry.npmjs.org/-/package/${EXECUTOR_PACKAGE_NAME}/dist-tags`; + +/** How long a successful registry lookup is reused before refetching. */ +const DIST_TAGS_TTL_MS = 10 * 60 * 1000; + +/** How long to wait on the registry before giving up (a check, not a gate). */ +const NPM_FETCH_TIMEOUT_MS = 1500; + +// ── Semver (ported from the web shell, kept byte-for-byte in behaviour) ──── + +type ParsedVersion = { + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly prerelease: ReadonlyArray | null; +}; + +const semverPattern = + /^(?\d+)\.(?\d+)\.(?\d+)(?:-(?[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; + +export const resolveUpdateChannel = (version: string): UpdateChannel => + version.includes("-beta.") ? "beta" : "latest"; + +const parseVersion = (version: string): ParsedVersion | null => { + const match = version.trim().match(semverPattern); + if (!match?.groups) return null; + return { + major: Number(match.groups.major), + minor: Number(match.groups.minor), + patch: Number(match.groups.patch), + prerelease: match.groups.prerelease + ? match.groups.prerelease.split(".").map((id) => (/^\d+$/.test(id) ? Number(id) : id)) + : null, + }; +}; + +const comparePrereleaseIdentifiers = ( + left: ReadonlyArray | null, + right: ReadonlyArray | null, +): number => { + if (left === null && right === null) return 0; + if (left === null) return 1; + if (right === null) return -1; + const max = Math.max(left.length, right.length); + for (let i = 0; i < max; i++) { + const l = left[i]; + const r = right[i]; + if (l === undefined) return -1; + if (r === undefined) return 1; + if (l === r) continue; + if (typeof l === "number" && typeof r === "number") return l < r ? -1 : 1; + if (typeof l === "number") return -1; + if (typeof r === "number") return 1; + return l < r ? -1 : 1; + } + return 0; +}; + +/** + * `-1` if `left` is older than `right`, `1` if newer, `0` if equal, `null` if + * either side is not parseable semver. + */ +export const compareVersions = (left: string, right: string): number | null => { + const lv = parseVersion(left); + const rv = parseVersion(right); + if (!lv || !rv) return null; + if (lv.major !== rv.major) return lv.major < rv.major ? -1 : 1; + if (lv.minor !== rv.minor) return lv.minor < rv.minor ? -1 : 1; + if (lv.patch !== rv.patch) return lv.patch < rv.patch ? -1 : 1; + return comparePrereleaseIdentifiers(lv.prerelease, rv.prerelease); +}; + +// ── dist-tags resolution ────────────────────────────────────────────────── + +export type DistTags = Partial>; + +export type ResolveDistTagsOptions = { + /** Env source; defaults to `process.env`. Injectable for tests. */ + readonly env?: Record; + /** Fetch impl; defaults to global `fetch`. Injectable for tests. */ + readonly fetchImpl?: typeof fetch; +}; + +type CacheEntry = { readonly at: number; readonly tags: DistTags }; +let registryCache: CacheEntry | null = null; + +// Read `process.env` without a hard `process` reference, so this module stays +// isomorphic (it is pulled into the browser-side type graph via the shared web +// shell's update card, which has no node types). +const ambientEnv = (): Record => { + const globalProcess = ( + globalThis as { readonly process?: { readonly env?: Record } } + ).process; + return globalProcess?.env ?? {}; +}; + +/** Reset the in-process registry cache. Test seam. */ +export const __resetDistTagsCache = (): void => { + registryCache = null; +}; + +const pickTags = (value: unknown): DistTags => { + if (typeof value !== "object" || value === null) return {}; + const record = value as Record; + const tags: DistTags = {}; + if (typeof record.latest === "string") tags.latest = record.latest; + if (typeof record.beta === "string") tags.beta = record.beta; + return tags; +}; + +const parseTagsJson = (raw: string): DistTags | null => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: an operator-supplied env override is untrusted text; a malformed value is ignored, not fatal + try { + // oxlint-disable-next-line executor/no-json-parse -- boundary: untrusted env override, structurally validated by pickTags below + return pickTags(JSON.parse(raw)); + } catch { + return null; + } +}; + +const overrideFromEnv = (env: Record): DistTags | null => { + const raw = env.EXECUTOR_NPM_DIST_TAGS?.trim(); + if (raw) { + const parsed = parseTagsJson(raw); + if (parsed) return parsed; + } + const forced = env.EXECUTOR_FORCE_LATEST_VERSION?.trim(); + if (forced) return { latest: forced, beta: forced }; + return null; +}; + +/** + * Resolve the published dist-tags for the `executor` package. Best-effort: + * returns `{}` when disabled, offline, timed out, or the registry misbehaves, + * never throws. + */ +export const resolveDistTags = async (options?: ResolveDistTagsOptions): Promise => { + const env = options?.env ?? ambientEnv(); + if (env.EXECUTOR_DISABLE_UPDATE_CHECK) return {}; + + const override = overrideFromEnv(env); + if (override) return override; + + const now = Date.now(); + if (registryCache && now - registryCache.at < DIST_TAGS_TTL_MS) { + return registryCache.tags; + } + + const fetchImpl = options?.fetchImpl ?? globalThis.fetch; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort fetch of an external registry; any failure (offline, timeout, bad JSON) collapses to "no update signal", never an error + try { + const res = await fetchImpl(NPM_DIST_TAGS_URL, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS), + }); + if (!res.ok) return {}; + const tags = pickTags(await res.json()); + registryCache = { at: now, tags }; + return tags; + } catch { + return {}; + } +}; + +// ── checkForUpdate ───────────────────────────────────────────────────────── + +export type UpdateStatus = { + readonly updateAvailable: boolean; + readonly currentVersion: string; + readonly latestVersion: string | null; + readonly channel: UpdateChannel; + /** Suggested upgrade command, e.g. `npm i -g executor@latest`. */ + readonly command: string; +}; + +/** + * Compare a running version against the published dist-tags for its channel. + * Best-effort: an unreachable registry yields `updateAvailable: false`. + */ +export const checkForUpdate = async ( + currentVersion: string, + options?: ResolveDistTagsOptions, +): Promise => { + const channel = resolveUpdateChannel(currentVersion); + const command = `npm i -g ${EXECUTOR_PACKAGE_NAME}@${channel}`; + const tags = await resolveDistTags(options); + const latestVersion = tags[channel] ?? null; + const updateAvailable = + latestVersion !== null && compareVersions(currentVersion, latestVersion) === -1; + return { updateAvailable, currentVersion, latestVersion, channel, command }; +}; diff --git a/packages/react/src/hooks/desktop-update.ts b/packages/react/src/hooks/desktop-update.ts new file mode 100644 index 000000000..4b6b61df0 --- /dev/null +++ b/packages/react/src/hooks/desktop-update.ts @@ -0,0 +1,69 @@ +// Renderer view of the desktop auto-update status the Electron main process +// exposes on `window.executor` (see apps/desktop/src/shared/update.ts for the +// wire contract and apps/desktop/src/preload for the bridge). On web and +// CLI-served installs there is no bridge, so `useDesktopUpdate` returns null and +// the shell falls back to its npm UpdateCard. In the desktop app it drives a +// native "Restart to update" card instead, because desktop updates ship as a +// new bundle via electron-updater, not `npm i -g`. +import { useEffect, useMemo, useState } from "react"; + +export type DesktopUpdateStatus = + | { readonly state: "idle" } + | { readonly state: "available"; readonly version: string } + | { readonly state: "downloading"; readonly version: string; readonly percent: number } + | { readonly state: "downloaded"; readonly version: string } + | { readonly state: "installing"; readonly version: string }; + +type DesktopUpdateBridge = { + getUpdateStatus: () => Promise; + onUpdateStatus: (cb: (status: DesktopUpdateStatus) => void) => () => void; + installUpdate: () => Promise; +}; + +const getDesktopUpdateBridge = (): DesktopUpdateBridge | null => { + if (typeof window === "undefined") return null; + const candidate = (window as { readonly executor?: Partial }).executor; + if ( + candidate && + typeof candidate.getUpdateStatus === "function" && + typeof candidate.onUpdateStatus === "function" && + typeof candidate.installUpdate === "function" + ) { + // oxlint-disable-next-line executor/no-double-cast -- boundary: narrowed by the typeof guards above + return candidate as DesktopUpdateBridge; + } + return null; +}; + +export interface DesktopUpdate { + readonly status: DesktopUpdateStatus; + /** Apply a downloaded update and restart the app. */ + readonly install: () => void; +} + +/** + * Subscribe to the desktop app's auto-update status. Returns null when not + * running inside the desktop bridge (web / CLI-served), so the caller can fall + * back to its npm upgrade card. Hook order is stable: the bridge is resolved + * once and the subscription effect always runs. + */ +export function useDesktopUpdate(): DesktopUpdate | null { + const bridge = useMemo(() => getDesktopUpdateBridge(), []); + const [status, setStatus] = useState({ state: "idle" }); + + useEffect(() => { + if (!bridge) return; + let active = true; + void bridge.getUpdateStatus().then((current) => { + if (active) setStatus(current); + }); + const unsubscribe = bridge.onUpdateStatus((next) => setStatus(next)); + return () => { + active = false; + unsubscribe(); + }; + }, [bridge]); + + if (!bridge) return null; + return { status, install: () => void bridge.installUpdate() }; +} From e5fe75754b1356edae91bd02b63ffa631a0e4117 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:24:23 -0700 Subject: [PATCH 2/5] Serve the update endpoint on Cloudflare and Docker self-host Cloudflare's Workers Static Assets answered /v1/app/npm/dist-tags with the SPA index.html (it was not in run_worker_first), so the update card stayed dark there. Forward /v1/* to the Worker. The production Docker image (serve.ts) already routes it through the Effect router. Add reachability scenarios for both hosts asserting the path answers JSON, not the SPA fallback. --- apps/host-cloudflare/wrangler.jsonc | 7 ++++--- e2e/cloudflare/update-endpoint.test.ts | 28 +++++++++++++++++++++++++ e2e/selfhost/update-endpoint.test.ts | 29 ++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 e2e/cloudflare/update-endpoint.test.ts create mode 100644 e2e/selfhost/update-endpoint.test.ts diff --git a/apps/host-cloudflare/wrangler.jsonc b/apps/host-cloudflare/wrangler.jsonc index ec80e3435..1fc906a55 100644 --- a/apps/host-cloudflare/wrangler.jsonc +++ b/apps/host-cloudflare/wrangler.jsonc @@ -7,12 +7,13 @@ "observability": { "enabled": true }, // The web UI (Workers Static Assets) — the shared multiplayer SPA built by // `vite build` into ./dist. `single-page-application` serves index.html for - // client routes (e.g. /policies); `run_worker_first` forces the API + MCP - // paths to the Worker instead of the SPA fallback. + // client routes (e.g. /policies); `run_worker_first` forces the API, MCP, and + // app-level routes (e.g. /v1/app/npm/dist-tags, the update check) to the + // Worker instead of the SPA fallback. "assets": { "directory": "./dist", "not_found_handling": "single-page-application", - "run_worker_first": ["/api/*", "/mcp", "/mcp/*", "/.well-known/*"], + "run_worker_first": ["/api/*", "/mcp", "/mcp/*", "/.well-known/*", "/v1", "/v1/*"], }, // D1 is the app's SQLite store (the DbProvider seam). `wrangler deploy` // auto-provisions it on first deploy; replace database_id after that, or run diff --git a/e2e/cloudflare/update-endpoint.test.ts b/e2e/cloudflare/update-endpoint.test.ts new file mode 100644 index 000000000..c6800c7ef --- /dev/null +++ b/e2e/cloudflare/update-endpoint.test.ts @@ -0,0 +1,28 @@ +// Cloudflare-only: the update check's `/v1/app/npm/dist-tags` must reach the +// Worker and answer JSON. On Cloudflare the SPA is served by Workers Static +// Assets, which return index.html for any path NOT in `run_worker_first` +// (wrangler.jsonc) — so without `/v1/*` listed there, this path 200s with HTML +// and the client's JSON parse fails (the sidebar UpdateCard stays dark). This +// pins that the asset layer forwards `/v1/*` to the Worker. The body is the live +// dist-tags (possibly empty); reachability is the contract here. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Target } from "../src/services"; + +scenario( + "Cloudflare · the update dist-tags endpoint answers JSON, not the SPA fallback", + { timeout: 60_000 }, + Effect.gen(function* () { + const target = yield* Target; + const res = yield* Effect.promise(() => fetch(`${target.baseUrl}/v1/app/npm/dist-tags`)); + expect(res.status, "the endpoint responds 200").toBe(200); + expect( + res.headers.get("content-type") ?? "", + "forwarded to the Worker (run_worker_first), not the SPA index.html", + ).toContain("application/json"); + const body = (yield* Effect.promise(() => res.json())) as Record; + expect(typeof body, "the body is a JSON object of dist-tags").toBe("object"); + }), +); diff --git a/e2e/selfhost/update-endpoint.test.ts b/e2e/selfhost/update-endpoint.test.ts new file mode 100644 index 000000000..78f65f359 --- /dev/null +++ b/e2e/selfhost/update-endpoint.test.ts @@ -0,0 +1,29 @@ +// Selfhost-only (runs on the dev server AND the production Docker image): the +// update check's `/v1/app/npm/dist-tags` must reach the Effect route and answer +// JSON, not get swallowed by the SPA index.html fallback. That fallback is +// exactly the failure mode that kept the sidebar UpdateCard dark before this +// route existed (a 200-with-HTML response fails the client's JSON parse), so +// asserting the content-type is JSON is the real contract. The body is the live +// dist-tags (possibly empty when the registry is unreachable) — reachability, +// not contents, is what this pins per host. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Target } from "../src/services"; + +scenario( + "Selfhost · the update dist-tags endpoint answers JSON, not the SPA fallback", + { timeout: 60_000 }, + Effect.gen(function* () { + const target = yield* Target; + const res = yield* Effect.promise(() => fetch(`${target.baseUrl}/v1/app/npm/dist-tags`)); + expect(res.status, "the endpoint responds 200").toBe(200); + expect( + res.headers.get("content-type") ?? "", + "served as JSON by the Effect route, not the SPA index.html", + ).toContain("application/json"); + const body = (yield* Effect.promise(() => res.json())) as Record; + expect(typeof body, "the body is a JSON object of dist-tags").toBe("object"); + }), +); From 392add738bb5ad2d20b9e0f590aa30fc2aef2acb Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:58:08 -0700 Subject: [PATCH 3/5] Show the update card in the multiplayer shell (self-host, Cloudflare, cloud) The card lived only in the local/desktop shell, so self-host and Cloudflare served the dist-tags endpoint but never rendered a card. Extract the card (npm and desktop-native variants) into one shared SidebarUpdateCard in @executor-js/react and drop it into both shells, so every web deployment shows it. Add per-host render scenarios proving the card paints on the self-host / Docker UI and on the real Cloudflare worker. --- e2e/cloudflare/update-card-render.test.ts | 8 + e2e/selfhost/update-card-render.test.ts | 8 + e2e/src/update-card-render.ts | 50 +++ packages/app/src/web/shell.tsx | 285 +----------------- packages/react/src/components/update-card.tsx | 225 ++++++++++++++ packages/react/src/multiplayer/shell.tsx | 3 + 6 files changed, 299 insertions(+), 280 deletions(-) create mode 100644 e2e/cloudflare/update-card-render.test.ts create mode 100644 e2e/selfhost/update-card-render.test.ts create mode 100644 e2e/src/update-card-render.ts create mode 100644 packages/react/src/components/update-card.tsx diff --git a/e2e/cloudflare/update-card-render.test.ts b/e2e/cloudflare/update-card-render.test.ts new file mode 100644 index 000000000..81901ef17 --- /dev/null +++ b/e2e/cloudflare/update-card-render.test.ts @@ -0,0 +1,8 @@ +// Cloudflare-only: the update card paints on the Cloudflare-served web shell +// (Workers Static Assets + the Worker route). See ../src/update-card-render.ts +// for the shared body. +import { registerUpdateCardRenderScenario } from "../src/update-card-render"; + +registerUpdateCardRenderScenario( + "Cloudflare · the web shell sidebar surfaces the update-available card", +); diff --git a/e2e/selfhost/update-card-render.test.ts b/e2e/selfhost/update-card-render.test.ts new file mode 100644 index 000000000..2830183e7 --- /dev/null +++ b/e2e/selfhost/update-card-render.test.ts @@ -0,0 +1,8 @@ +// Selfhost-only (runs on the dev server AND the production Docker image): the +// update card paints on the self-host web shell. See +// ../src/update-card-render.ts for the shared body. +import { registerUpdateCardRenderScenario } from "../src/update-card-render"; + +registerUpdateCardRenderScenario( + "Selfhost · the web shell sidebar surfaces the update-available card", +); diff --git a/e2e/src/update-card-render.ts b/e2e/src/update-card-render.ts new file mode 100644 index 000000000..2bd066941 --- /dev/null +++ b/e2e/src/update-card-render.ts @@ -0,0 +1,50 @@ +// Shared body for the per-host "the update card renders" browser scenarios +// (selfhost + selfhost-docker + cloudflare). Each host serves the same web +// shell, so the only thing worth proving per host is that the card actually +// paints on THAT deployment's real, served UI. The published-version signal is +// forced with a Playwright route mock so the screenshot is deterministic and +// offline (reachability of the real `/v1/app/npm/dist-tags` route is pinned +// separately by the `update-endpoint` scenarios). +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "./scenario"; +import { Browser, Target } from "./services"; + +export const FORCED_LATEST = "99.0.0"; + +export const registerUpdateCardRenderScenario = (name: string): void => + scenario( + name, + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + // Force a newer published version regardless of the registry, so the + // card is deterministic on this host's real UI. + await page.route("**/v1/app/npm/dist-tags", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ latest: FORCED_LATEST, beta: `${FORCED_LATEST}-beta.1` }), + }), + ); + + await step("Open the console", async () => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByRole("heading", { name: "Integrations" }).waitFor({ timeout: 60_000 }); + }); + + await step("The sidebar surfaces the update-available card", async () => { + await page.getByText("Update available").waitFor({ timeout: 30_000 }); + await page.getByText(`v${FORCED_LATEST}`).waitFor({ timeout: 5_000 }); + expect( + await page.getByText("npm i -g executor@latest", { exact: true }).count(), + "the web card shows the npm upgrade command", + ).toBeGreaterThan(0); + }); + }); + }), + ); diff --git a/packages/app/src/web/shell.tsx b/packages/app/src/web/shell.tsx index 7f70532d0..53f391491 100644 --- a/packages/app/src/web/shell.tsx +++ b/packages/app/src/web/shell.tsx @@ -1,8 +1,6 @@ import { Link, Outlet, useLocation } from "@tanstack/react-router"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import type { Integration } from "@executor-js/sdk/shared"; import { @@ -11,13 +9,11 @@ import { toolsAllAtom, } from "@executor-js/react/api/atoms"; import { Button } from "@executor-js/react/components/button"; -import { toast } from "@executor-js/react/components/sonner"; -import { copyToClipboard } from "@executor-js/react/lib/clipboard"; import { integrationPresetIconUrl } from "@executor-js/react/components/integration-favicon"; import { IntegrationIconWithAccount } from "@executor-js/react/components/integration-icon-with-account"; import { CommandPalette } from "@executor-js/react/components/command-palette"; import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; -import { type DesktopUpdate, useDesktopUpdate } from "@executor-js/react/hooks/desktop-update"; +import { SidebarUpdateCard } from "@executor-js/react/components/update-card"; import { ServerConnectionMenu } from "./server-connection-menu"; // ── Env ───────────────────────────────────────────────────────────────── @@ -33,247 +29,6 @@ const { VITE_APP_VERSION, VITE_GITHUB_URL } = ( } ).env; -// ── Version helpers ───────────────────────────────────────────────────── - -type UpdateChannel = "latest" | "beta"; - -const EXECUTOR_DIST_TAGS_PATH = "/v1/app/npm/dist-tags"; - -type ParsedVersion = { - readonly major: number; - readonly minor: number; - readonly patch: number; - readonly prerelease: ReadonlyArray | null; -}; - -const semverPattern = - /^(?\d+)\.(?\d+)\.(?\d+)(?:-(?[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; - -const resolveUpdateChannel = (version: string): UpdateChannel => - version.includes("-beta.") ? "beta" : "latest"; - -const parseVersion = (version: string): ParsedVersion | null => { - const match = version.trim().match(semverPattern); - if (!match?.groups) return null; - return { - major: Number(match.groups.major), - minor: Number(match.groups.minor), - patch: Number(match.groups.patch), - prerelease: match.groups.prerelease - ? match.groups.prerelease.split(".").map((id) => (/^\d+$/.test(id) ? Number(id) : id)) - : null, - }; -}; - -const comparePrereleaseIdentifiers = ( - left: ReadonlyArray | null, - right: ReadonlyArray | null, -): number => { - if (left === null && right === null) return 0; - if (left === null) return 1; - if (right === null) return -1; - const max = Math.max(left.length, right.length); - for (let i = 0; i < max; i++) { - const l = left[i]; - const r = right[i]; - if (l === undefined) return -1; - if (r === undefined) return 1; - if (l === r) continue; - if (typeof l === "number" && typeof r === "number") return l < r ? -1 : 1; - if (typeof l === "number") return -1; - if (typeof r === "number") return 1; - return l < r ? -1 : 1; - } - return 0; -}; - -const compareVersions = (left: string, right: string): number | null => { - const lv = parseVersion(left); - const rv = parseVersion(right); - if (!lv || !rv) return null; - if (lv.major !== rv.major) return lv.major < rv.major ? -1 : 1; - if (lv.minor !== rv.minor) return lv.minor < rv.minor ? -1 : 1; - if (lv.patch !== rv.patch) return lv.patch < rv.patch ? -1 : 1; - return comparePrereleaseIdentifiers(lv.prerelease, rv.prerelease); -}; - -// ── useLatestVersion ──────────────────────────────────────────────────── - -function useLatestVersion(currentVersion: string) { - const channel = resolveUpdateChannel(currentVersion); - const [latestVersion, setLatestVersion] = useState(null); - - useEffect(() => { - let cancelled = false; - void Effect.runPromiseExit( - Effect.tryPromise({ - try: async () => { - const res = await fetch(EXECUTOR_DIST_TAGS_PATH); - if (!res.ok) return null; - return (await res.json()) as Partial>; - }, - catch: (cause) => cause, - }), - ).then((exit) => { - if (!cancelled && Exit.isSuccess(exit)) { - setLatestVersion(exit.value?.[channel] ?? null); - } - }); - return () => { - cancelled = true; - }; - }, [channel]); - - const updateAvailable = - latestVersion !== null && compareVersions(currentVersion, latestVersion) === -1; - - return { latestVersion, updateAvailable, channel }; -} - -// ── UpdateCard ────────────────────────────────────────────────────────── - -function UpdateCard(props: { latestVersion: string; channel: UpdateChannel }) { - const command = `npm i -g executor@${props.channel}`; - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(() => { - void copyToClipboard(command).then((ok) => { - if (!ok) { - toast.error("Failed to copy to clipboard"); - return; - } - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - }, [command]); - - return ( -
-
-
- - - - -
-
-

Update available

-

v{props.latestVersion}

-
-
- -
- ); -} - -// ── DesktopUpdateCard ───────────────────────────────────────────────────── - -// Desktop builds update via electron-updater (a new bundle, swapped in place), -// not `npm i -g`, so the desktop shows a native action wired to the main -// process instead of UpdateCard's copyable command. Driven by the auto-update -// status main pushes over IPC (see hooks/desktop-update). -function DesktopUpdateCard(props: { update: DesktopUpdate }) { - const { status, install } = props.update; - const version = "version" in status ? status.version : null; - - const action = (() => { - if (status.state === "downloaded") { - return ( - - ); - } - if (status.state === "downloading") { - return ( -

- Downloading… {status.percent}% -

- ); - } - if (status.state === "available") { - return

Preparing update…

; - } - if (status.state === "installing") { - return

Restarting…

; - } - return null; - })(); - - return ( -
-
-
- - - - -
-
-

Update available

- {version &&

v{version}

} -
-
- {action} -
- ); -} - // ── NavItem ────────────────────────────────────────────────────────────── function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) { @@ -396,15 +151,7 @@ function IntegrationList(props: { pathname: string; onNavigate?: () => void }) { // ── SidebarContent ─────────────────────────────────────────────────────── -function SidebarContent(props: { - pathname: string; - onNavigate?: () => void; - showBrand?: boolean; - updateAvailable: boolean; - latestVersion: string | null; - channel: UpdateChannel; - desktopUpdate: DesktopUpdate | null; -}) { +function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) { const isHome = props.pathname === "/"; const isSecrets = props.pathname === "/secrets"; const isPolicies = props.pathname === "/policies"; @@ -467,17 +214,7 @@ function SidebarContent(props: { - {/* In the desktop app electron-updater owns updates, so show a native - "Restart to update" card and never the npm command. Web and - CLI-served installs keep the npm UpdateCard. */} - {props.desktopUpdate - ? props.desktopUpdate.status.state !== "idle" && ( - - ) - : props.updateAvailable && - props.latestVersion && ( - - )} + {/* Footer */}
@@ -522,8 +259,6 @@ export function Shell() { const pathname = location.pathname; const refreshSources = useAtomRefresh(integrationsAtom); const refreshTools = useAtomRefresh(toolsAllAtom); - const { latestVersion, updateAvailable, channel } = useLatestVersion(VITE_APP_VERSION); - const desktopUpdate = useDesktopUpdate(); const lastPathname = useRef(pathname); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); if (lastPathname.current !== pathname) { @@ -563,13 +298,7 @@ export function Shell() { {/* Desktop sidebar */} {/* Mobile sidebar overlay */} @@ -613,10 +342,6 @@ export function Shell() { pathname={pathname} onNavigate={() => setMobileSidebarOpen(false)} showBrand={false} - updateAvailable={updateAvailable} - latestVersion={latestVersion} - channel={channel} - desktopUpdate={desktopUpdate} />
diff --git a/packages/react/src/components/update-card.tsx b/packages/react/src/components/update-card.tsx new file mode 100644 index 000000000..578db9398 --- /dev/null +++ b/packages/react/src/components/update-card.tsx @@ -0,0 +1,225 @@ +// The sidebar "update available" card, shared by both shells (the local/desktop +// single-user shell in @executor-js/app and the multiplayer shell here). One +// `` encapsulates the whole decision: +// +// - in the desktop app (the bridge is present): a native "Restart to update" +// action wired to electron-updater (see ../hooks/desktop-update), and +// - on web / CLI-served installs: the npm upgrade command, lit up when the +// `/v1/app/npm/dist-tags` endpoint reports a newer version for the channel. +// +// The semver verdict comes from @executor-js/api so the card and the CLI notice +// can never disagree. +import { useCallback, useEffect, useState } from "react"; + +import { Effect, Exit } from "effect"; +import { compareVersions, resolveUpdateChannel, type UpdateChannel } from "@executor-js/api"; + +import { Button } from "./button"; +import { toast } from "./sonner"; +import { copyToClipboard } from "../lib/clipboard"; +import { type DesktopUpdate, useDesktopUpdate } from "../hooks/desktop-update"; + +const EXECUTOR_DIST_TAGS_PATH = "/v1/app/npm/dist-tags"; + +const APP_VERSION = ( + import.meta as ImportMeta & { readonly env?: { readonly VITE_APP_VERSION?: string } } +).env?.VITE_APP_VERSION; + +// ── useLatestVersion ──────────────────────────────────────────────────── + +function useLatestVersion(currentVersion: string | undefined) { + const channel: UpdateChannel = currentVersion ? resolveUpdateChannel(currentVersion) : "latest"; + const [latestVersion, setLatestVersion] = useState(null); + + useEffect(() => { + if (!currentVersion) return; + let cancelled = false; + void Effect.runPromiseExit( + Effect.tryPromise({ + try: async () => { + const res = await fetch(EXECUTOR_DIST_TAGS_PATH); + if (!res.ok) return null; + return (await res.json()) as Partial>; + }, + catch: (cause) => cause, + }), + ).then((exit) => { + if (!cancelled && Exit.isSuccess(exit)) { + setLatestVersion(exit.value?.[channel] ?? null); + } + }); + return () => { + cancelled = true; + }; + }, [channel, currentVersion]); + + const updateAvailable = + currentVersion !== undefined && + latestVersion !== null && + compareVersions(currentVersion, latestVersion) === -1; + + return { latestVersion, updateAvailable, channel }; +} + +// ── UpdateCard (web / CLI install: copyable npm command) ───────────────── + +function UpdateCard(props: { latestVersion: string; channel: UpdateChannel }) { + const command = `npm i -g executor@${props.channel}`; + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + void copyToClipboard(command).then((ok) => { + if (!ok) { + toast.error("Failed to copy to clipboard"); + return; + } + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }, [command]); + + return ( +
+
+
+ + + + +
+
+

Update available

+

v{props.latestVersion}

+
+
+ +
+ ); +} + +// ── DesktopUpdateCard (desktop app: native restart action) ─────────────── + +function DesktopUpdateCard(props: { update: DesktopUpdate }) { + const { status, install } = props.update; + const version = "version" in status ? status.version : null; + + const action = (() => { + if (status.state === "downloaded") { + return ( + + ); + } + if (status.state === "downloading") { + return ( +

+ Downloading… {status.percent}% +

+ ); + } + if (status.state === "available") { + return

Preparing update…

; + } + if (status.state === "installing") { + return

Restarting…

; + } + return null; + })(); + + return ( +
+
+
+ + + + +
+
+

Update available

+ {version &&

v{version}

} +
+
+ {action} +
+ ); +} + +// ── SidebarUpdateCard (the only export the shells consume) ──────────────── + +/** + * The sidebar update card, or null when no update is available. Reads the + * build's `VITE_APP_VERSION` and the desktop bridge itself, so a shell just + * drops it in above its footer. Hook order is stable across renders. + */ +export function SidebarUpdateCard(): React.ReactElement | null { + const desktopUpdate = useDesktopUpdate(); + const { latestVersion, updateAvailable, channel } = useLatestVersion(APP_VERSION); + + // In the desktop app electron-updater owns updates, so show the native card + // and never the npm command. Web and CLI-served installs show the npm card. + if (desktopUpdate) { + return desktopUpdate.status.state !== "idle" ? ( + + ) : null; + } + return updateAvailable && latestVersion ? ( + + ) : null; +} diff --git a/packages/react/src/multiplayer/shell.tsx b/packages/react/src/multiplayer/shell.tsx index 420e43f44..a57da4d44 100644 --- a/packages/react/src/multiplayer/shell.tsx +++ b/packages/react/src/multiplayer/shell.tsx @@ -8,6 +8,7 @@ import { integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { Button } from "../components/button"; import { Skeleton } from "../components/skeleton"; +import { SidebarUpdateCard } from "../components/update-card"; import { DropdownMenu, DropdownMenuContent, @@ -362,6 +363,8 @@ function SidebarContent( + +
From 8f4e7aa6155a488216cead22455f495e2d059540 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:21:10 -0700 Subject: [PATCH 4/5] Upgrade affordance per deployment, not npm everywhere `npm i -g executor@latest` only upgrades the npm-installed CLI. Self-host and Cloudflare deploy by pulling/rebuilding the image or redeploying the Worker, so the npm command was misleading there (the same trap the desktop app avoids). Each host's vite build now injects a VITE_UPGRADE_HINT, and the sidebar card picks the right affordance: the copyable npm command for the CLI, a link to the host's upgrade guide for self-host/Cloudflare, the native restart for desktop, and nothing for managed cloud. --- apps/host-cloudflare/vite.config.ts | 3 + apps/host-selfhost/vite.config.ts | 3 + apps/local/vite.config.ts | 3 + e2e/src/update-card-render.ts | 14 +- packages/react/src/components/update-card.tsx | 167 +++++++++++------- 5 files changed, 127 insertions(+), 63 deletions(-) diff --git a/apps/host-cloudflare/vite.config.ts b/apps/host-cloudflare/vite.config.ts index e40d62e01..ef154e9e6 100644 --- a/apps/host-cloudflare/vite.config.ts +++ b/apps/host-cloudflare/vite.config.ts @@ -37,6 +37,9 @@ export default defineConfig({ }, define: { "import.meta.env.VITE_APP_VERSION": JSON.stringify("0.0.0-cloudflare"), + // Cloudflare upgrades by redeploying the Worker (wrangler deploy), not npm, + // so the update card links to the upgrade guide instead of a command. + "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("cloudflare"), "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"), "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "production"), }, diff --git a/apps/host-selfhost/vite.config.ts b/apps/host-selfhost/vite.config.ts index c89c5e935..0a3085940 100644 --- a/apps/host-selfhost/vite.config.ts +++ b/apps/host-selfhost/vite.config.ts @@ -141,6 +141,9 @@ export default defineConfig({ }, define: { "import.meta.env.VITE_APP_VERSION": JSON.stringify("0.0.0-selfhost"), + // Self-host upgrades by pulling/rebuilding the image (or git + rebuild), not + // npm, so the update card links to the upgrade guide instead of a command. + "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("selfhost"), "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"), "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), }, diff --git a/apps/local/vite.config.ts b/apps/local/vite.config.ts index c8798a73f..82788decf 100644 --- a/apps/local/vite.config.ts +++ b/apps/local/vite.config.ts @@ -217,6 +217,9 @@ export default defineConfig({ }, define: { "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION), + // The local app IS the npm-installed CLI, so its update card shows the npm + // upgrade command (the desktop bridge overrides this with a native action). + "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("npm"), "import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL), "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT), "import.meta.env.VITE_EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL": JSON.stringify( diff --git a/e2e/src/update-card-render.ts b/e2e/src/update-card-render.ts index 2bd066941..d47527572 100644 --- a/e2e/src/update-card-render.ts +++ b/e2e/src/update-card-render.ts @@ -40,10 +40,18 @@ export const registerUpdateCardRenderScenario = (name: string): void => await step("The sidebar surfaces the update-available card", async () => { await page.getByText("Update available").waitFor({ timeout: 30_000 }); await page.getByText(`v${FORCED_LATEST}`).waitFor({ timeout: 5_000 }); + // Self-host and Cloudflare upgrade via their own deploy (image pull / + // rebuild / redeploy), not npm: the card links to the host's upgrade + // guide and shows NO npm command. + const guide = page.getByRole("link", { name: "Upgrade guide" }); + await guide.waitFor({ timeout: 5_000 }); + expect(await guide.getAttribute("href"), "links to the hosted upgrade docs").toContain( + "/docs/hosted/", + ); expect( - await page.getByText("npm i -g executor@latest", { exact: true }).count(), - "the web card shows the npm upgrade command", - ).toBeGreaterThan(0); + await page.getByText("npm i -g", { exact: false }).count(), + "the self-host / Cloudflare card shows no npm command", + ).toBe(0); }); }); }), diff --git a/packages/react/src/components/update-card.tsx b/packages/react/src/components/update-card.tsx index 578db9398..1eb5e3fe1 100644 --- a/packages/react/src/components/update-card.tsx +++ b/packages/react/src/components/update-card.tsx @@ -1,14 +1,19 @@ // The sidebar "update available" card, shared by both shells (the local/desktop // single-user shell in @executor-js/app and the multiplayer shell here). One -// `` encapsulates the whole decision: +// `` encapsulates the whole decision, because the right way +// to upgrade depends on how this build was deployed: // -// - in the desktop app (the bridge is present): a native "Restart to update" -// action wired to electron-updater (see ../hooks/desktop-update), and -// - on web / CLI-served installs: the npm upgrade command, lit up when the -// `/v1/app/npm/dist-tags` endpoint reports a newer version for the channel. +// - desktop app (the bridge is present): a native "Restart to update" action +// wired to electron-updater (see ../hooks/desktop-update); +// - npm-installed CLI / its local web UI (`VITE_UPGRADE_HINT === "npm"`): the +// copyable `npm i -g executor@latest` command; +// - self-host and Cloudflare (`"selfhost"` / `"cloudflare"`): a link to that +// host's upgrade guide, because the steps (image pull, rebuild, redeploy) +// vary too much for one correct command; +// - managed cloud (`"managed"`): nothing, it deploys itself. // -// The semver verdict comes from @executor-js/api so the card and the CLI notice -// can never disagree. +// The "is a newer version published?" verdict comes from the same resolver as +// the CLI notice (@executor-js/api) so the two can never disagree. import { useCallback, useEffect, useState } from "react"; import { Effect, Exit } from "effect"; @@ -20,10 +25,25 @@ import { copyToClipboard } from "../lib/clipboard"; import { type DesktopUpdate, useDesktopUpdate } from "../hooks/desktop-update"; const EXECUTOR_DIST_TAGS_PATH = "/v1/app/npm/dist-tags"; +const DOCS_BASE_URL = "https://executor.sh/docs"; -const APP_VERSION = ( - import.meta as ImportMeta & { readonly env?: { readonly VITE_APP_VERSION?: string } } -).env?.VITE_APP_VERSION; +type UpgradeHint = "npm" | "selfhost" | "cloudflare" | "managed"; + +const appEnv = ( + import.meta as ImportMeta & { + readonly env?: { readonly VITE_APP_VERSION?: string; readonly VITE_UPGRADE_HINT?: string }; + } +).env; +const APP_VERSION = appEnv?.VITE_APP_VERSION; +const UPGRADE_HINT = appEnv?.VITE_UPGRADE_HINT as UpgradeHint | undefined; + +// Per-host upgrade guide. A host without an entry (or no hint at all) falls back +// to the docs root rather than a command, so a missing hint can never show a +// wrong upgrade step. +const UPGRADE_DOCS_URL: Partial> = { + selfhost: `${DOCS_BASE_URL}/hosted/docker`, + cloudflare: `${DOCS_BASE_URL}/hosted/cloudflare`, +}; // ── useLatestVersion ──────────────────────────────────────────────────── @@ -61,23 +81,11 @@ function useLatestVersion(currentVersion: string | undefined) { return { latestVersion, updateAvailable, channel }; } -// ── UpdateCard (web / CLI install: copyable npm command) ───────────────── - -function UpdateCard(props: { latestVersion: string; channel: UpdateChannel }) { - const command = `npm i -g executor@${props.channel}`; - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(() => { - void copyToClipboard(command).then((ok) => { - if (!ok) { - toast.error("Failed to copy to clipboard"); - return; - } - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - }, [command]); +// ── Card chrome ────────────────────────────────────────────────────────── +/** The bordered card with the download glyph + "Update available" + version. + * Each variant supplies its own action as children. */ +function UpdateCardShell(props: { version: string | null; children?: React.ReactNode }) { return (
@@ -95,9 +103,33 @@ function UpdateCard(props: { latestVersion: string; channel: UpdateChannel }) {

Update available

-

v{props.latestVersion}

+ {props.version &&

v{props.version}

}
+ {props.children} + + ); +} + +// ── NpmUpdateCard (npm-installed CLI: copyable command) ─────────────────── + +function NpmUpdateCard(props: { latestVersion: string; channel: UpdateChannel }) { + const command = `npm i -g executor@${props.channel}`; + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + void copyToClipboard(command).then((ok) => { + if (!ok) { + toast.error("Failed to copy to clipboard"); + return; + } + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }, [command]); + + return ( + - + ); } -// ── DesktopUpdateCard (desktop app: native restart action) ─────────────── +// ── LinkUpdateCard (self-host / Cloudflare: link to the upgrade guide) ──── + +function LinkUpdateCard(props: { latestVersion: string; href: string }) { + return ( + + + Upgrade guide + + + + + + ); +} + +// ── DesktopUpdateCard (desktop app: native restart action) ──────────────── function DesktopUpdateCard(props: { update: DesktopUpdate }) { const { status, install } = props.update; @@ -176,50 +234,39 @@ function DesktopUpdateCard(props: { update: DesktopUpdate }) { return null; })(); - return ( -
-
-
- - - - -
-
-

Update available

- {version &&

v{version}

} -
-
- {action} -
- ); + return {action}; } // ── SidebarUpdateCard (the only export the shells consume) ──────────────── /** - * The sidebar update card, or null when no update is available. Reads the - * build's `VITE_APP_VERSION` and the desktop bridge itself, so a shell just - * drops it in above its footer. Hook order is stable across renders. + * The sidebar update card, or null when no update is available (or the host + * manages its own updates). Reads `VITE_APP_VERSION` / `VITE_UPGRADE_HINT` and + * the desktop bridge itself, so a shell just drops it in above its footer. Hook + * order is stable across renders. */ export function SidebarUpdateCard(): React.ReactElement | null { const desktopUpdate = useDesktopUpdate(); const { latestVersion, updateAvailable, channel } = useLatestVersion(APP_VERSION); - // In the desktop app electron-updater owns updates, so show the native card - // and never the npm command. Web and CLI-served installs show the npm card. + // The desktop app updates via electron-updater, so its native card wins + // wherever the bridge is present (it loads the local app UI, whose hint is + // "npm" — the bridge overrides it). if (desktopUpdate) { return desktopUpdate.status.state !== "idle" ? ( ) : null; } - return updateAvailable && latestVersion ? ( - - ) : null; + + if (!updateAvailable || !latestVersion) return null; + // Managed cloud deploys itself: knowing a version exists is not actionable. + if (UPGRADE_HINT === "managed") return null; + // The npm-installed CLI is the only deployment a command actually upgrades. + if (UPGRADE_HINT === "npm") { + return ; + } + // Self-host / Cloudflare (and any host without an explicit hint): link to the + // upgrade guide rather than risk a wrong command. + const href = (UPGRADE_HINT && UPGRADE_DOCS_URL[UPGRADE_HINT]) ?? DOCS_BASE_URL; + return ; } From aab73a1df71d7ef3b920d9194c9a974f28eac313 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:26:48 -0700 Subject: [PATCH 5/5] Address review: fix update-status race, guard fake seam, negative-cache failures - desktop-update hook: an initial getUpdateStatus() snapshot could overwrite a newer push that arrived first; ignore the snapshot once a push has landed. - applyFakeUpdateFromEnv: bail when app.isPackaged so a stray env var can never seed a phantom update in a real build. - resolveDistTags: negative-cache empty/failed registry lookups for 60s so an offline server pays the fetch timeout once per window, not per request. --- apps/desktop/src/main/index.ts | 3 +++ packages/core/api/src/update-check.test.ts | 14 ++++++++++++++ packages/core/api/src/update-check.ts | 19 ++++++++++++++++--- packages/react/src/hooks/desktop-update.ts | 11 +++++++++-- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 4039985e2..526048df1 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -727,6 +727,9 @@ const setUpdateStatus = (status: DesktopUpdateStatus) => { // `{"state":"downloaded","version":"99.0.0"}`) seeds a status to exercise the // renderer card and the install handler without one. Ignored in production. const applyFakeUpdateFromEnv = () => { + // Never honor the fake seam in a packaged build: a stray env var would seed a + // phantom "downloaded" update whose install quits the app against nothing. + if (app.isPackaged) return; const raw = process.env.EXECUTOR_DESKTOP_FAKE_UPDATE?.trim(); if (!raw) return; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: dev-only env override is untrusted text; a malformed value is logged and ignored, not fatal diff --git a/packages/core/api/src/update-check.test.ts b/packages/core/api/src/update-check.test.ts index 24839238c..1ea1ba5a9 100644 --- a/packages/core/api/src/update-check.test.ts +++ b/packages/core/api/src/update-check.test.ts @@ -92,6 +92,20 @@ describe("resolveDistTags", () => { const tags = await resolveDistTags({ env: {}, fetchImpl: fetchThatFails() }); expect(tags).toEqual({}); }); + + it("negative-caches a failure so the next call skips the fetch", async () => { + __resetDistTagsCache(); + const failed = await resolveDistTags({ env: {}, fetchImpl: fetchThatFails() }); + expect(failed).toEqual({}); + // Within the negative TTL the cached empty result is reused, even though a + // working registry is now reachable (so an offline server pays the timeout + // once, not per request). + const cached = await resolveDistTags({ + env: {}, + fetchImpl: fetchReturning({ latest: "1.5.22" }), + }); + expect(cached).toEqual({}); + }); }); describe("checkForUpdate", () => { diff --git a/packages/core/api/src/update-check.ts b/packages/core/api/src/update-check.ts index 244d417ce..310d40dec 100644 --- a/packages/core/api/src/update-check.ts +++ b/packages/core/api/src/update-check.ts @@ -30,6 +30,11 @@ const NPM_DIST_TAGS_URL = `https://registry.npmjs.org/-/package/${EXECUTOR_PACKA /** How long a successful registry lookup is reused before refetching. */ const DIST_TAGS_TTL_MS = 10 * 60 * 1000; +/** How long an empty result (offline, timeout, no tags) is reused before + * retrying. Negative-caches failures so an air-gapped server does not pay the + * fetch timeout on every shell load, while still recovering quickly. */ +const EMPTY_TTL_MS = 60 * 1000; + /** How long to wait on the registry before giving up (a check, not a gate). */ const NPM_FETCH_TIMEOUT_MS = 1500; @@ -169,8 +174,12 @@ export const resolveDistTags = async (options?: ResolveDistTagsOptions): Promise if (override) return override; const now = Date.now(); - if (registryCache && now - registryCache.at < DIST_TAGS_TTL_MS) { - return registryCache.tags; + if (registryCache) { + // Empty results (failures, or a package with no tags) carry the shorter + // negative TTL so a transient outage recovers quickly; real tags stick. + const ttl = + registryCache.tags.latest || registryCache.tags.beta ? DIST_TAGS_TTL_MS : EMPTY_TTL_MS; + if (now - registryCache.at < ttl) return registryCache.tags; } const fetchImpl = options?.fetchImpl ?? globalThis.fetch; @@ -180,11 +189,15 @@ export const resolveDistTags = async (options?: ResolveDistTagsOptions): Promise headers: { accept: "application/json" }, signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS), }); - if (!res.ok) return {}; + if (!res.ok) { + registryCache = { at: now, tags: {} }; + return {}; + } const tags = pickTags(await res.json()); registryCache = { at: now, tags }; return tags; } catch { + registryCache = { at: now, tags: {} }; return {}; } }; diff --git a/packages/react/src/hooks/desktop-update.ts b/packages/react/src/hooks/desktop-update.ts index 4b6b61df0..776f535e9 100644 --- a/packages/react/src/hooks/desktop-update.ts +++ b/packages/react/src/hooks/desktop-update.ts @@ -54,10 +54,17 @@ export function useDesktopUpdate(): DesktopUpdate | null { useEffect(() => { if (!bridge) return; let active = true; + // The initial snapshot is an async IPC round-trip; a push (e.g. download + // finished) can land first. Never let the stale snapshot overwrite a push + // that already arrived. + let receivedPush = false; void bridge.getUpdateStatus().then((current) => { - if (active) setStatus(current); + if (active && !receivedPush) setStatus(current); + }); + const unsubscribe = bridge.onUpdateStatus((next) => { + receivedPush = true; + setStatus(next); }); - const unsubscribe = bridge.onUpdateStatus((next) => setStatus(next)); return () => { active = false; unsubscribe();