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 e237e1712..be8955166 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, @@ -1060,6 +1060,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 e9e1a56af..361388fe8 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 @@ -679,6 +685,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. @@ -725,6 +749,57 @@ 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 = () => { + // 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 + 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; @@ -764,8 +839,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) => { @@ -915,6 +1004,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-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-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/apps/host-selfhost/vite.config.ts b/apps/host-selfhost/vite.config.ts index 79d25b7e6..0a3085940 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. @@ -135,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/src/serve.ts b/apps/local/src/serve.ts index d6858f9dc..0ecdc609c 100644 --- a/apps/local/src/serve.ts +++ b/apps/local/src/serve.ts @@ -441,6 +441,15 @@ export async function startServer(opts: StartServerOptions = {}): 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/desktop/update-card.test.ts b/e2e/desktop/update-card.test.ts new file mode 100644 index 000000000..1ae63b21c --- /dev/null +++ b/e2e/desktop/update-card.test.ts @@ -0,0 +1,83 @@ +// Desktop-only: the desktop app updates via electron-updater (a new bundle, +// swapped in place), NOT `npm i -g`, so its sidebar must show a native +// "Restart to update" action instead of the copyable npm command the web/CLI +// shell shows. A packaged build is required for a real electron-updater +// release, so `EXECUTOR_DESKTOP_FAKE_UPDATE` seeds a "downloaded" status the +// main process pushes to the renderer over IPC (see apps/desktop/src/shared/ +// update.ts). Launches the REAL Electron app via Playwright against a throwaway +// HOME (same harness as the other desktop scenarios). +import { mkdtempSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +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 { _electron, type ElectronApplication } from "playwright"; + +import { scenario } from "../src/scenario"; +import { RunDir } from "../src/services"; + +const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); +const electronBinary = createRequire(join(appDir, "package.json"))("electron") as string; + +const FORCED_VERSION = "99.0.0"; + +const launchDesktop = (home: string): 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 0dd4bc733..1c4d95942 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-")); @@ -97,7 +102,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/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/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"); + }), +); diff --git a/e2e/src/update-card-render.ts b/e2e/src/update-card-render.ts new file mode 100644 index 000000000..d47527572 --- /dev/null +++ b/e2e/src/update-card-render.ts @@ -0,0 +1,58 @@ +// 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 }); + // 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", { exact: false }).count(), + "the self-host / Cloudflare card shows no npm command", + ).toBe(0); + }); + }); + }), + ); diff --git a/packages/app/src/web/shell.tsx b/packages/app/src/web/shell.tsx index cb0849781..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,12 +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 { SidebarUpdateCard } from "@executor-js/react/components/update-card"; import { ServerConnectionMenu } from "./server-connection-menu"; // ── Env ───────────────────────────────────────────────────────────────── @@ -32,183 +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}

-
-
- -
- ); -} - // ── NavItem ────────────────────────────────────────────────────────────── function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) { @@ -331,14 +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; -}) { +function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) { const isHome = props.pathname === "/"; const isSecrets = props.pathname === "/secrets"; const isPolicies = props.pathname === "/policies"; @@ -401,9 +214,7 @@ function SidebarContent(props: { - {props.updateAvailable && props.latestVersion && ( - - )} + {/* Footer */}
@@ -448,7 +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 lastPathname = useRef(pathname); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); if (lastPathname.current !== pathname) { @@ -488,12 +298,7 @@ export function Shell() { {/* Desktop sidebar */} {/* Mobile sidebar overlay */} @@ -537,9 +342,6 @@ export function Shell() { pathname={pathname} onNavigate={() => setMobileSidebarOpen(false)} showBrand={false} - updateAvailable={updateAvailable} - latestVersion={latestVersion} - channel={channel} />
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 56640d96e..96b8c1f7c 100644 --- a/packages/core/api/src/server/executor-app.ts +++ b/packages/core/api/src/server/executor-app.ts @@ -57,6 +57,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, @@ -570,6 +571,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..1ea1ba5a9 --- /dev/null +++ b/packages/core/api/src/update-check.test.ts @@ -0,0 +1,145 @@ +// --------------------------------------------------------------------------- +// 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({}); + }); + + 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", () => { + 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..310d40dec --- /dev/null +++ b/packages/core/api/src/update-check.ts @@ -0,0 +1,231 @@ +// --------------------------------------------------------------------------- +// 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 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; + +// ── 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) { + // 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; + // 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) { + registryCache = { at: now, tags: {} }; + return {}; + } + const tags = pickTags(await res.json()); + registryCache = { at: now, tags }; + return tags; + } catch { + registryCache = { at: now, tags: {} }; + 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/components/update-card.tsx b/packages/react/src/components/update-card.tsx new file mode 100644 index 000000000..1eb5e3fe1 --- /dev/null +++ b/packages/react/src/components/update-card.tsx @@ -0,0 +1,272 @@ +// 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, because the right way +// to upgrade depends on how this build was deployed: +// +// - 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 "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"; +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 DOCS_BASE_URL = "https://executor.sh/docs"; + +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 ──────────────────────────────────────────────────── + +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 }; +} + +// ── 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 ( +
+
+
+ + + + +
+
+

Update available

+ {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 ( + + + + ); +} + +// ── 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; + 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 {action}; +} + +// ── SidebarUpdateCard (the only export the shells consume) ──────────────── + +/** + * 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); + + // 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; + } + + 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 ; +} diff --git a/packages/react/src/hooks/desktop-update.ts b/packages/react/src/hooks/desktop-update.ts new file mode 100644 index 000000000..776f535e9 --- /dev/null +++ b/packages/react/src/hooks/desktop-update.ts @@ -0,0 +1,76 @@ +// 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; + // 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 && !receivedPush) setStatus(current); + }); + const unsubscribe = bridge.onUpdateStatus((next) => { + receivedPush = true; + setStatus(next); + }); + return () => { + active = false; + unsubscribe(); + }; + }, [bridge]); + + if (!bridge) return null; + return { status, install: () => void bridge.installUpdate() }; +} 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( + +