Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/update-available-notifications.md
Original file line number Diff line number Diff line change
@@ -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`.
13 changes: 12 additions & 1 deletion apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
90 changes: 90 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void> => {
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.
Expand Down Expand Up @@ -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;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
// 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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -67,6 +73,25 @@ const api = {
checkForUpdates(): Promise<void> {
return ipcRenderer.invoke("executor:updates:check");
},
/** Read the current auto-update status once (renderer mount). */
getUpdateStatus(): Promise<DesktopUpdateStatus> {
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<void> {
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
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/shared/update.ts
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions apps/host-cloudflare/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
Expand Down
7 changes: 4 additions & 3 deletions apps/host-cloudflare/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions apps/host-selfhost/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"),
},
Expand Down
9 changes: 9 additions & 0 deletions apps/local/src/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,15 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server
return withCors(await handlers.api.handler(new Request(url, req)));
}

// App-level routes the Effect app serves at root, OUTSIDE the `/api`
// prefix the shell strips (e.g. `/v1/app/npm/dist-tags`, which the web
// shell's update check fetches). Public, like `/api/health` above, so no
// bearer gate. Forward verbatim; without this they'd fall through to the
// vite/SPA fallback and answer 200-with-HTML, breaking the JSON parse.
if (url.pathname === "/v1" || url.pathname.startsWith("/v1/")) {
return withCors(await handlers.api.handler(new Request(url, req)));
}

// Dev mode: forward everything else (SPA + hashed assets) to the
// vite child so source edits show up without a rebuild.
if (viteChild) {
Expand Down
15 changes: 13 additions & 2 deletions apps/local/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,12 @@ function executorApiPlugin(): Plugin {
const pathOnly = rawUrl.split("?")[0] ?? "/";
const isApi = pathOnly.startsWith("/api/") || pathOnly === "/api";
const isMcp = pathOnly === "/mcp" || pathOnly.startsWith("/mcp/");
// App-level routes the Effect app serves at root, outside the `/api`
// prefix (e.g. `/v1/app/npm/dist-tags`, the update-check the web shell
// fetches). Public, like `/api/health` below.
const isV1 = pathOnly === "/v1" || pathOnly.startsWith("/v1/");

if (!isApi && !isMcp) return next();
if (!isApi && !isMcp && !isV1) return next();

// Gate parity with the production Bun shell (serve.ts): the vite server
// is reachable by any local process, so /api and /mcp require the bearer
Expand All @@ -111,7 +115,8 @@ function executorApiPlugin(): Plugin {
// callback. The SPA carries the token from its `?_token`/localStorage
// bootstrap, so the UI is unaffected; external MCP clients use the
// daemon port.
const authExempt = pathOnly === "/api/health" || isUnauthenticatedOAuthPath(pathOnly);
const authExempt =
pathOnly === "/api/health" || isV1 || isUnauthenticatedOAuthPath(pathOnly);
if (!authExempt) {
const presented = req.headers.authorization;
const authValue = Array.isArray(presented) ? presented[0] : presented;
Expand Down Expand Up @@ -154,6 +159,9 @@ function executorApiPlugin(): Plugin {
response = oauthClientMetadataResponse(rawUrl, webRequest(rawUrl));
} else if (isMcp) {
response = await handlers.mcp.handleRequest(webRequest(rawUrl));
} else if (isV1) {
// Served at root by the Effect app; forward verbatim (no /api strip).
response = await handlers.api.handler(webRequest(rawUrl));
} else if (pathOnly === "/api/health" && req.method === "GET") {
response = new Response("ok", {
headers: { "content-type": "text/plain" },
Expand Down Expand Up @@ -209,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(
Expand Down
8 changes: 8 additions & 0 deletions e2e/cloudflare/update-card-render.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
28 changes: 28 additions & 0 deletions e2e/cloudflare/update-endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
expect(typeof body, "the body is a JSON object of dist-tags").toBe("object");
}),
);
Loading
Loading