From 314f1969d16f1d3c4401fbdf075a566bc216c695 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 22 May 2026 14:00:21 -0700 Subject: [PATCH 1/7] fix(app): suppress update toast in desktop Skip the npx bb-app rerun update toast when the renderer is running inside bb desktop. Add regression coverage so desktop update state does not trigger the server-update toast path. Source: bb/don-t-show-npx-rerun-server-toast-inside-desktop-thr_k7ggqdj9v3 (thr_k7ggqdj9v3). --- .../hooks/useUpdateAvailableToast.test.tsx | 28 +++++++++++++++++++ apps/app/src/hooks/useUpdateAvailableToast.ts | 3 ++ 2 files changed, 31 insertions(+) diff --git a/apps/app/src/hooks/useUpdateAvailableToast.test.tsx b/apps/app/src/hooks/useUpdateAvailableToast.test.tsx index 4d2afe16..ade691f8 100644 --- a/apps/app/src/hooks/useUpdateAvailableToast.test.tsx +++ b/apps/app/src/hooks/useUpdateAvailableToast.test.tsx @@ -130,6 +130,7 @@ afterEach(() => { describe("useUpdateAvailableToast", () => { beforeEach(() => { window.localStorage.clear(); + delete window.bbDesktop; toastFn.mockReset(); toastDismissFn.mockReset(); }); @@ -155,6 +156,33 @@ describe("useUpdateAvailableToast", () => { expect(invocation.options.action.label).toBe("Dismiss"); }); + it("does not show the toast inside the bb desktop app", async () => { + const desktopStub = createDesktopApiStub({ + lastCheckedAt: "2026-05-21T00:00:00.000Z", + latestVersion: "0.0.2", + pendingVersion: null, + platform: "macos", + updateAvailable: true, + updateDownloaded: false, + version: "0.0.1", + }); + window.bbDesktop = desktopStub.api; + stubFetchOnce({ + currentVersion: "0.0.5", + latestVersion: "0.0.6", + source: "npm", + updateAvailable: true, + isDevelopment: false, + upgradeCommand: "npx bb-app@latest", + }); + const { useUpdateAvailableToast } = await loadHook(); + const { wrapper } = createQueryClientTestHarness(); + renderHook(() => useUpdateAvailableToast(), { wrapper }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(toastFn).not.toHaveBeenCalled(); + }); + it("never shows the toast in development mode", async () => { stubFetchOnce({ currentVersion: "0.0.0-dev", diff --git a/apps/app/src/hooks/useUpdateAvailableToast.ts b/apps/app/src/hooks/useUpdateAvailableToast.ts index e173910c..5091eeaa 100644 --- a/apps/app/src/hooks/useUpdateAvailableToast.ts +++ b/apps/app/src/hooks/useUpdateAvailableToast.ts @@ -90,6 +90,9 @@ export function useUpdateAvailableToast(): void { if (!data) { return; } + if (getBbDesktopInfo() !== null) { + return; + } if (data.isDevelopment) { return; } From ef03b32ed778f0297755276ed2b635aae3701613 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 22 May 2026 14:23:43 -0700 Subject: [PATCH 2/7] fix(desktop): exclude source maps from packaged app Exclude generated source maps from electron-builder packaged desktop files. Add config and build tests so map files remain produced for local builds but omitted from app packaging. Source: bb/electron-desktop-strip-production-source-maps-fr-thr_rxa6kx5ces (thr_rxa6kx5ces). --- apps/desktop/electron-builder.config.json | 8 +++++++- apps/desktop/test/electron-builder-config.test.ts | 11 +++++++++++ apps/desktop/test/preload-build.test.ts | 14 +++++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron-builder.config.json b/apps/desktop/electron-builder.config.json index 03dae971..bdc91b0a 100644 --- a/apps/desktop/electron-builder.config.json +++ b/apps/desktop/electron-builder.config.json @@ -9,7 +9,13 @@ "buildResources": "assets", "output": "release" }, - "files": ["assets/**", "dist/**", "node_modules/**", "package.json"], + "files": [ + "assets/**", + "dist/**", + "node_modules/**", + "package.json", + "!**/*.map" + ], "publish": [ { "channel": "latest", diff --git a/apps/desktop/test/electron-builder-config.test.ts b/apps/desktop/test/electron-builder-config.test.ts index 702615d8..d74e1227 100644 --- a/apps/desktop/test/electron-builder-config.test.ts +++ b/apps/desktop/test/electron-builder-config.test.ts @@ -37,6 +37,7 @@ const electronBuilderConfigSchema = z sign: z.boolean(), }) .passthrough(), + files: z.array(z.string().min(1)), mac: macConfigSchema, publish: z.tuple([ z @@ -213,6 +214,16 @@ describe("electron-builder signing config", () => { ).resolves.toBeUndefined(); }); + it("excludes source maps from packaged app files", async () => { + const configText = await readFile( + resolve(desktopPackageRoot, "electron-builder.config.json"), + "utf8", + ); + const config = electronBuilderConfigSchema.parse(JSON.parse(configText)); + + expect(config.files).toContain("!**/*.map"); + }); + it("patches packaged node-pty helper path handling", async () => { const appOutDir = await mkdtemp( resolve(tmpdir(), "bb-desktop-native-modules-"), diff --git a/apps/desktop/test/preload-build.test.ts b/apps/desktop/test/preload-build.test.ts index e968c6b6..35fc7082 100644 --- a/apps/desktop/test/preload-build.test.ts +++ b/apps/desktop/test/preload-build.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { access, readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { promisify } from "node:util"; import { z } from "zod"; @@ -52,5 +52,17 @@ describe("desktop build", () => { expect(preloadSource).not.toContain("BB_DESKTOP_VERSION"); expect(preloadSource).not.toContain("getDesktopVersion(process.env"); expect(bridgeSource).toContain('import "bb-app/dist/bb-app.js"'); + await expect( + access(resolve(desktopPackageRoot, "dist", "main.js.map")), + ).resolves.toBeUndefined(); + await expect( + access(resolve(desktopPackageRoot, "dist", "preload.cjs.map")), + ).resolves.toBeUndefined(); + await expect( + access(resolve(desktopPackageRoot, "dist", "log-viewer-preload.cjs.map")), + ).resolves.toBeUndefined(); + await expect( + access(resolve(desktopPackageRoot, "dist", "bb-app-bridge.mjs.map")), + ).resolves.toBeUndefined(); }); }); From cf9d38d03e2f4407518a816e3400fe524e47637a Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 22 May 2026 14:32:09 -0700 Subject: [PATCH 3/7] desktop: build arm64-only mac artifacts Switch desktop packaging and CI from universal macOS builds to Apple Silicon arm64 artifacts. Pin the workflow to macos-15 with an arm64 runner check and rename uploaded artifacts accordingly. Update electron-builder targets and desktop docs to reflect arm64-only macOS builds. Source: bb/electron-desktop-arm64-only-build-drop-universal-thr_n4fiu5rihf (thr_n4fiu5rihf). --- .github/workflows/build-desktop.yml | 13 ++++++++----- apps/desktop/README.md | 8 ++++---- apps/desktop/electron-builder.config.json | 13 ++++++++++--- apps/desktop/package.json | 4 ++-- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 81be337e..f3ffab4a 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -31,8 +31,8 @@ concurrency: jobs: macos: - name: macOS desktop artifacts - runs-on: macos-latest + name: macOS arm64 desktop artifacts + runs-on: macos-15 timeout-minutes: 45 steps: @@ -51,6 +51,9 @@ jobs: node-version: ${{ env.NODE_VERSION }} cache: pnpm + - name: Verify arm64 runner + run: test "$(uname -m)" = "arm64" + - name: Install dependencies run: pnpm install --frozen-lockfile --prefer-offline @@ -100,7 +103,7 @@ jobs: exit 1 fi - - name: Package universal desktop artifacts + - name: Package arm64 desktop artifacts env: CSC_LINK: ${{ secrets.MACOS_CERTIFICATE_P12 }} CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} @@ -160,7 +163,7 @@ jobs: - name: Upload desktop workflow artifacts uses: actions/upload-artifact@v4 with: - name: bb-desktop-macos-universal + name: bb-desktop-macos-arm64 path: | apps/desktop/release/*.dmg apps/desktop/release/*.zip @@ -244,7 +247,7 @@ jobs: { echo "## bb desktop artifacts" echo - echo "- universal macOS .dmg, .zip, latest-mac.yml, and desktop-version.json uploaded as workflow artifacts" + echo "- arm64 macOS .dmg, .zip, latest-mac.yml, and desktop-version.json uploaded as workflow artifacts" echo "- version: ${VERSION}" echo "- prerelease version: ${IS_PRERELEASE}" echo "- stable release publication requested and allowed: ${SHOULD_PUBLISH}" diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6c8129e8..6395b0d4 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -34,9 +34,10 @@ pnpm exec turbo run dev --filter=@bb/desktop pnpm exec turbo run desktop:build --filter=@bb/desktop ``` -Artifacts are written under `apps/desktop/release/`. Without signing secrets, -local and CI builds remain unsigned and macOS shows the normal Gatekeeper warning -on first launch. +Artifacts are written under `apps/desktop/release/`. The desktop build is +macOS-only and Apple Silicon arm64-only. Without signing secrets, local and CI +builds remain unsigned and macOS shows the normal Gatekeeper warning on first +launch. ## Releasing @@ -102,7 +103,6 @@ To verify a downloaded or unpacked build: spctl --assess --verbose /path/to/bb.app codesign --verify --deep --strict --verbose=2 /path/to/bb.app ``` - ## Debugging The Turbo dev task opens DevTools automatically. For a packaged app, run the diff --git a/apps/desktop/electron-builder.config.json b/apps/desktop/electron-builder.config.json index bdc91b0a..c09d9bff 100644 --- a/apps/desktop/electron-builder.config.json +++ b/apps/desktop/electron-builder.config.json @@ -31,10 +31,17 @@ "hardenedRuntime": true, "icon": "assets/icon.icns", "identity": null, - "mergeASARs": false, "notarize": false, - "x64ArchFiles": "**/*darwin-*/**/*", - "target": ["dmg", "zip"] + "target": [ + { + "target": "dmg", + "arch": ["arm64"] + }, + { + "target": "zip", + "arch": ["arm64"] + } + ] }, "dmg": { "sign": false diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b5fe9f0b..848769b3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -10,11 +10,11 @@ "scripts": { "build": "node scripts/build.mjs", "clean": "rimraf dist release", - "desktop:build": "pnpm run build && node scripts/run-electron-builder.mjs --mac --universal --publish always", + "desktop:build": "pnpm run build && node scripts/run-electron-builder.mjs --mac --arm64 --publish always", "desktop:version-feed": "tsx scripts/generate-version-feed.mts", "dev": "pnpm run package && node scripts/run-packaged-app.mjs", "dist": "pnpm run prepare-runtime && pnpm run desktop:build", - "package": "pnpm run prepare-runtime && pnpm run build && node scripts/run-electron-builder.mjs --mac --dir", + "package": "pnpm run prepare-runtime && pnpm run build && node scripts/run-electron-builder.mjs --mac --dir --arm64", "prepare-runtime": "pnpm exec turbo run build --filter=bb-app --output-logs=new-only", "start": "electron .", "test": "vitest run --config vitest.config.ts", From f121157c08f11ff32d2e62bcab58f744aa805db1 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 22 May 2026 15:03:38 -0700 Subject: [PATCH 4/7] app+host-daemon: add provider CLI health toasts Detect missing or outdated Codex and Claude Code CLIs through the host-daemon local API. Surface dismissible app toasts with install/update actions and progress dialog output. Use provider CLI self-update commands and cover contract, local API, and UI behavior. Source: bb/toast-for-codex-claude-code-missing-or-outdated-thr_xpi6p9ywmg (thr_xpi6p9ywmg). --- apps/app/src/App.tsx | 2 + .../provider-cli/ProviderCliHealthToasts.tsx | 476 ++++++++++ apps/app/src/hooks/queries/query-keys.ts | 17 + apps/app/src/hooks/queries/system-queries.ts | 52 +- apps/app/src/lib/api-host-daemon.test.ts | 73 ++ apps/app/src/lib/api-host-daemon.ts | 104 +++ apps/app/src/lib/api.ts | 5 + apps/host-daemon/package.json | 5 +- apps/host-daemon/src/local-api.ts | 52 +- .../src/provider-cli-health.test.ts | 607 +++++++++++++ apps/host-daemon/src/provider-cli-health.ts | 841 ++++++++++++++++++ packages/host-daemon-contract/src/index.ts | 33 + packages/host-daemon-contract/src/local.ts | 149 ++++ .../host-daemon-contract/test/local.test.ts | 101 +++ pnpm-lock.yaml | 9 + 15 files changed, 2521 insertions(+), 5 deletions(-) create mode 100644 apps/app/src/components/provider-cli/ProviderCliHealthToasts.tsx create mode 100644 apps/host-daemon/src/provider-cli-health.test.ts create mode 100644 apps/host-daemon/src/provider-cli-health.ts diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 979306b9..3d40114a 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -6,6 +6,7 @@ import { MainView } from "./views/MainView"; import { ProjectMainView } from "./views/ProjectMainView"; import { NewManagerDialogProvider } from "./hooks/useNewManagerDialog"; import { QuickCreateProjectProvider } from "./hooks/useQuickCreateProject"; +import { ProviderCliHealthToasts } from "./components/provider-cli/ProviderCliHealthToasts"; import { useDesktopUpdateAvailableToast, useUpdateAvailableToast, @@ -93,6 +94,7 @@ export function App() { return ( + ; + +interface ProviderCliStatusEntry { + provider: ProviderCliKey; + status: ProviderCliStatus; +} + +interface ProviderCliToastIssue { + provider: ProviderCliKey; + status: ProviderCliStatus; + action: ProviderCliStatus["installAction"]; + title: string; + description: string; + fingerprint: string; + toastId: string; +} + +interface ProviderCliInstallState { + provider: ProviderCliKey; + displayName: string; + actionLabel: ProviderCliActionLabel; + command: string; + status: ProviderCliInstallStatus; + log: string; + errorMessage: string | null; +} + +interface ProviderCliInstallDialogProps { + state: ProviderCliInstallState | null; + onClose: () => void; +} + +const DISMISSED_STORAGE_KEY_PREFIX = "bb:provider-cli-health:dismissed:"; + +function getLocalStorage(): Storage | null { + if (typeof window === "undefined") { + return null; + } + try { + return window.localStorage; + } catch { + return null; + } +} + +function dismissedStorageKey(fingerprint: string): string { + return `${DISMISSED_STORAGE_KEY_PREFIX}${fingerprint}`; +} + +function isDismissedForFingerprint(fingerprint: string): boolean { + const storage = getLocalStorage(); + if (storage === null) { + return false; + } + try { + return storage.getItem(dismissedStorageKey(fingerprint)) === "true"; + } catch { + return false; + } +} + +function markDismissedForFingerprint(fingerprint: string): void { + const storage = getLocalStorage(); + if (storage === null) { + return; + } + try { + storage.setItem(dismissedStorageKey(fingerprint), "true"); + } catch { + // The in-memory dismissal set still keeps the toast hidden this session. + } +} + +function providerCliEntries( + status: ProviderCliStatusResponse, +): ProviderCliStatusEntry[] { + return [ + { provider: "codex", status: status.codex }, + { provider: "claudeCode", status: status.claudeCode }, + ]; +} + +function buildProviderCliIssue( + entry: ProviderCliStatusEntry, +): ProviderCliToastIssue | null { + const { provider, status } = entry; + const toastId = `provider-cli-health:${provider}`; + if (!status.installed) { + return { + provider, + status, + action: status.installAction, + title: `${status.displayName} is not installed`, + description: `Install the latest ${status.displayName} CLI with npm so bb can start ${status.executableName} sessions.`, + fingerprint: `${provider}:missing:${status.latestVersion ?? "latest"}`, + toastId, + }; + } + + if (status.needsUpdate) { + if (status.installAction === null) { + return null; + } + const description = `${status.currentVersion ?? "Installed version unknown"} -> ${status.latestVersion ?? "latest"}`; + const fingerprint = [ + provider, + "outdated", + status.installSource, + status.currentVersion ?? "unknown", + status.latestVersion ?? "unknown", + status.executablePath ?? status.executableName, + ].join(":"); + return { + provider, + status, + action: status.installAction, + title: `${status.displayName} update available`, + description, + fingerprint, + toastId, + }; + } + + return null; +} + +function appendInstallLog(log: string, text: string): string { + if (text.length === 0) { + return log; + } + return `${log}${text}`; +} + +function exitDescription(event: ProviderCliInstallCompletedEvent): string { + if (event.exitCode !== null) { + return `Command exited with code ${event.exitCode}`; + } + return `Command exited after signal ${event.signal ?? "unknown"}`; +} + +function applyInstallEvent( + state: ProviderCliInstallState, + event: ProviderCliInstallEvent, +): ProviderCliInstallState { + if (state.provider !== event.provider) { + return state; + } + + switch (event.type) { + case "started": + return { + ...state, + command: event.command, + log: `$ ${event.command}\n`, + }; + case "output": + return { + ...state, + log: appendInstallLog(state.log, event.text), + }; + case "completed": + return { + ...state, + status: event.success ? "succeeded" : "failed", + errorMessage: event.success ? null : exitDescription(event), + }; + case "error": + return { + ...state, + status: "failed", + errorMessage: event.message, + log: appendInstallLog(state.log, `\n${event.message}\n`), + }; + } +} + +function installDialogDescription( + state: ProviderCliInstallState | null, +): string { + if (state === null) { + return "Provider CLI setup"; + } + if (state.status === "running") { + return `Running ${state.command}. Keep bb open until the command finishes.`; + } + if (state.status === "succeeded") { + return `${state.command} completed successfully.`; + } + return `${state.command} failed. Review or copy the log below.`; +} + +function ProviderCliInstallDialog({ + state, + onClose, +}: ProviderCliInstallDialogProps) { + const open = state !== null; + const isRunning = state?.status === "running"; + const statusLabel = + state?.status === "running" + ? "Running" + : state?.status === "succeeded" + ? "Complete" + : "Failed"; + const statusIcon: IconName = + state?.status === "succeeded" + ? "Check" + : state?.status === "failed" + ? "AlertCircle" + : "Spinner"; + + return ( + { + if (nextOpen || isRunning) { + return; + } + onClose(); + }} + > + + + + {state ? `${state.actionLabel} ${state.displayName}` : "CLI setup"} + + + {installDialogDescription(state)} + + + +
+
+ + {statusLabel} +
+ {state?.log ? ( + + ) : null} +
+ +
+          {state?.log || "Waiting for install output..."}
+        
+ + {state?.errorMessage ? ( +
+ +

{state.errorMessage}

+
+ ) : null} + + + + +
+
+ ); +} + +export function ProviderCliHealthToasts() { + const systemConfig = useSystemConfig(); + const daemonPort = systemConfig.data?.hostDaemonPort ?? null; + const providerCliStatus = useLocalProviderCliStatus({ + daemonPort, + enabled: daemonPort !== null, + }); + const dismissedFingerprintsRef = useRef>(new Set()); + const shownFingerprintsRef = useRef>(new Set()); + const runningProviderRef = useRef(null); + const [installState, setInstallState] = + useState(null); + + const markIssueDismissed = useCallback((issue: ProviderCliToastIssue) => { + dismissedFingerprintsRef.current.add(issue.fingerprint); + markDismissedForFingerprint(issue.fingerprint); + }, []); + + const dismissIssue = useCallback( + (issue: ProviderCliToastIssue) => { + markIssueDismissed(issue); + toast.dismiss(issue.toastId); + }, + [markIssueDismissed], + ); + + const startInstall = useCallback( + (issue: ProviderCliToastIssue) => { + if (issue.action === null) { + return; + } + if (daemonPort === null) { + toast.error("Host daemon unavailable", { + description: "Start bb again and retry the provider CLI setup.", + }); + return; + } + if (runningProviderRef.current !== null) { + toast.error("Provider CLI setup already running", { + description: "Wait for the current install or update to finish.", + }); + return; + } + + runningProviderRef.current = issue.provider; + toast.dismiss(issue.toastId); + setInstallState({ + provider: issue.provider, + displayName: issue.status.displayName, + actionLabel: issue.action.label, + command: issue.action.command, + status: "running", + log: `$ ${issue.action.command}\n`, + errorMessage: null, + }); + + let installSucceeded = false; + void installProviderCli({ + port: daemonPort, + request: { + provider: issue.provider, + actionKind: issue.action.kind, + }, + onEvent: (event) => { + if (event.type === "completed") { + installSucceeded = event.success; + } + setInstallState((current) => + current ? applyInstallEvent(current, event) : current, + ); + }, + }) + .then(() => { + if (!installSucceeded) { + return; + } + toast.success(`${issue.status.displayName} is up to date`); + void providerCliStatus.refetch(); + }) + .catch((error) => { + const message = + error instanceof Error ? error.message : String(error); + setInstallState((current) => + current + ? { + ...current, + status: "failed", + errorMessage: message, + log: appendInstallLog(current.log, `\n${message}\n`), + } + : current, + ); + }) + .finally(() => { + if (runningProviderRef.current === issue.provider) { + runningProviderRef.current = null; + } + }); + }, + [daemonPort, providerCliStatus], + ); + + useEffect(() => { + const data = providerCliStatus.data; + if (!data) { + return; + } + + for (const entry of providerCliEntries(data)) { + const issue = buildProviderCliIssue(entry); + if (!issue) { + continue; + } + if ( + dismissedFingerprintsRef.current.has(issue.fingerprint) || + isDismissedForFingerprint(issue.fingerprint) + ) { + dismissedFingerprintsRef.current.add(issue.fingerprint); + continue; + } + if (shownFingerprintsRef.current.has(issue.fingerprint)) { + continue; + } + + shownFingerprintsRef.current.add(issue.fingerprint); + if (issue.action !== null) { + toast.warning(issue.title, { + id: issue.toastId, + description: issue.description, + duration: Infinity, + closeButton: true, + action: { + label: issue.action.label, + onClick: () => startInstall(issue), + }, + cancel: { + label: "Dismiss", + onClick: () => dismissIssue(issue), + }, + onDismiss: () => { + markIssueDismissed(issue); + }, + }); + } else { + toast.warning(issue.title, { + id: issue.toastId, + description: issue.description, + duration: Infinity, + closeButton: true, + cancel: { + label: "Dismiss", + onClick: () => dismissIssue(issue), + }, + onDismiss: () => { + markIssueDismissed(issue); + }, + }); + } + } + }, [dismissIssue, markIssueDismissed, providerCliStatus.data, startInstall]); + + return ( + setInstallState(null)} + /> + ); +} diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 46a2411d..d43f7d95 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -45,8 +45,10 @@ export const ENVIRONMENT_DIFF_FILE_QUERY_KEY = "environmentDiffFile"; export const ENVIRONMENT_FILE_PREVIEW_QUERY_KEY = "environmentFilePreview"; export const THREAD_TIMELINE_QUERY_KEY = "threadTimeline"; export const SYSTEM_PROVIDERS_QUERY_KEY = "systemProviders"; +export const SYSTEM_CONFIG_QUERY_KEY = "systemConfig"; export const SYSTEM_EXECUTION_OPTIONS_QUERY_KEY = "systemExecutionOptions"; export const SYSTEM_VERSION_QUERY_KEY = "systemVersion"; +export const LOCAL_PROVIDER_CLI_STATUS_QUERY_KEY = "localProviderCliStatus"; export const MANAGER_TEMPLATES_QUERY_KEY = "managerTemplates"; export const LOCAL_PATH_EXISTENCE_QUERY_KEY = "localPathExistence"; export const REPLAY_CAPTURES_QUERY_KEY = "internalReplayCaptures"; @@ -336,7 +338,12 @@ export type EnvironmentFilePreviewQueryKeyPrefix = readonly [ export type SystemProvidersQueryKey = readonly [ typeof SYSTEM_PROVIDERS_QUERY_KEY, ]; +export type SystemConfigQueryKey = readonly [typeof SYSTEM_CONFIG_QUERY_KEY]; export type SystemVersionQueryKey = readonly [typeof SYSTEM_VERSION_QUERY_KEY]; +export type LocalProviderCliStatusQueryKey = readonly [ + typeof LOCAL_PROVIDER_CLI_STATUS_QUERY_KEY, + number | null, +]; export type ManagerTemplatesQueryKey = readonly [ typeof MANAGER_TEMPLATES_QUERY_KEY, string | null, @@ -805,10 +812,20 @@ export function systemProvidersQueryKey(): SystemProvidersQueryKey { return [SYSTEM_PROVIDERS_QUERY_KEY]; } +export function systemConfigQueryKey(): SystemConfigQueryKey { + return [SYSTEM_CONFIG_QUERY_KEY]; +} + export function systemVersionQueryKey(): SystemVersionQueryKey { return [SYSTEM_VERSION_QUERY_KEY]; } +export function localProviderCliStatusQueryKey( + daemonPort: number | null, +): LocalProviderCliStatusQueryKey { + return [LOCAL_PROVIDER_CLI_STATUS_QUERY_KEY, daemonPort]; +} + export function managerTemplatesQueryKey( hostId: string | null, ): ManagerTemplatesQueryKey { diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts index 6179a177..eb4e8e89 100644 --- a/apps/app/src/hooks/queries/system-queries.ts +++ b/apps/app/src/hooks/queries/system-queries.ts @@ -2,16 +2,21 @@ import { useQuery } from "@tanstack/react-query"; import type { Host } from "@bb/domain"; import type { ManagerTemplatesResponse, + SystemConfigResponse, SystemExecutionOptionsResponse, SystemProviderInfo, SystemVersionResponse, } from "@bb/server-contract"; +import type { ProviderCliStatusResponse } from "@bb/host-daemon-contract"; import * as api from "@/lib/api"; +import { fetchProviderCliStatus } from "@/lib/api-host-daemon"; import { type HostQueryId, hostQueryKey, hostsQueryKey, + localProviderCliStatusQueryKey, managerTemplatesQueryKey, + systemConfigQueryKey, systemExecutionOptionsQueryKey, systemProvidersQueryKey, systemVersionQueryKey, @@ -35,6 +40,18 @@ function requireQueryId(id: HostQueryId, hookName: string): string { return id; } +function requireDaemonPort( + daemonPort: number | null, + hookName: string, +): number { + if (daemonPort === null) { + throw new Error( + `${hookName}: daemonPort is required when query is enabled`, + ); + } + return daemonPort; +} + export function useHosts(options?: QueryOptions) { return useQuery({ queryKey: hostsQueryKey(), @@ -80,6 +97,15 @@ export function useSystemProviders(options?: QueryOptions) { }); } +export function useSystemConfig(options?: QueryOptions) { + return useQuery({ + queryKey: systemConfigQueryKey(), + queryFn: () => api.getSystemConfig(), + enabled: options?.enabled ?? true, + staleTime: 60_000, + }); +} + export interface UseManagerTemplatesArgs { hostId?: string | null; enabled?: boolean; @@ -89,8 +115,7 @@ export function useManagerTemplates(args: UseManagerTemplatesArgs = {}) { const hostId = args.hostId ?? null; return useQuery({ queryKey: managerTemplatesQueryKey(hostId), - queryFn: () => - api.listManagerTemplates(hostId ? { hostId } : {}), + queryFn: () => api.listManagerTemplates(hostId ? { hostId } : {}), enabled: args.enabled ?? true, staleTime: 30_000, }); @@ -108,3 +133,26 @@ export function useSystemVersion(options?: QueryOptions) { staleTime: SYSTEM_VERSION_STALE_TIME_MS, }); } + +export interface UseLocalProviderCliStatusArgs { + daemonPort: number | null; + enabled?: boolean; +} + +export function useLocalProviderCliStatus({ + daemonPort, + enabled, +}: UseLocalProviderCliStatusArgs) { + return useQuery({ + queryKey: localProviderCliStatusQueryKey(daemonPort), + queryFn: () => + fetchProviderCliStatus( + requireDaemonPort(daemonPort, "useLocalProviderCliStatus"), + ), + enabled: (enabled ?? true) && daemonPort !== null, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + staleTime: Infinity, + }); +} diff --git a/apps/app/src/lib/api-host-daemon.test.ts b/apps/app/src/lib/api-host-daemon.test.ts index 8b679d01..36426cf4 100644 --- a/apps/app/src/lib/api-host-daemon.test.ts +++ b/apps/app/src/lib/api-host-daemon.test.ts @@ -4,6 +4,8 @@ import { DEFAULT_HOST_DAEMON_LOCAL_BIND_HOST, HOST_DAEMON_PROTOCOL_VERSION, openInTargetRequestSchema, + providerCliInstallRequestSchema, + type ProviderCliInstallEvent, type WorkspaceOpenTarget, } from "@bb/host-daemon-contract"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -16,6 +18,23 @@ async function importFreshApiHostDaemon(): Promise< return import("./api-host-daemon"); } +function ndjsonResponse(chunks: string[]): Response { + const encoder = new TextEncoder(); + return new Response( + new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }), + { + headers: { "content-type": "application/x-ndjson" }, + }, + ); +} + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -237,6 +256,60 @@ describe("api-host-daemon", () => { await expect(fetchWorkspaceOpenTargets(3002)).rejects.toThrow(); }); + it("streams provider CLI install events from split NDJSON chunks", async () => { + const requests: Array< + ReturnType + > = []; + const events: ProviderCliInstallEvent[] = []; + installFetchRoutes([ + { + method: "POST", + pathname: "/provider-clis/install", + port: 3002, + handler: async (request) => { + requests.push( + providerCliInstallRequestSchema.parse(await request.json()), + ); + return ndjsonResponse([ + '{"type":"started","provider":"codex","command":"npm install -g @openai/codex@latest"}\n{"type":"out', + 'put","provider":"codex","stream":"stderr","text":"permission denied\\n"}\n', + '{"type":"completed","provider":"codex","exitCode":1,"signal":null,"success":false}\n', + ]); + }, + }, + ]); + + const { installProviderCli } = await importFreshApiHostDaemon(); + + await installProviderCli({ + port: 3002, + request: { provider: "codex", actionKind: "update" }, + onEvent: (event) => events.push(event), + }); + + expect(requests).toEqual([{ provider: "codex", actionKind: "update" }]); + expect(events).toEqual([ + { + type: "started", + provider: "codex", + command: "npm install -g @openai/codex@latest", + }, + { + type: "output", + provider: "codex", + stream: "stderr", + text: "permission denied\n", + }, + { + type: "completed", + provider: "codex", + exitCode: 1, + signal: null, + success: false, + }, + ]); + }); + it("opens a path with a selected target", async () => { const requests: Array> = []; diff --git a/apps/app/src/lib/api-host-daemon.ts b/apps/app/src/lib/api-host-daemon.ts index 4aa29100..051841b0 100644 --- a/apps/app/src/lib/api-host-daemon.ts +++ b/apps/app/src/lib/api-host-daemon.ts @@ -1,8 +1,13 @@ import { createHostDaemonLocalClient, DEFAULT_HOST_DAEMON_LOCAL_BIND_HOST, + providerCliInstallEventSchema, + providerCliStatusResponseSchema, workspaceOpenTargetsResponseSchema, type OpenInTargetRequest, + type ProviderCliInstallEvent, + type ProviderCliInstallRequest, + type ProviderCliStatusResponse, type StatusResponse, type WorkspaceOpenTarget, } from "@bb/host-daemon-contract"; @@ -17,6 +22,17 @@ const hostDaemonErrorResponseSchema = z.object({ message: z.string().min(1), }); +export type ProviderCliInstallEventHandler = ( + event: ProviderCliInstallEvent, +) => void; + +export interface InstallProviderCliArgs { + port: number; + request: ProviderCliInstallRequest; + onEvent: ProviderCliInstallEventHandler; + signal?: AbortSignal; +} + /** * Get or create the host daemon client. * Recreates the client if the port changes. @@ -31,6 +47,10 @@ export function getHostDaemonClient(port: number) { return client; } +function getHostDaemonBaseUrl(port: number): string { + return `http://${DEFAULT_HOST_DAEMON_LOCAL_BIND_HOST}:${port}`; +} + /** * Fetch the local host ID from the daemon. * Returns null if the daemon is unreachable. @@ -72,6 +92,18 @@ export async function fetchWorkspaceOpenTargets( return body.targets; } +export async function fetchProviderCliStatus( + port: number, +): Promise { + const daemon = getHostDaemonClient(port); + const res = await daemon["provider-clis"].status.$get(); + if (!res.ok) { + const status = Number(res.status); + throw new Error(`Provider CLI status check failed: HTTP ${status}`); + } + return providerCliStatusResponseSchema.parse(await res.json()); +} + export async function openInTarget( port: number, request: OpenInTargetRequest, @@ -91,6 +123,78 @@ export async function openInTarget( } } +function handleProviderCliInstallEventLine( + line: string, + onEvent: ProviderCliInstallEventHandler, +): void { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0) { + return; + } + onEvent(providerCliInstallEventSchema.parse(JSON.parse(trimmedLine))); +} + +function emitProviderCliInstallEventLines( + buffer: string, + onEvent: ProviderCliInstallEventHandler, +): string { + const lines = buffer.split(/\r?\n/u); + const lastLine = lines.pop(); + for (const line of lines) { + handleProviderCliInstallEventLine(line, onEvent); + } + return lastLine ?? ""; +} + +export async function installProviderCli({ + port, + request, + onEvent, + signal, +}: InstallProviderCliArgs): Promise { + const res = await fetch( + `${getHostDaemonBaseUrl(port)}/provider-clis/install`, + { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(request), + signal, + }, + ); + + if (!res.ok) { + const status = Number(res.status); + throw new Error( + await readHostDaemonErrorMessage( + res, + `Provider CLI install failed: HTTP ${status}`, + ), + ); + } + + if (!res.body) { + throw new Error("Provider CLI install did not return a log stream"); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const result = await reader.read(); + if (result.done) { + break; + } + buffer += decoder.decode(result.value, { stream: true }); + buffer = emitProviderCliInstallEventLines(buffer, onEvent); + } + + buffer += decoder.decode(); + handleProviderCliInstallEventLine(buffer, onEvent); +} + async function readHostDaemonErrorMessage( response: Response, fallbackMessage: string, diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts index 9bad30df..72e211d0 100644 --- a/apps/app/src/lib/api.ts +++ b/apps/app/src/lib/api.ts @@ -36,6 +36,7 @@ import type { SendQueuedMessageRequest, SendQueuedMessageResponse, SendMessageRequest, + SystemConfigResponse, SystemExecutionOptionsResponse, SystemProviderInfo, SystemVersionResponse, @@ -1293,6 +1294,10 @@ export async function getSystemVersion(): Promise { return request(apiClient.system.version.$get()); } +export async function getSystemConfig(): Promise { + return request(apiClient.system.config.$get()); +} + export async function listHosts(): Promise { return request(apiClient.hosts.$get()); } diff --git a/apps/host-daemon/package.json b/apps/host-daemon/package.json index bc073e8f..bd64738e 100644 --- a/apps/host-daemon/package.json +++ b/apps/host-daemon/package.json @@ -47,7 +47,9 @@ "pino-pretty": "^13.0.0", "pino-roll": "^4.0.0", "proper-lockfile": "^4.1.2", - "ws": "^8.19.0" + "semver": "^7.7.4", + "ws": "^8.19.0", + "zod": "^4.3.6" }, "devDependencies": { "@bb/scripts": "workspace:*", @@ -56,6 +58,7 @@ "@types/better-sqlite3": "^7.6.12", "@types/mime-types": "^3.0.1", "@types/node": "^22.0.0", + "@types/semver": "^7.7.1", "esbuild": "^0.28.0", "hono": "^4.7.0", "tsx": "^4.21.0", diff --git a/apps/host-daemon/src/local-api.ts b/apps/host-daemon/src/local-api.ts index 7b4c2eea..f67d23a4 100644 --- a/apps/host-daemon/src/local-api.ts +++ b/apps/host-daemon/src/local-api.ts @@ -12,6 +12,8 @@ import { HOST_DAEMON_PROTOCOL_VERSION, openInTargetRequestSchema, pathsExistRequestSchema, + providerCliInstallRequestSchema, + providerCliStatusResponseSchema, typedRoutes, type HostDaemonLocalSchema, type HostPlatform, @@ -28,6 +30,11 @@ import { openPathInTarget, WorkspaceOpenTargetError, } from "./workspace-open-targets.js"; +import { + getProviderCliStatus, + ProviderCliInstallInProgressError, + streamProviderCliInstall, +} from "./provider-cli-health.js"; const execFileAsync = promisify(execFile); export type WorkspaceOpenTargetListHandler = () => Promise< @@ -151,6 +158,44 @@ export async function startLocalApiServer( }), ); + get("/provider-clis/status", async (c) => + c.json(providerCliStatusResponseSchema.parse(await getProviderCliStatus())), + ); + + app.post("/provider-clis/install", async (c) => { + const parsed = providerCliInstallRequestSchema.safeParse( + await c.req.json().catch(() => null), + ); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + throw new HTTPException(400, { + message: issue?.message ?? "Invalid provider CLI install request", + }); + } + + try { + return new Response( + streamProviderCliInstall({ + provider: parsed.data.provider, + actionKind: parsed.data.actionKind, + }), + { + headers: { + "content-type": "application/x-ndjson; charset=utf-8", + "cache-control": "no-store", + }, + }, + ); + } catch (error) { + if (error instanceof ProviderCliInstallInProgressError) { + throw new HTTPException(409, { + message: error.message, + }); + } + throw error; + } + }); + post("/paths/exist", pathsExistRequestSchema, async (c, payload) => { const entries = await Promise.all( payload.paths.map( @@ -228,8 +273,11 @@ async function pathExists(path: string): Promise { await fs.stat(path); return true; } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ENOTDIR") { + if ( + error instanceof Error && + "code" in error && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ) { return false; } // Permission denied / loops / etc. — we can't tell, but the entry exists diff --git a/apps/host-daemon/src/provider-cli-health.test.ts b/apps/host-daemon/src/provider-cli-health.test.ts new file mode 100644 index 00000000..460a61ee --- /dev/null +++ b/apps/host-daemon/src/provider-cli-health.test.ts @@ -0,0 +1,607 @@ +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { + getProviderCliStatus, + inspectProviderCli, + ProviderCliInstallInProgressError, + streamProviderCliInstall, + type ProviderCliCommandResult, + type ProviderCliCommandRunner, + type ProviderCliDefinition, + type ProviderCliInstallProcess, + type ProviderCliInstallProcessCloseListener, + type ProviderCliInstallProcessErrorListener, + type ProviderCliInstallProcessSpawner, + type RunProviderCliCommandArgs, + type SpawnProviderCliInstallProcessArgs, +} from "./provider-cli-health.js"; +import { + providerCliInstallEventSchema, + type ProviderCliInstallEvent, +} from "@bb/host-daemon-contract"; + +interface FakeCommandBehavior { + stdout: string; + stderr: string; + exitCode: number | null; + signal: string | null; + errorMessage: string | null; +} + +class FakeProviderCliCommandRunner implements ProviderCliCommandRunner { + readonly calls: RunProviderCliCommandArgs[] = []; + private readonly behaviorsByKey = new Map(); + + setSuccess(command: string, args: readonly string[], stdout: string): void { + this.behaviorsByKey.set(this.keyFor(command, args), { + stdout, + stderr: "", + exitCode: 0, + signal: null, + errorMessage: null, + }); + } + + setExit( + command: string, + args: readonly string[], + exitCode: number, + stderr: string, + ): void { + this.behaviorsByKey.set(this.keyFor(command, args), { + stdout: "", + stderr, + exitCode, + signal: null, + errorMessage: null, + }); + } + + setSpawnError( + command: string, + args: readonly string[], + message: string, + ): void { + this.behaviorsByKey.set(this.keyFor(command, args), { + stdout: "", + stderr: "", + exitCode: null, + signal: null, + errorMessage: message, + }); + } + + async run( + args: RunProviderCliCommandArgs, + ): Promise { + this.calls.push(args); + const behavior = this.behaviorsByKey.get( + this.keyFor(args.command, args.args), + ); + if (!behavior) { + throw new Error(`No fake command behavior for ${this.describe(args)}`); + } + return { + command: args.command, + args: args.args, + stdout: behavior.stdout, + stderr: behavior.stderr, + exitCode: behavior.exitCode, + signal: behavior.signal, + errorMessage: behavior.errorMessage, + }; + } + + commandLines(): string[] { + return this.calls.map((call) => this.describe(call)); + } + + private keyFor(command: string, args: readonly string[]): string { + return [command, ...args].join("\0"); + } + + private describe(args: RunProviderCliCommandArgs): string { + return [args.command, ...args.args].join(" "); + } +} + +class FakeProviderCliInstallProcess implements ProviderCliInstallProcess { + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly killSignals: NodeJS.Signals[] = []; + private readonly errorListeners: ProviderCliInstallProcessErrorListener[] = + []; + private readonly closeListeners: ProviderCliInstallProcessCloseListener[] = + []; + + kill(signal: NodeJS.Signals): boolean { + this.killSignals.push(signal); + return true; + } + + onError(listener: ProviderCliInstallProcessErrorListener): void { + this.errorListeners.push(listener); + } + + onClose(listener: ProviderCliInstallProcessCloseListener): void { + this.closeListeners.push(listener); + } + + emitError(error: Error): void { + for (const listener of this.errorListeners) { + listener(error); + } + } + + emitClose(exitCode: number | null, signal: NodeJS.Signals | null): void { + for (const listener of this.closeListeners) { + listener(exitCode, signal); + } + } +} + +class FakeProviderCliInstallProcessSpawner implements ProviderCliInstallProcessSpawner { + readonly processes: FakeProviderCliInstallProcess[] = []; + readonly spawnRequests: SpawnProviderCliInstallProcessArgs[] = []; + + spawn(args: SpawnProviderCliInstallProcessArgs): ProviderCliInstallProcess { + this.spawnRequests.push(args); + const process = new FakeProviderCliInstallProcess(); + this.processes.push(process); + return process; + } + + lastProcess(): FakeProviderCliInstallProcess { + const process = this.processes.at(-1); + if (!process) { + throw new Error("Expected an install process to be spawned"); + } + return process; + } +} + +const CODEX_DEFINITION: ProviderCliDefinition = { + key: "codex", + displayName: "Codex", + executableName: "codex", + npmPackageName: "@openai/codex", + installCommand: { + kind: "npmGlobal", + }, + updateCommand: { + commandKind: "exec", + displayCommand: "codex update", + command: "codex", + args: ["update"], + }, +}; + +const CLAUDE_CODE_DEFINITION: ProviderCliDefinition = { + key: "claudeCode", + displayName: "Claude Code", + executableName: "claude", + npmPackageName: "@anthropic-ai/claude-code", + installCommand: { + kind: "shell", + command: "curl -fsSL https://claude.ai/install.sh | bash", + }, + updateCommand: { + commandKind: "exec", + displayCommand: "claude update", + command: "claude", + args: ["update"], + }, +}; + +function installNpmStateCommands( + runner: FakeProviderCliCommandRunner, + definition: ProviderCliDefinition, + prefix: string, + packageVersion: string | null, +): void { + runner.setSuccess("npm", ["prefix", "-g"], `${prefix}\n`); + runner.setSuccess( + "npm", + ["list", "-g", definition.npmPackageName, "--depth=0", "--json"], + packageVersion === null + ? JSON.stringify({ dependencies: {} }) + : JSON.stringify({ + dependencies: { + [definition.npmPackageName]: { version: packageVersion }, + }, + }), + ); +} + +function installMissingCodexCommands( + runner: FakeProviderCliCommandRunner, +): void { + runner.setExit("which", ["codex"], 1, "codex not found"); + runner.setSpawnError("codex", ["--version"], "spawn codex ENOENT"); + runner.setSuccess("npm", ["view", "@openai/codex", "version"], "0.133.0\n"); + installNpmStateCommands(runner, CODEX_DEFINITION, "/usr/local", null); +} + +function installMissingClaudeCommands( + runner: FakeProviderCliCommandRunner, +): void { + runner.setExit("which", ["claude"], 1, "claude not found"); + runner.setSpawnError("claude", ["--version"], "spawn claude ENOENT"); + runner.setSuccess( + "npm", + ["view", "@anthropic-ai/claude-code", "version"], + "2.1.148\n", + ); + installNpmStateCommands(runner, CLAUDE_CODE_DEFINITION, "/usr/local", null); +} + +function installOutdatedNpmCodexCommands( + runner: FakeProviderCliCommandRunner, +): void { + runner.setSuccess("which", ["codex"], "/usr/local/bin/codex\n"); + runner.setSuccess("codex", ["--version"], "codex 0.132.0\n"); + runner.setSuccess("npm", ["view", "@openai/codex", "version"], "0.133.0\n"); + installNpmStateCommands(runner, CODEX_DEFINITION, "/usr/local", "0.132.0"); +} + +function installOutdatedExternalClaudeCommands( + runner: FakeProviderCliCommandRunner, +): void { + runner.setSuccess("which", ["claude"], "/opt/homebrew/bin/claude\n"); + runner.setSuccess("claude", ["--version"], "2.1.147 (Claude Code)\n"); + runner.setSuccess( + "npm", + ["view", "@anthropic-ai/claude-code", "version"], + "2.1.148\n", + ); + installNpmStateCommands( + runner, + CLAUDE_CODE_DEFINITION, + "/Users/me/.npm-global", + "2.1.147", + ); +} + +function installCurrentClaudeCommands( + runner: FakeProviderCliCommandRunner, +): void { + runner.setSuccess("which", ["claude"], "/opt/homebrew/bin/claude\n"); + runner.setSuccess("claude", ["--version"], "2.1.148 (Claude Code)\n"); + runner.setSuccess( + "npm", + ["view", "@anthropic-ai/claude-code", "version"], + "2.1.148\n", + ); + installNpmStateCommands( + runner, + CLAUDE_CODE_DEFINITION, + "/opt/homebrew", + "2.1.148", + ); +} + +async function collectInstallEvents( + stream: ReadableStream, +): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + const events: ProviderCliInstallEvent[] = []; + let buffer = ""; + + while (true) { + const result = await reader.read(); + if (result.done) { + break; + } + buffer += decoder.decode(result.value, { stream: true }); + const lines = buffer.split(/\r?\n/u); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (line.trim().length > 0) { + events.push(providerCliInstallEventSchema.parse(JSON.parse(line))); + } + } + } + + buffer += decoder.decode(); + if (buffer.trim().length > 0) { + events.push(providerCliInstallEventSchema.parse(JSON.parse(buffer))); + } + return events; +} + +describe("provider CLI health", () => { + it("reports a missing CLI with an npm install action", async () => { + const runner = new FakeProviderCliCommandRunner(); + installMissingCodexCommands(runner); + + const status = await inspectProviderCli({ + definition: CODEX_DEFINITION, + runner, + nodePlatform: "darwin", + }); + + expect(status).toEqual({ + displayName: "Codex", + executableName: "codex", + executablePath: null, + installed: false, + installSource: "notInstalled", + currentVersion: null, + latestVersion: "0.133.0", + npmPackageName: "@openai/codex", + npmGlobalPackageVersion: null, + installAction: { + kind: "install", + label: "Install", + commandKind: "exec", + command: "npm install -g @openai/codex@latest", + }, + needsUpdate: false, + }); + }); + + it("reports a missing Claude Code CLI with the shell installer action", async () => { + const runner = new FakeProviderCliCommandRunner(); + installMissingClaudeCommands(runner); + + const status = await inspectProviderCli({ + definition: CLAUDE_CODE_DEFINITION, + runner, + nodePlatform: "darwin", + }); + + expect(status.installed).toBe(false); + expect(status.installSource).toBe("notInstalled"); + expect(status.installAction).toEqual({ + kind: "install", + label: "Install", + commandKind: "shell", + command: "curl -fsSL https://claude.ai/install.sh | bash", + }); + }); + + it("offers a self-update action when the active executable is npm-global", async () => { + const runner = new FakeProviderCliCommandRunner(); + installOutdatedNpmCodexCommands(runner); + + const status = await inspectProviderCli({ + definition: CODEX_DEFINITION, + runner, + nodePlatform: "darwin", + }); + + expect(status.installed).toBe(true); + expect(status.installSource).toBe("npmGlobal"); + expect(status.currentVersion).toBe("0.132.0"); + expect(status.latestVersion).toBe("0.133.0"); + expect(status.needsUpdate).toBe(true); + expect(status.installAction).toEqual({ + kind: "update", + label: "Update", + commandKind: "exec", + command: "codex update", + }); + }); + + it("offers a self-update action when the active executable is external", async () => { + const runner = new FakeProviderCliCommandRunner(); + installOutdatedExternalClaudeCommands(runner); + + const status = await inspectProviderCli({ + definition: CLAUDE_CODE_DEFINITION, + runner, + nodePlatform: "darwin", + }); + + expect(status.installed).toBe(true); + expect(status.installSource).toBe("external"); + expect(status.needsUpdate).toBe(true); + expect(status.installAction).toEqual({ + kind: "update", + label: "Update", + commandKind: "exec", + command: "claude update", + }); + }); + + it("does not report an update when the CLI version matches npm latest", async () => { + const runner = new FakeProviderCliCommandRunner(); + installCurrentClaudeCommands(runner); + + const status = await inspectProviderCli({ + definition: CLAUDE_CODE_DEFINITION, + runner, + nodePlatform: "darwin", + }); + + expect(status.installed).toBe(true); + expect(status.installSource).toBe("npmGlobal"); + expect(status.currentVersion).toBe("2.1.148"); + expect(status.latestVersion).toBe("2.1.148"); + expect(status.needsUpdate).toBe(false); + expect(status.installAction).toBeNull(); + }); + + it("returns both provider keys and queries the confirmed npm packages", async () => { + const runner = new FakeProviderCliCommandRunner(); + installOutdatedNpmCodexCommands(runner); + installCurrentClaudeCommands(runner); + + const status = await getProviderCliStatus({ + runner, + nodePlatform: "darwin", + }); + + expect(status.codex.needsUpdate).toBe(true); + expect(status.claudeCode.needsUpdate).toBe(false); + expect(runner.commandLines()).toContain("npm view @openai/codex version"); + expect(runner.commandLines()).toContain( + "npm view @anthropic-ai/claude-code version", + ); + }); + + it("streams failed npm installs without hiding the exit status", async () => { + const spawner = new FakeProviderCliInstallProcessSpawner(); + const stream = streamProviderCliInstall({ + provider: "codex", + actionKind: "install", + nodePlatform: "darwin", + installProcessSpawner: spawner, + }); + const eventsPromise = collectInstallEvents(stream); + + spawner.lastProcess().stderr.write("permission denied\n"); + spawner.lastProcess().emitClose(1, null); + + await expect(eventsPromise).resolves.toEqual([ + { + type: "started", + provider: "codex", + command: "npm install -g @openai/codex@latest", + }, + { + type: "output", + provider: "codex", + stream: "stderr", + text: "permission denied\n", + }, + { + type: "completed", + provider: "codex", + exitCode: 1, + signal: null, + success: false, + }, + ]); + expect(spawner.spawnRequests).toEqual([ + { + command: "npm", + args: ["install", "-g", "@openai/codex@latest"], + }, + ]); + }); + + it("streams Claude Code shell installs with the visible install command", async () => { + const spawner = new FakeProviderCliInstallProcessSpawner(); + const stream = streamProviderCliInstall({ + provider: "claudeCode", + actionKind: "install", + nodePlatform: "darwin", + installProcessSpawner: spawner, + }); + const eventsPromise = collectInstallEvents(stream); + + spawner.lastProcess().stdout.write("installing claude\n"); + spawner.lastProcess().emitClose(0, null); + + await expect(eventsPromise).resolves.toEqual([ + { + type: "started", + provider: "claudeCode", + command: "curl -fsSL https://claude.ai/install.sh | bash", + }, + { + type: "output", + provider: "claudeCode", + stream: "stdout", + text: "installing claude\n", + }, + { + type: "completed", + provider: "claudeCode", + exitCode: 0, + signal: null, + success: true, + }, + ]); + expect(spawner.spawnRequests).toEqual([ + { + command: "sh", + args: ["-c", "curl -fsSL https://claude.ai/install.sh | bash"], + }, + ]); + }); + + it("streams provider self-updates with the visible update command", async () => { + const spawner = new FakeProviderCliInstallProcessSpawner(); + const stream = streamProviderCliInstall({ + provider: "claudeCode", + actionKind: "update", + nodePlatform: "darwin", + installProcessSpawner: spawner, + }); + const eventsPromise = collectInstallEvents(stream); + + spawner.lastProcess().emitClose(0, null); + + await expect(eventsPromise).resolves.toEqual([ + { + type: "started", + provider: "claudeCode", + command: "claude update", + }, + { + type: "completed", + provider: "claudeCode", + exitCode: 0, + signal: null, + success: true, + }, + ]); + expect(spawner.spawnRequests).toEqual([ + { + command: "claude", + args: ["update"], + }, + ]); + }); + + it("does not enqueue completion after stream cancellation", async () => { + const spawner = new FakeProviderCliInstallProcessSpawner(); + const stream = streamProviderCliInstall({ + provider: "codex", + actionKind: "update", + nodePlatform: "darwin", + installProcessSpawner: spawner, + }); + const reader = stream.getReader(); + + await expect(reader.read()).resolves.toMatchObject({ + done: false, + }); + await reader.cancel(); + + const process = spawner.lastProcess(); + expect(process.killSignals).toEqual(["SIGTERM"]); + expect(() => process.emitClose(0, null)).not.toThrow(); + }); + + it("rejects duplicate provider CLI installs until the active stream ends", async () => { + const spawner = new FakeProviderCliInstallProcessSpawner(); + const firstStream = streamProviderCliInstall({ + provider: "codex", + actionKind: "install", + nodePlatform: "darwin", + installProcessSpawner: spawner, + }); + + expect(() => + streamProviderCliInstall({ + provider: "claudeCode", + actionKind: "update", + nodePlatform: "darwin", + installProcessSpawner: spawner, + }), + ).toThrow(ProviderCliInstallInProgressError); + + await firstStream.cancel(); + const secondStream = streamProviderCliInstall({ + provider: "claudeCode", + actionKind: "update", + nodePlatform: "darwin", + installProcessSpawner: spawner, + }); + await secondStream.cancel(); + }); +}); diff --git a/apps/host-daemon/src/provider-cli-health.ts b/apps/host-daemon/src/provider-cli-health.ts new file mode 100644 index 00000000..8bf7e911 --- /dev/null +++ b/apps/host-daemon/src/provider-cli-health.ts @@ -0,0 +1,841 @@ +import { isAbsolute, join, relative, resolve } from "node:path"; +import type { Readable } from "node:stream"; +import { spawnPortableOutputProcess } from "@bb/process-utils"; +import semver from "semver"; +import { z } from "zod"; +import { + providerCliInstallEventSchema, + type ProviderCliInstallAction, + type ProviderCliInstallActionKind, + type ProviderCliInstallEvent, + type ProviderCliInstallSource, + type ProviderCliKey, + type ProviderCliStatus, + type ProviderCliStatusResponse, +} from "@bb/host-daemon-contract"; + +const COMMAND_CHECK_TIMEOUT_MS = 5_000; +const NPM_VIEW_TIMEOUT_MS = 15_000; +const NPM_INSTALL_STATE_TIMEOUT_MS = 5_000; + +const npmGlobalListDependencySchema = z + .object({ + version: z.string().min(1), + }) + .passthrough(); + +const npmGlobalListResponseSchema = z + .object({ + dependencies: z + .record(z.string(), npmGlobalListDependencySchema) + .default({}), + }) + .passthrough(); + +export interface ProviderCliDefinition { + key: ProviderCliKey; + displayName: string; + executableName: string; + npmPackageName: string; + installCommand: ProviderCliInstallCommandDefinition; + updateCommand: ProviderCliActionCommand; +} + +export interface ProviderCliCommandResult { + command: string; + args: readonly string[]; + stdout: string; + stderr: string; + exitCode: number | null; + signal: string | null; + errorMessage: string | null; +} + +export interface RunProviderCliCommandArgs { + command: string; + args: readonly string[]; + timeoutMs: number; +} + +export interface ProviderCliCommandRunner { + run(args: RunProviderCliCommandArgs): Promise; +} + +export interface InspectProviderCliArgs { + definition: ProviderCliDefinition; + runner: ProviderCliCommandRunner; + nodePlatform: NodeJS.Platform; +} + +export interface GetProviderCliStatusArgs { + runner?: ProviderCliCommandRunner; + nodePlatform?: NodeJS.Platform; +} + +export interface SpawnProviderCliInstallProcessArgs { + command: string; + args: string[]; +} + +export type ProviderCliInstallProcessErrorListener = (error: Error) => void; +export type ProviderCliInstallProcessCloseListener = ( + exitCode: number | null, + signal: NodeJS.Signals | null, +) => void; + +export interface ProviderCliInstallProcess { + stdout: Readable; + stderr: Readable; + kill(signal: NodeJS.Signals): boolean; + onError(listener: ProviderCliInstallProcessErrorListener): void; + onClose(listener: ProviderCliInstallProcessCloseListener): void; +} + +export interface ProviderCliInstallProcessSpawner { + spawn(args: SpawnProviderCliInstallProcessArgs): ProviderCliInstallProcess; +} + +export interface StreamProviderCliInstallArgs { + provider: ProviderCliKey; + actionKind: ProviderCliInstallActionKind; + nodePlatform?: NodeJS.Platform; + installProcessSpawner?: ProviderCliInstallProcessSpawner; +} + +interface NeedsProviderCliUpdateArgs { + installed: boolean; + currentVersion: string | null; + latestVersion: string | null; +} + +interface ResolveProviderCliInstallSourceArgs { + installed: boolean; + executablePath: string | null; + npmGlobalPrefix: string | null; + nodePlatform: NodeJS.Platform; +} + +interface BuildInstallActionArgs { + definition: ProviderCliDefinition; + installed: boolean; + needsUpdate: boolean; + nodePlatform: NodeJS.Platform; +} + +interface CreateCommandResultArgs { + command: string; + commandArgs: readonly string[]; + stdout: string; + stderr: string; + exitCode: number | null; + signal: string | null; + errorMessage: string | null; +} + +interface ProviderCliActionCommand { + commandKind: "exec" | "shell"; + displayCommand: string; + command: string; + args: readonly string[]; +} + +type ProviderCliInstallCommandDefinition = + | Readonly<{ + kind: "npmGlobal"; + }> + | Readonly<{ + kind: "shell"; + command: string; + }>; + +interface ResolveProviderCliActionCommandArgs { + definition: ProviderCliDefinition; + actionKind: ProviderCliInstallActionKind; + nodePlatform: NodeJS.Platform; +} + +interface ProviderCliInstallSlot { + provider: ProviderCliKey; + released: boolean; +} + +interface ProviderCliInstallStreamState { + closed: boolean; + childProcess: ProviderCliInstallProcess | null; + installSlot: ProviderCliInstallSlot; +} + +interface WriteInstallEventArgs { + controller: ReadableStreamDefaultController; + encoder: TextEncoder; + state: ProviderCliInstallStreamState; + event: ProviderCliInstallEvent; +} + +interface CloseInstallStreamArgs { + controller: ReadableStreamDefaultController; + state: ProviderCliInstallStreamState; +} + +let activeProviderCliInstallProvider: ProviderCliKey | null = null; + +export class ProviderCliInstallInProgressError extends Error { + readonly provider: ProviderCliKey; + + constructor(provider: ProviderCliKey) { + super(`Provider CLI install already running for ${provider}`); + this.name = "ProviderCliInstallInProgressError"; + this.provider = provider; + } +} + +function getProviderCliDefinition( + provider: ProviderCliKey, +): ProviderCliDefinition { + switch (provider) { + case "codex": + return { + key: "codex", + displayName: "Codex", + executableName: "codex", + npmPackageName: "@openai/codex", + installCommand: { + kind: "npmGlobal", + }, + updateCommand: { + commandKind: "exec", + displayCommand: "codex update", + command: "codex", + args: ["update"], + }, + }; + case "claudeCode": + return { + key: "claudeCode", + displayName: "Claude Code", + executableName: "claude", + npmPackageName: "@anthropic-ai/claude-code", + installCommand: { + kind: "shell", + command: "curl -fsSL https://claude.ai/install.sh | bash", + }, + updateCommand: { + commandKind: "exec", + displayCommand: "claude update", + command: "claude", + args: ["update"], + }, + }; + } +} + +function npmExecutableName(nodePlatform: NodeJS.Platform): string { + return nodePlatform === "win32" ? "npm.cmd" : "npm"; +} + +function formatCommand(command: string, args: readonly string[]): string { + const parts = [command, ...args]; + return parts + .map((part) => + /^[A-Za-z0-9_./:@+-]+$/u.test(part) + ? part + : `'${part.replace(/'/gu, "'\\''")}'`, + ) + .join(" "); +} + +function isSuccessfulCommand(result: ProviderCliCommandResult): boolean { + return result.errorMessage === null && result.exitCode === 0; +} + +function firstOutputLine(text: string): string | null { + const line = text + .split(/\r?\n/u) + .map((candidate) => candidate.trim()) + .find((candidate) => candidate.length > 0); + return line ?? null; +} + +function extractVersion(text: string): string | null { + const match = + /\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/u.exec( + text, + ); + const candidate = match?.[1]; + if (!candidate) { + return null; + } + return semver.valid(candidate); +} + +function parseNpmGlobalPackageVersion( + text: string, + npmPackageName: string, +): string | null { + const trimmedText = text.trim(); + if (trimmedText.length === 0) { + return null; + } + + try { + const parsed = npmGlobalListResponseSchema.safeParse( + JSON.parse(trimmedText), + ); + if (!parsed.success) { + return null; + } + return parsed.data.dependencies[npmPackageName]?.version ?? null; + } catch { + return null; + } +} + +function needsProviderCliUpdate(args: NeedsProviderCliUpdateArgs): boolean { + if (!args.installed || !args.currentVersion || !args.latestVersion) { + return false; + } + return semver.gt(args.latestVersion, args.currentVersion); +} + +function npmInstallCommandArgs(definition: ProviderCliDefinition): string[] { + return ["install", "-g", `${definition.npmPackageName}@latest`]; +} + +function npmInstallActionCommand( + definition: ProviderCliDefinition, + nodePlatform: NodeJS.Platform, +): ProviderCliActionCommand { + const command = npmExecutableName(nodePlatform); + const args = npmInstallCommandArgs(definition); + return { + commandKind: "exec", + displayCommand: formatCommand(command, args), + command, + args, + }; +} + +function shellInstallActionCommand(command: string): ProviderCliActionCommand { + return { + commandKind: "shell", + displayCommand: command, + command: "sh", + args: ["-c", command], + }; +} + +function installActionCommand( + definition: ProviderCliDefinition, + nodePlatform: NodeJS.Platform, +): ProviderCliActionCommand { + switch (definition.installCommand.kind) { + case "npmGlobal": + return npmInstallActionCommand(definition, nodePlatform); + case "shell": + return shellInstallActionCommand(definition.installCommand.command); + } +} + +function npmGlobalBinDirectory( + npmGlobalPrefix: string, + nodePlatform: NodeJS.Platform, +): string { + return nodePlatform === "win32" + ? npmGlobalPrefix + : join(npmGlobalPrefix, "bin"); +} + +function isPathInsideDirectory(path: string, directory: string): boolean { + const relativePath = relative(resolve(directory), resolve(path)); + return ( + relativePath === "" || + (!relativePath.startsWith("..") && !isAbsolute(relativePath)) + ); +} + +function resolveProviderCliInstallSource({ + installed, + executablePath, + npmGlobalPrefix, + nodePlatform, +}: ResolveProviderCliInstallSourceArgs): ProviderCliInstallSource { + if (!installed) { + return "notInstalled"; + } + if (!executablePath || !npmGlobalPrefix) { + return "external"; + } + + const npmBinDirectory = npmGlobalBinDirectory(npmGlobalPrefix, nodePlatform); + return isPathInsideDirectory(executablePath, npmBinDirectory) + ? "npmGlobal" + : "external"; +} + +function buildInstallAction({ + definition, + installed, + needsUpdate, + nodePlatform, +}: BuildInstallActionArgs): ProviderCliInstallAction | null { + if (!installed) { + const command = installActionCommand(definition, nodePlatform); + return { + kind: "install", + label: "Install", + commandKind: command.commandKind, + command: command.displayCommand, + }; + } + if (needsUpdate) { + const command = definition.updateCommand; + return { + kind: "update", + label: "Update", + commandKind: command.commandKind, + command: command.displayCommand, + }; + } + return null; +} + +function resolveProviderCliActionCommand({ + definition, + actionKind, + nodePlatform, +}: ResolveProviderCliActionCommandArgs): ProviderCliActionCommand { + switch (actionKind) { + case "install": + return installActionCommand(definition, nodePlatform); + case "update": + return definition.updateCommand; + } +} + +function createCommandResult( + args: CreateCommandResultArgs, +): ProviderCliCommandResult { + return { + command: args.command, + args: args.commandArgs, + stdout: args.stdout, + stderr: args.stderr, + exitCode: args.exitCode, + signal: args.signal, + errorMessage: args.errorMessage, + }; +} + +export function createSpawnProviderCliCommandRunner(): ProviderCliCommandRunner { + return { + run: runProviderCliCommand, + }; +} + +export async function runProviderCliCommand( + args: RunProviderCliCommandArgs, +): Promise { + return await new Promise((resolve) => { + let child; + try { + child = spawnPortableOutputProcess({ + command: args.command, + args: [...args.args], + env: process.env, + }); + } catch (error) { + resolve( + createCommandResult({ + command: args.command, + commandArgs: args.args, + stdout: "", + stderr: "", + exitCode: null, + signal: null, + errorMessage: error instanceof Error ? error.message : String(error), + }), + ); + return; + } + + let stdout = ""; + let stderr = ""; + let settled = false; + + function settle(result: ProviderCliCommandResult): void { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolve(result); + } + + const timeout = setTimeout(() => { + child.kill("SIGTERM"); + }, args.timeoutMs); + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + + child.on("error", (error) => { + settle( + createCommandResult({ + command: args.command, + commandArgs: args.args, + stdout, + stderr, + exitCode: null, + signal: null, + errorMessage: error.message, + }), + ); + }); + + child.on("close", (exitCode, signal) => { + settle( + createCommandResult({ + command: args.command, + commandArgs: args.args, + stdout, + stderr, + exitCode, + signal, + errorMessage: null, + }), + ); + }); + }); +} + +export async function inspectProviderCli({ + definition, + runner, + nodePlatform, +}: InspectProviderCliArgs): Promise { + const npmCommand = npmExecutableName(nodePlatform); + const [ + whichResult, + versionResult, + latestResult, + npmPrefixResult, + npmListResult, + ] = await Promise.all([ + runner.run({ + command: "which", + args: [definition.executableName], + timeoutMs: COMMAND_CHECK_TIMEOUT_MS, + }), + runner.run({ + command: definition.executableName, + args: ["--version"], + timeoutMs: COMMAND_CHECK_TIMEOUT_MS, + }), + runner.run({ + command: npmCommand, + args: ["view", definition.npmPackageName, "version"], + timeoutMs: NPM_VIEW_TIMEOUT_MS, + }), + runner.run({ + command: npmCommand, + args: ["prefix", "-g"], + timeoutMs: NPM_INSTALL_STATE_TIMEOUT_MS, + }), + runner.run({ + command: npmCommand, + args: ["list", "-g", definition.npmPackageName, "--depth=0", "--json"], + timeoutMs: NPM_INSTALL_STATE_TIMEOUT_MS, + }), + ]); + + const executablePath = isSuccessfulCommand(whichResult) + ? firstOutputLine(whichResult.stdout) + : null; + const installed = + executablePath !== null || isSuccessfulCommand(versionResult); + const currentVersion = isSuccessfulCommand(versionResult) + ? extractVersion(`${versionResult.stdout}\n${versionResult.stderr}`) + : null; + const latestVersion = isSuccessfulCommand(latestResult) + ? extractVersion(`${latestResult.stdout}\n${latestResult.stderr}`) + : null; + const npmGlobalPrefix = isSuccessfulCommand(npmPrefixResult) + ? firstOutputLine(npmPrefixResult.stdout) + : null; + const npmGlobalPackageVersion = parseNpmGlobalPackageVersion( + `${npmListResult.stdout}\n${npmListResult.stderr}`, + definition.npmPackageName, + ); + const installSource = resolveProviderCliInstallSource({ + installed, + executablePath, + npmGlobalPrefix, + nodePlatform, + }); + const needsUpdate = needsProviderCliUpdate({ + installed, + currentVersion, + latestVersion, + }); + const installAction = buildInstallAction({ + definition, + installed, + needsUpdate, + nodePlatform, + }); + + return { + displayName: definition.displayName, + executableName: definition.executableName, + executablePath, + installed, + installSource, + currentVersion, + latestVersion, + npmPackageName: definition.npmPackageName, + npmGlobalPackageVersion, + installAction, + needsUpdate, + }; +} + +export async function getProviderCliStatus( + args: GetProviderCliStatusArgs = {}, +): Promise { + const runner = args.runner ?? createSpawnProviderCliCommandRunner(); + const nodePlatform = args.nodePlatform ?? process.platform; + const [codex, claudeCode] = await Promise.all([ + inspectProviderCli({ + definition: getProviderCliDefinition("codex"), + runner, + nodePlatform, + }), + inspectProviderCli({ + definition: getProviderCliDefinition("claudeCode"), + runner, + nodePlatform, + }), + ]); + + return { + codex, + claudeCode, + }; +} + +export function createPortableProviderCliInstallProcessSpawner(): ProviderCliInstallProcessSpawner { + return { + spawn(args) { + const child = spawnPortableOutputProcess({ + command: args.command, + args: args.args, + env: process.env, + }); + return { + stdout: child.stdout, + stderr: child.stderr, + kill(signal) { + return child.kill(signal); + }, + onError(listener) { + child.on("error", listener); + }, + onClose(listener) { + child.on("close", listener); + }, + }; + }, + }; +} + +function reserveProviderCliInstall( + provider: ProviderCliKey, +): ProviderCliInstallSlot { + if (activeProviderCliInstallProvider !== null) { + throw new ProviderCliInstallInProgressError( + activeProviderCliInstallProvider, + ); + } + activeProviderCliInstallProvider = provider; + return { + provider, + released: false, + }; +} + +function releaseProviderCliInstall(slot: ProviderCliInstallSlot): void { + if (slot.released) { + return; + } + slot.released = true; + if (activeProviderCliInstallProvider === slot.provider) { + activeProviderCliInstallProvider = null; + } +} + +function writeInstallEvent({ + controller, + encoder, + state, + event, +}: WriteInstallEventArgs): void { + if (state.closed) { + return; + } + + try { + const parsedEvent = providerCliInstallEventSchema.parse(event); + controller.enqueue(encoder.encode(`${JSON.stringify(parsedEvent)}\n`)); + } catch { + state.closed = true; + releaseProviderCliInstall(state.installSlot); + state.childProcess?.kill("SIGTERM"); + } +} + +function closeInstallStream({ + controller, + state, +}: CloseInstallStreamArgs): void { + if (state.closed) { + return; + } + state.closed = true; + releaseProviderCliInstall(state.installSlot); + controller.close(); +} + +export function streamProviderCliInstall({ + provider, + actionKind, + nodePlatform = process.platform, + installProcessSpawner = createPortableProviderCliInstallProcessSpawner(), +}: StreamProviderCliInstallArgs): ReadableStream { + const definition = getProviderCliDefinition(provider); + const actionCommand = resolveProviderCliActionCommand({ + definition, + actionKind, + nodePlatform, + }); + const command = actionCommand.command; + const commandArgs = [...actionCommand.args]; + const displayCommand = actionCommand.displayCommand; + const installSlot = reserveProviderCliInstall(provider); + const state: ProviderCliInstallStreamState = { + closed: false, + childProcess: null, + installSlot, + }; + + return new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + writeInstallEvent({ + controller, + encoder, + state, + event: { + type: "started", + provider, + command: displayCommand, + }, + }); + + try { + state.childProcess = installProcessSpawner.spawn({ + command, + args: commandArgs, + }); + } catch (error) { + writeInstallEvent({ + controller, + encoder, + state, + event: { + type: "error", + provider, + message: error instanceof Error ? error.message : String(error), + }, + }); + closeInstallStream({ controller, state }); + return; + } + + const spawned = state.childProcess; + spawned.stdout.setEncoding("utf8"); + spawned.stdout.on("data", (text: string) => { + writeInstallEvent({ + controller, + encoder, + state, + event: { + type: "output", + provider, + stream: "stdout", + text, + }, + }); + }); + + spawned.stderr.setEncoding("utf8"); + spawned.stderr.on("data", (text: string) => { + writeInstallEvent({ + controller, + encoder, + state, + event: { + type: "output", + provider, + stream: "stderr", + text, + }, + }); + }); + + spawned.onError((error) => { + writeInstallEvent({ + controller, + encoder, + state, + event: { + type: "error", + provider, + message: error.message, + }, + }); + closeInstallStream({ controller, state }); + }); + + spawned.onClose((exitCode, signal) => { + if (state.closed) { + return; + } + writeInstallEvent({ + controller, + encoder, + state, + event: { + type: "completed", + provider, + exitCode, + signal, + success: exitCode === 0, + }, + }); + closeInstallStream({ controller, state }); + }); + }, + cancel() { + state.closed = true; + releaseProviderCliInstall(state.installSlot); + state.childProcess?.kill("SIGTERM"); + }, + }); +} diff --git a/packages/host-daemon-contract/src/index.ts b/packages/host-daemon-contract/src/index.ts index 14ac1c62..2afaf33a 100644 --- a/packages/host-daemon-contract/src/index.ts +++ b/packages/host-daemon-contract/src/index.ts @@ -29,6 +29,25 @@ export { pathsExistRequestSchema, pathsExistResponseSchema, pickFolderResponseSchema, + providerCliInstallActionKindSchema, + providerCliInstallActionKindValues, + providerCliInstallActionSchema, + providerCliInstallCommandKindSchema, + providerCliInstallCommandKindValues, + providerCliInstallCompletedEventSchema, + providerCliInstallErrorEventSchema, + providerCliInstallEventSchema, + providerCliInstallOutputEventSchema, + providerCliInstallOutputStreamSchema, + providerCliInstallOutputStreamValues, + providerCliInstallRequestSchema, + providerCliInstallSourceSchema, + providerCliInstallSourceValues, + providerCliInstallStartedEventSchema, + providerCliKeySchema, + providerCliKeyValues, + providerCliStatusResponseSchema, + providerCliStatusSchema, statusResponseSchema, workspaceOpenTargetIdSchema, workspaceOpenTargetIdValues, @@ -46,6 +65,20 @@ export type { PathsExistRequest, PathsExistResponse, PickFolderResponse, + ProviderCliInstallAction, + ProviderCliInstallActionKind, + ProviderCliInstallCommandKind, + ProviderCliInstallCompletedEvent, + ProviderCliInstallErrorEvent, + ProviderCliInstallEvent, + ProviderCliInstallOutputEvent, + ProviderCliInstallOutputStream, + ProviderCliInstallRequest, + ProviderCliInstallSource, + ProviderCliInstallStartedEvent, + ProviderCliKey, + ProviderCliStatus, + ProviderCliStatusResponse, StatusResponse, WorkspaceOpenTarget, WorkspaceOpenTargetId, diff --git a/packages/host-daemon-contract/src/local.ts b/packages/host-daemon-contract/src/local.ts index 9a29ab58..c56669e3 100644 --- a/packages/host-daemon-contract/src/local.ts +++ b/packages/host-daemon-contract/src/local.ts @@ -107,6 +107,142 @@ export type StatusResponse = z.infer; export const healthResponseSchema = z.string().min(1); export type HealthResponse = z.infer; +export const providerCliKeyValues = ["codex", "claudeCode"] as const; +export const providerCliKeySchema = z.enum(providerCliKeyValues); +export type ProviderCliKey = z.infer; + +export const providerCliInstallOutputStreamValues = [ + "stdout", + "stderr", +] as const; +export const providerCliInstallOutputStreamSchema = z.enum( + providerCliInstallOutputStreamValues, +); +export type ProviderCliInstallOutputStream = z.infer< + typeof providerCliInstallOutputStreamSchema +>; + +export const providerCliInstallSourceValues = [ + "notInstalled", + "npmGlobal", + "external", +] as const; +export const providerCliInstallSourceSchema = z.enum( + providerCliInstallSourceValues, +); +export type ProviderCliInstallSource = z.infer< + typeof providerCliInstallSourceSchema +>; + +export const providerCliInstallActionKindValues = [ + "install", + "update", +] as const; +export const providerCliInstallActionKindSchema = z.enum( + providerCliInstallActionKindValues, +); +export type ProviderCliInstallActionKind = z.infer< + typeof providerCliInstallActionKindSchema +>; + +export const providerCliInstallCommandKindValues = ["exec", "shell"] as const; +export const providerCliInstallCommandKindSchema = z.enum( + providerCliInstallCommandKindValues, +); +export type ProviderCliInstallCommandKind = z.infer< + typeof providerCliInstallCommandKindSchema +>; + +export const providerCliInstallActionSchema = z.object({ + kind: providerCliInstallActionKindSchema, + label: z.enum(["Install", "Update"]), + commandKind: providerCliInstallCommandKindSchema, + command: z.string().min(1), +}); +export type ProviderCliInstallAction = z.infer< + typeof providerCliInstallActionSchema +>; + +export const providerCliStatusSchema = z.object({ + displayName: z.string().min(1), + executableName: z.string().min(1), + executablePath: z.string().min(1).nullable(), + installed: z.boolean(), + installSource: providerCliInstallSourceSchema, + currentVersion: z.string().min(1).nullable(), + latestVersion: z.string().min(1).nullable(), + npmPackageName: z.string().min(1), + npmGlobalPackageVersion: z.string().min(1).nullable(), + installAction: providerCliInstallActionSchema.nullable(), + needsUpdate: z.boolean(), +}); +export type ProviderCliStatus = z.infer; + +export const providerCliStatusResponseSchema = z.object({ + codex: providerCliStatusSchema, + claudeCode: providerCliStatusSchema, +}); +export type ProviderCliStatusResponse = z.infer< + typeof providerCliStatusResponseSchema +>; + +export const providerCliInstallRequestSchema = z.object({ + provider: providerCliKeySchema, + actionKind: providerCliInstallActionKindSchema, +}); +export type ProviderCliInstallRequest = z.infer< + typeof providerCliInstallRequestSchema +>; + +export const providerCliInstallStartedEventSchema = z.object({ + type: z.literal("started"), + provider: providerCliKeySchema, + command: z.string().min(1), +}); +export type ProviderCliInstallStartedEvent = z.infer< + typeof providerCliInstallStartedEventSchema +>; + +export const providerCliInstallOutputEventSchema = z.object({ + type: z.literal("output"), + provider: providerCliKeySchema, + stream: providerCliInstallOutputStreamSchema, + text: z.string(), +}); +export type ProviderCliInstallOutputEvent = z.infer< + typeof providerCliInstallOutputEventSchema +>; + +export const providerCliInstallCompletedEventSchema = z.object({ + type: z.literal("completed"), + provider: providerCliKeySchema, + exitCode: z.number().int().nullable(), + signal: z.string().min(1).nullable(), + success: z.boolean(), +}); +export type ProviderCliInstallCompletedEvent = z.infer< + typeof providerCliInstallCompletedEventSchema +>; + +export const providerCliInstallErrorEventSchema = z.object({ + type: z.literal("error"), + provider: providerCliKeySchema, + message: z.string().min(1), +}); +export type ProviderCliInstallErrorEvent = z.infer< + typeof providerCliInstallErrorEventSchema +>; + +export const providerCliInstallEventSchema = z.discriminatedUnion("type", [ + providerCliInstallStartedEventSchema, + providerCliInstallOutputEventSchema, + providerCliInstallCompletedEventSchema, + providerCliInstallErrorEventSchema, +]); +export type ProviderCliInstallEvent = z.infer< + typeof providerCliInstallEventSchema +>; + // --------------------------------------------------------------------------- // Route type definition for Hono typed client // --------------------------------------------------------------------------- @@ -130,6 +266,19 @@ export type HostDaemonLocalSchema = { "/status": { $get: Endpoint; }; + "/provider-clis/status": { + /** Checks local Codex and Claude Code CLI install/update status for startup UI nudges. */ + $get: Endpoint; + }; + "/provider-clis/install": { + /** Streams `npm install -g @latest` progress as newline-delimited JSON events. */ + $post: Endpoint< + { json: ProviderCliInstallRequest }, + ProviderCliInstallEvent, + 200, + "text" + >; + }; }; export type HostDaemonLocalRoutes = Hono<{}, HostDaemonLocalSchema, "/">; diff --git a/packages/host-daemon-contract/test/local.test.ts b/packages/host-daemon-contract/test/local.test.ts index 529dad92..43b26f32 100644 --- a/packages/host-daemon-contract/test/local.test.ts +++ b/packages/host-daemon-contract/test/local.test.ts @@ -5,6 +5,9 @@ import { hostPlatformSchema, pathsExistRequestSchema, pathsExistResponseSchema, + providerCliInstallEventSchema, + providerCliInstallRequestSchema, + providerCliStatusResponseSchema, statusResponseSchema, } from "../src/index.js"; @@ -88,3 +91,101 @@ describe("pathsExistResponseSchema", () => { ).toThrow(); }); }); + +describe("provider CLI schemas", () => { + it("accepts status for Codex and Claude Code", () => { + expect( + providerCliStatusResponseSchema.parse({ + codex: { + displayName: "Codex", + executableName: "codex", + executablePath: null, + installed: false, + installSource: "notInstalled", + currentVersion: null, + latestVersion: "0.133.0", + npmPackageName: "@openai/codex", + npmGlobalPackageVersion: null, + installAction: { + kind: "install", + label: "Install", + commandKind: "exec", + command: "npm install -g @openai/codex@latest", + }, + needsUpdate: false, + }, + claudeCode: { + displayName: "Claude Code", + executableName: "claude", + executablePath: "/opt/homebrew/bin/claude", + installed: true, + installSource: "npmGlobal", + currentVersion: "2.1.147", + latestVersion: "2.1.148", + npmPackageName: "@anthropic-ai/claude-code", + npmGlobalPackageVersion: "2.1.147", + installAction: { + kind: "update", + label: "Update", + commandKind: "exec", + command: "claude update", + }, + needsUpdate: true, + }, + }).claudeCode.needsUpdate, + ).toBe(true); + + expect( + providerCliStatusResponseSchema.parse({ + codex: { + displayName: "Codex", + executableName: "codex", + executablePath: "/usr/local/bin/codex", + installed: true, + installSource: "npmGlobal", + currentVersion: "0.133.0", + latestVersion: "0.133.0", + npmPackageName: "@openai/codex", + npmGlobalPackageVersion: "0.133.0", + installAction: null, + needsUpdate: false, + }, + claudeCode: { + displayName: "Claude Code", + executableName: "claude", + executablePath: null, + installed: false, + installSource: "notInstalled", + currentVersion: null, + latestVersion: "2.1.148", + npmPackageName: "@anthropic-ai/claude-code", + npmGlobalPackageVersion: null, + installAction: { + kind: "install", + label: "Install", + commandKind: "shell", + command: "curl -fsSL https://claude.ai/install.sh | bash", + }, + needsUpdate: false, + }, + }).claudeCode.installAction, + ).toMatchObject({ commandKind: "shell" }); + }); + + it("accepts install requests and streamed install events", () => { + expect( + providerCliInstallRequestSchema.parse({ + provider: "codex", + actionKind: "update", + }), + ).toEqual({ provider: "codex", actionKind: "update" }); + expect( + providerCliInstallEventSchema.parse({ + type: "output", + provider: "claudeCode", + stream: "stderr", + text: "installing\n", + }), + ).toMatchObject({ type: "output", stream: "stderr" }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2e46602..6c326994 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,9 +425,15 @@ importers: proper-lockfile: specifier: ^4.1.2 version: 4.1.2 + semver: + specifier: ^7.7.4 + version: 7.7.4 ws: specifier: ^8.19.0 version: 8.19.0 + zod: + specifier: 4.3.6 + version: 4.3.6 devDependencies: '@bb/scripts': specifier: workspace:* @@ -447,6 +453,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.10 + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 esbuild: specifier: ^0.28.0 version: 0.28.0 From 43b2eb5f78e9ed529dd08d175b38901aa17a4434 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 22 May 2026 15:44:44 -0700 Subject: [PATCH 5/7] fix(app): persist provider CLI toast dismissals Update provider CLI health toast dismissal keys so dismissed warnings remain hidden across refetches and reloads. Dismiss stale warning toasts when the underlying provider CLI issue clears. Add UI coverage for dismiss, reappearance, stale cleanup, and localStorage failure behavior. Source: bb/provider-cli-toast-dismissal-v2. --- .../ProviderCliHealthToasts.test.tsx | 299 ++++++++++++++++++ .../provider-cli/ProviderCliHealthToasts.tsx | 65 +++- 2 files changed, 351 insertions(+), 13 deletions(-) create mode 100644 apps/app/src/components/provider-cli/ProviderCliHealthToasts.test.tsx diff --git a/apps/app/src/components/provider-cli/ProviderCliHealthToasts.test.tsx b/apps/app/src/components/provider-cli/ProviderCliHealthToasts.test.tsx new file mode 100644 index 00000000..2fe7c899 --- /dev/null +++ b/apps/app/src/components/provider-cli/ProviderCliHealthToasts.test.tsx @@ -0,0 +1,299 @@ +// @vitest-environment jsdom + +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import type { QueryClient } from "@tanstack/react-query"; +import type { + ProviderCliStatus, + ProviderCliStatusResponse, +} from "@bb/host-daemon-contract"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { localProviderCliStatusQueryKey } from "@/hooks/queries/query-keys"; +import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { installFetchRoutes, jsonResponse } from "@/test/http-test-utils"; +import { ProviderCliHealthToasts } from "./ProviderCliHealthToasts"; + +interface ToastButton { + label: string; + onClick: () => void; +} + +interface ProviderCliWarningToastOptions { + id: string; + description: string; + duration: number; + closeButton: boolean; + action?: ToastButton; + cancel?: ToastButton; + onDismiss?: () => void; +} + +interface ProviderCliToastInvocation { + title: string; + options: ProviderCliWarningToastOptions; +} + +interface ProviderCliHealthFetchState { + hostDaemonPort: number; + status: ProviderCliStatusResponse; +} + +interface ProviderCliHealthRenderResult { + queryClient: QueryClient; + state: ProviderCliHealthFetchState; +} + +const providerCliToastState = vi.hoisted(() => { + const invocations: ProviderCliToastInvocation[] = []; + const activeToasts = new Map(); + const warning = vi.fn( + (title: string, options: ProviderCliWarningToastOptions) => { + invocations.push({ title, options }); + activeToasts.set(options.id, options); + }, + ); + const dismiss = vi.fn((toastId: string | number | undefined) => { + if (typeof toastId !== "string") { + return; + } + const options = activeToasts.get(toastId); + activeToasts.delete(toastId); + options?.onDismiss?.(); + }); + return { + activeToasts, + dismiss, + error: vi.fn(), + invocations, + success: vi.fn(), + warning, + }; +}); + +vi.mock("sonner", () => ({ + toast: { + dismiss: providerCliToastState.dismiss, + error: providerCliToastState.error, + success: providerCliToastState.success, + warning: providerCliToastState.warning, + }, +})); + +const HOST_DAEMON_PORT = 4123; +const CODEX_TOAST_ID = "provider-cli-health:codex"; +const CODEX_MISSING_FINGERPRINT = "codex:missing:0.133.0"; +const DISMISSED_STORAGE_KEY_PREFIX = "bb:provider-cli-toast:dismissed-v2:"; +const CODEX_DISMISSED_STORAGE_KEY = `${DISMISSED_STORAGE_KEY_PREFIX}${CODEX_MISSING_FINGERPRINT}`; + +function codexMissingStatus(): ProviderCliStatus { + return { + currentVersion: null, + displayName: "Codex", + executableName: "codex", + executablePath: null, + installAction: { + command: "npm install -g @openai/codex", + commandKind: "exec", + kind: "install", + label: "Install", + }, + installed: false, + installSource: "notInstalled", + latestVersion: "0.133.0", + needsUpdate: false, + npmGlobalPackageVersion: null, + npmPackageName: "@openai/codex", + }; +} + +function codexInstalledStatus(): ProviderCliStatus { + return { + currentVersion: "0.133.0", + displayName: "Codex", + executableName: "codex", + executablePath: "/usr/local/bin/codex", + installAction: null, + installed: true, + installSource: "npmGlobal", + latestVersion: "0.133.0", + needsUpdate: false, + npmGlobalPackageVersion: "0.133.0", + npmPackageName: "@openai/codex", + }; +} + +function claudeCodeInstalledStatus(): ProviderCliStatus { + return { + currentVersion: "1.0.0", + displayName: "Claude Code", + executableName: "claude", + executablePath: "/usr/local/bin/claude", + installAction: null, + installed: true, + installSource: "npmGlobal", + latestVersion: "1.0.0", + needsUpdate: false, + npmGlobalPackageVersion: "1.0.0", + npmPackageName: "@anthropic-ai/claude-code", + }; +} + +function statusResponseWithCodex( + codex: ProviderCliStatus, +): ProviderCliStatusResponse { + return { + claudeCode: claudeCodeInstalledStatus(), + codex, + }; +} + +function installProviderCliHealthFetchRoutes( + state: ProviderCliHealthFetchState, +): void { + installFetchRoutes([ + { + pathname: "/api/v1/system/config", + handler: async () => + jsonResponse({ + hostDaemonPort: state.hostDaemonPort, + voiceTranscriptionEnabled: false, + }), + }, + { + pathname: "/provider-clis/status", + port: state.hostDaemonPort, + handler: async () => jsonResponse(state.status), + }, + ]); +} + +function renderProviderCliHealthToasts( + initialStatus: ProviderCliStatusResponse, +): ProviderCliHealthRenderResult { + const state: ProviderCliHealthFetchState = { + hostDaemonPort: HOST_DAEMON_PORT, + status: initialStatus, + }; + installProviderCliHealthFetchRoutes(state); + + const { queryClient, wrapper } = createQueryClientTestHarness(); + render(, { wrapper }); + + return { queryClient, state }; +} + +function resetProviderCliToastState(): void { + providerCliToastState.activeToasts.clear(); + providerCliToastState.invocations.splice(0); + providerCliToastState.dismiss.mockClear(); + providerCliToastState.error.mockClear(); + providerCliToastState.success.mockClear(); + providerCliToastState.warning.mockClear(); +} + +function requireLatestCodexToastInvocation(): ProviderCliToastInvocation { + for ( + let index = providerCliToastState.invocations.length - 1; + index >= 0; + index -= 1 + ) { + const invocation = providerCliToastState.invocations[index]; + if (invocation.options.id === CODEX_TOAST_ID) { + return invocation; + } + } + throw new Error("Expected a Codex provider CLI toast invocation."); +} + +async function waitForVisibleCodexToast(): Promise { + await waitFor(() => { + expect(providerCliToastState.activeToasts.has(CODEX_TOAST_ID)).toBe(true); + }); + return requireLatestCodexToastInvocation(); +} + +function clickToastCancel(invocation: ProviderCliToastInvocation): void { + const cancel = invocation.options.cancel; + if (!cancel) { + throw new Error("Expected provider CLI toast to have a cancel action."); + } + cancel.onClick(); +} + +async function refetchProviderCliStatus( + result: ProviderCliHealthRenderResult, +): Promise { + await act(async () => { + await result.queryClient.refetchQueries({ + queryKey: localProviderCliStatusQueryKey(result.state.hostDaemonPort), + }); + }); +} + +afterEach(() => { + cleanup(); + resetProviderCliToastState(); + window.localStorage.clear(); + vi.unstubAllGlobals(); +}); + +describe("ProviderCliHealthToasts", () => { + it("does not persist dismissal when the toast closes without the cancel button", async () => { + renderProviderCliHealthToasts(statusResponseWithCodex(codexMissingStatus())); + + const invocation = await waitForVisibleCodexToast(); + + act(() => { + providerCliToastState.dismiss(invocation.options.id); + }); + + expect(window.localStorage.getItem(CODEX_DISMISSED_STORAGE_KEY)).toBeNull(); + expect(providerCliToastState.activeToasts.has(CODEX_TOAST_ID)).toBe(false); + }); + + it("persists dismissal when the user clicks the cancel button", async () => { + renderProviderCliHealthToasts(statusResponseWithCodex(codexMissingStatus())); + + const invocation = await waitForVisibleCodexToast(); + + act(() => { + clickToastCancel(invocation); + }); + + expect(window.localStorage.getItem(CODEX_DISMISSED_STORAGE_KEY)).toBe( + "true", + ); + expect(providerCliToastState.activeToasts.has(CODEX_TOAST_ID)).toBe(false); + }); + + it("clears resolved issue state so a recurring missing CLI shows again", async () => { + const result = renderProviderCliHealthToasts( + statusResponseWithCodex(codexMissingStatus()), + ); + await waitForVisibleCodexToast(); + window.localStorage.setItem(CODEX_DISMISSED_STORAGE_KEY, "true"); + + result.state.status = statusResponseWithCodex(codexInstalledStatus()); + await refetchProviderCliStatus(result); + + await waitFor(() => { + expect(providerCliToastState.activeToasts.has(CODEX_TOAST_ID)).toBe( + false, + ); + expect( + window.localStorage.getItem(CODEX_DISMISSED_STORAGE_KEY), + ).toBeNull(); + }); + + const toastInvocationCountAfterResolve = + providerCliToastState.invocations.length; + result.state.status = statusResponseWithCodex(codexMissingStatus()); + await refetchProviderCliStatus(result); + + await waitFor(() => { + expect(providerCliToastState.invocations.length).toBe( + toastInvocationCountAfterResolve + 1, + ); + expect(providerCliToastState.activeToasts.has(CODEX_TOAST_ID)).toBe(true); + }); + }); +}); diff --git a/apps/app/src/components/provider-cli/ProviderCliHealthToasts.tsx b/apps/app/src/components/provider-cli/ProviderCliHealthToasts.tsx index f390bb6e..2ba003aa 100644 --- a/apps/app/src/components/provider-cli/ProviderCliHealthToasts.tsx +++ b/apps/app/src/components/provider-cli/ProviderCliHealthToasts.tsx @@ -60,7 +60,8 @@ interface ProviderCliInstallDialogProps { onClose: () => void; } -const DISMISSED_STORAGE_KEY_PREFIX = "bb:provider-cli-health:dismissed:"; +const PROVIDER_CLI_TOAST_DISMISSED_STORAGE_KEY_PREFIX = + "bb:provider-cli-toast:dismissed-v2:"; function getLocalStorage(): Storage | null { if (typeof window === "undefined") { @@ -74,7 +75,7 @@ function getLocalStorage(): Storage | null { } function dismissedStorageKey(fingerprint: string): string { - return `${DISMISSED_STORAGE_KEY_PREFIX}${fingerprint}`; + return `${PROVIDER_CLI_TOAST_DISMISSED_STORAGE_KEY_PREFIX}${fingerprint}`; } function isDismissedForFingerprint(fingerprint: string): boolean { @@ -101,6 +102,18 @@ function markDismissedForFingerprint(fingerprint: string): void { } } +function clearDismissedForFingerprint(fingerprint: string): void { + const storage = getLocalStorage(); + if (storage === null) { + return; + } + try { + storage.removeItem(dismissedStorageKey(fingerprint)); + } catch { + // Clearing localStorage is best-effort; refs still reset this session. + } +} + function providerCliEntries( status: ProviderCliStatusResponse, ): ProviderCliStatusEntry[] { @@ -154,6 +167,12 @@ function buildProviderCliIssue( return null; } +function isProviderCliToastIssue( + issue: ProviderCliToastIssue | null, +): issue is ProviderCliToastIssue { + return issue !== null; +} + function appendInstallLog(log: string, text: string): string { if (text.length === 0) { return log; @@ -315,6 +334,9 @@ export function ProviderCliHealthToasts() { }); const dismissedFingerprintsRef = useRef>(new Set()); const shownFingerprintsRef = useRef>(new Set()); + const activeIssuesRef = useRef>( + new Map(), + ); const runningProviderRef = useRef(null); const [installState, setInstallState] = useState(null); @@ -324,6 +346,11 @@ export function ProviderCliHealthToasts() { markDismissedForFingerprint(issue.fingerprint); }, []); + const clearIssueDismissal = useCallback((issue: ProviderCliToastIssue) => { + dismissedFingerprintsRef.current.delete(issue.fingerprint); + clearDismissedForFingerprint(issue.fingerprint); + }, []); + const dismissIssue = useCallback( (issue: ProviderCliToastIssue) => { markIssueDismissed(issue); @@ -414,11 +441,29 @@ export function ProviderCliHealthToasts() { return; } - for (const entry of providerCliEntries(data)) { - const issue = buildProviderCliIssue(entry); - if (!issue) { - continue; + const currentIssues = providerCliEntries(data) + .map(buildProviderCliIssue) + .filter(isProviderCliToastIssue); + const currentIssuesByFingerprint = new Map< + string, + ProviderCliToastIssue + >(); + + for (const issue of currentIssues) { + currentIssuesByFingerprint.set(issue.fingerprint, issue); + } + + for (const previousIssue of activeIssuesRef.current.values()) { + if (!currentIssuesByFingerprint.has(previousIssue.fingerprint)) { + toast.dismiss(previousIssue.toastId); + shownFingerprintsRef.current.delete(previousIssue.fingerprint); + clearIssueDismissal(previousIssue); } + } + + activeIssuesRef.current = currentIssuesByFingerprint; + + for (const issue of currentIssues) { if ( dismissedFingerprintsRef.current.has(issue.fingerprint) || isDismissedForFingerprint(issue.fingerprint) @@ -445,9 +490,6 @@ export function ProviderCliHealthToasts() { label: "Dismiss", onClick: () => dismissIssue(issue), }, - onDismiss: () => { - markIssueDismissed(issue); - }, }); } else { toast.warning(issue.title, { @@ -459,13 +501,10 @@ export function ProviderCliHealthToasts() { label: "Dismiss", onClick: () => dismissIssue(issue), }, - onDismiss: () => { - markIssueDismissed(issue); - }, }); } } - }, [dismissIssue, markIssueDismissed, providerCliStatus.data, startInstall]); + }, [clearIssueDismissal, dismissIssue, providerCliStatus.data, startInstall]); return ( Date: Fri, 22 May 2026 16:18:39 -0700 Subject: [PATCH 6/7] desktop: add developer tools menu item Add a View menu toggle for Electron developer tools with the Cmd+Option+I accelerator. Cover the desktop menu template so the toggle remains wired to the built-in toggleDevTools role. Source: bb/desktop-enable-devtools-menu-item-cmd-option-i-s-thr_kzjvkt4mzx (thr_kzjvkt4mzx). --- apps/desktop/src/menu.ts | 7 +++++++ apps/desktop/test/menu.test.ts | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/apps/desktop/src/menu.ts b/apps/desktop/src/menu.ts index 8699ae23..4e2855fa 100644 --- a/apps/desktop/src/menu.ts +++ b/apps/desktop/src/menu.ts @@ -1,6 +1,8 @@ import { app, Menu, type MenuItemConstructorOptions } from "electron"; export const SERVER_DAEMON_LOGS_MENU_LABEL = "Server & Daemon Logs"; +export const TOGGLE_DEVELOPER_TOOLS_MENU_LABEL = "Toggle Developer Tools"; +export const TOGGLE_DEVELOPER_TOOLS_ACCELERATOR = "Command+Option+I"; export interface InstallApplicationMenuArgs { createNewWindow(): void; @@ -72,6 +74,11 @@ export function buildApplicationMenuTemplate( submenu: [ { role: "reload" }, { role: "forceReload" }, + { + accelerator: TOGGLE_DEVELOPER_TOOLS_ACCELERATOR, + label: TOGGLE_DEVELOPER_TOOLS_MENU_LABEL, + role: "toggleDevTools", + }, { type: "separator" }, { role: "resetZoom" }, { role: "zoomIn" }, diff --git a/apps/desktop/test/menu.test.ts b/apps/desktop/test/menu.test.ts index 9b142ef8..5e1410b9 100644 --- a/apps/desktop/test/menu.test.ts +++ b/apps/desktop/test/menu.test.ts @@ -2,6 +2,8 @@ import type { MenuItemConstructorOptions } from "electron"; import { describe, expect, it, vi } from "vitest"; import { SERVER_DAEMON_LOGS_MENU_LABEL, + TOGGLE_DEVELOPER_TOOLS_ACCELERATOR, + TOGGLE_DEVELOPER_TOOLS_MENU_LABEL, buildApplicationMenuTemplate, } from "../src/menu.js"; @@ -41,6 +43,24 @@ function findSubmenuItem( } describe("application menu", () => { + it("shows a developer tools toggle in the view menu", () => { + const template = buildApplicationMenuTemplate({ + createNewWindow() {}, + openServerDaemonLogs() {}, + serverDaemonLogsMenuEnabled: true, + }); + + const menuItem = findSubmenuItem({ + itemLabel: TOGGLE_DEVELOPER_TOOLS_MENU_LABEL, + parentLabel: "View", + template, + }); + + expect(menuItem).not.toBeNull(); + expect(menuItem?.accelerator).toBe(TOGGLE_DEVELOPER_TOOLS_ACCELERATOR); + expect(menuItem?.role).toBe("toggleDevTools"); + }); + it("shows an enabled server and daemon logs item for owned runtimes", () => { const template = buildApplicationMenuTemplate({ createNewWindow() {}, From 654306e8e6db720bbd1bf2e2e04c887fae3fa994 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 22 May 2026 16:33:30 -0700 Subject: [PATCH 7/7] fix(desktop): refresh packaged renderer cache Clear the Electron default session HTTP cache on packaged desktop startup. Serve index HTML with no-store while keeping hashed static assets immutable. Add desktop session-cache and server static-header coverage. Source: bb/fix-prod-server-no-cache-headers-desktop-session-thr_9fmhce2btp (thr_9fmhce2btp). --- apps/desktop/src/desktop-session-cache.ts | 18 ++++++++ apps/desktop/src/main.ts | 6 +++ .../test/desktop-session-cache.test.ts | 37 ++++++++++++++++ apps/server/src/server.ts | 28 ++++++++++++- apps/server/test/app/static-cache.test.ts | 42 +++++++++++++++++++ 5 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/desktop-session-cache.ts create mode 100644 apps/desktop/test/desktop-session-cache.test.ts create mode 100644 apps/server/test/app/static-cache.test.ts diff --git a/apps/desktop/src/desktop-session-cache.ts b/apps/desktop/src/desktop-session-cache.ts new file mode 100644 index 00000000..f41219bd --- /dev/null +++ b/apps/desktop/src/desktop-session-cache.ts @@ -0,0 +1,18 @@ +export interface DesktopSessionHttpCache { + clearCache(): Promise; +} + +export interface ClearPackagedSessionHttpCacheArgs { + isPackaged: boolean; + session: DesktopSessionHttpCache; +} + +export async function clearPackagedSessionHttpCache( + args: ClearPackagedSessionHttpCacheArgs, +): Promise { + if (!args.isPackaged) { + return; + } + + await args.session.clearCache(); +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9ae10db0..2532f700 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -7,6 +7,7 @@ import { clipboard, ipcMain, nativeImage, + session, shell, type Event, } from "electron"; @@ -65,6 +66,7 @@ import { BB_DESKTOP_INSTALL_UPDATE_CHANNEL, } from "./desktop-update-ipc.js"; import { ensurePackagedMacOsUserShellPath } from "./desktop-shell-path.js"; +import { clearPackagedSessionHttpCache } from "./desktop-session-cache.js"; import { createLogTailer, createLogLineBuffer, @@ -832,6 +834,10 @@ async function runDesktopApp(): Promise { }); await app.whenReady(); + await clearPackagedSessionHttpCache({ + isPackaged: app.isPackaged, + session: session.defaultSession, + }); const paths = createDesktopPathContext(); const iconPath = resolveDesktopAssetPath({ diff --git a/apps/desktop/test/desktop-session-cache.test.ts b/apps/desktop/test/desktop-session-cache.test.ts new file mode 100644 index 00000000..587e5e65 --- /dev/null +++ b/apps/desktop/test/desktop-session-cache.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + clearPackagedSessionHttpCache, + type DesktopSessionHttpCache, +} from "../src/desktop-session-cache.js"; + +class DesktopSessionHttpCacheStub implements DesktopSessionHttpCache { + clearCacheCalls = 0; + + async clearCache(): Promise { + this.clearCacheCalls += 1; + } +} + +describe("desktop session cache clearing", () => { + it("clears Electron's HTTP cache for packaged launches", async () => { + const session = new DesktopSessionHttpCacheStub(); + + await clearPackagedSessionHttpCache({ + isPackaged: true, + session, + }); + + expect(session.clearCacheCalls).toBe(1); + }); + + it("leaves the session cache alone in development", async () => { + const session = new DesktopSessionHttpCacheStub(); + + await clearPackagedSessionHttpCache({ + isPackaged: false, + session, + }); + + expect(session.clearCacheCalls).toBe(0); + }); +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2041ecf4..293d83ba 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -88,6 +88,13 @@ interface CreateAppOptions { staticDir?: string; } +interface StaticResponseHeadersArgs { + contentType: string; + urlPath: string; +} + +const STATIC_INDEX_CACHE_CONTROL = "no-store"; +const STATIC_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"; const WEB_SOCKET_SHUTDOWN_CODE = 1001; const WEB_SOCKET_SHUTDOWN_FORCE_CLOSE_MS = 1_000; const WEB_SOCKET_SHUTDOWN_REASON = "server-shutdown"; @@ -108,6 +115,20 @@ function shouldLogSlowApiRequest(args: ShouldLogSlowApiRequestArgs): boolean { return !THREAD_EVENT_WAIT_PATH_PATTERN.test(args.path); } +function createStaticResponseHeaders( + args: StaticResponseHeadersArgs, +): Headers { + const headers = new Headers(); + headers.set("content-type", args.contentType); + headers.set( + "cache-control", + args.urlPath.startsWith("/assets/") + ? STATIC_ASSET_CACHE_CONTROL + : STATIC_INDEX_CACHE_CONTROL, + ); + return headers; +} + function buildAllowedCorsOrigins(deps: AppDeps): Set { const originArgs: BuildLocalAppOriginsArgs = { serverPort: deps.config.serverPort, @@ -347,7 +368,7 @@ export function createApp( const contentType = MIME[extname(filePath)] ?? "application/octet-stream"; return new Response(content, { - headers: { "content-type": contentType }, + headers: createStaticResponseHeaders({ contentType, urlPath }), }); } } catch { @@ -355,7 +376,10 @@ export function createApp( } const indexHtml = await readFile(join(root, "index.html")); return new Response(indexHtml, { - headers: { "content-type": "text/html" }, + headers: createStaticResponseHeaders({ + contentType: "text/html", + urlPath: "/index.html", + }), }); }); } diff --git a/apps/server/test/app/static-cache.test.ts b/apps/server/test/app/static-cache.test.ts new file mode 100644 index 00000000..171cf93a --- /dev/null +++ b/apps/server/test/app/static-cache.test.ts @@ -0,0 +1,42 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/server.js"; +import { createTestAppHarness } from "../helpers/test-app.js"; + +describe("production static cache headers", () => { + it("keeps index.html fresh while allowing immutable hashed assets", async () => { + const staticDir = await mkdtemp(join(tmpdir(), "bb-server-static-")); + await mkdir(join(staticDir, "assets"), { recursive: true }); + await writeFile( + join(staticDir, "index.html"), + "", + ); + await writeFile( + join(staticDir, "assets", "index-test.js"), + "console.log('fresh bundle');", + ); + + const harness = await createTestAppHarness(); + const serverApp = createApp(harness.deps, { staticDir }); + try { + const rootResponse = await serverApp.app.request("/"); + expect(rootResponse.headers.get("cache-control")).toBe("no-store"); + + const fallbackResponse = await serverApp.app.request("/threads/thr_123"); + expect(fallbackResponse.headers.get("cache-control")).toBe("no-store"); + + const assetResponse = await serverApp.app.request( + "/assets/index-test.js", + ); + expect(assetResponse.headers.get("cache-control")).toBe( + "public, max-age=31536000, immutable", + ); + } finally { + await serverApp.closeWebSockets(); + await harness.cleanup(); + await rm(staticDir, { force: true, recursive: true }); + } + }); +});