From 6f3bd895e72b737b8dcf1898a982aad661593b11 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:15:13 +0100 Subject: [PATCH 01/30] Add provider version advisories --- apps/server/package.json | 2 +- .../generate-provider-tested-versions.ts | 151 ++++++++++++ .../src/provider/Drivers/ClaudeDriver.ts | 9 + .../src/provider/Drivers/CodexDriver.ts | 9 + .../src/provider/Drivers/CursorDriver.ts | 2 + .../src/provider/Drivers/OpenCodeDriver.ts | 9 + .../src/provider/Layers/CodexProvider.ts | 1 - .../src/provider/Layers/CursorProvider.ts | 72 ++++-- .../src/provider/Layers/OpenCodeProvider.ts | 1 - .../src/provider/Services/ServerProvider.ts | 2 + .../makeManagedServerProvider.test.ts | 10 + .../src/provider/makeManagedServerProvider.ts | 2 + apps/server/src/provider/providerSnapshot.ts | 10 + .../providerTestedVersions.generated.json | 17 ++ .../provider/providerVersionLifecycle.test.ts | 45 ++++ .../src/provider/providerVersionLifecycle.ts | 224 ++++++++++++++++++ .../settings/ProviderInstanceCard.tsx | 60 ++++- .../src/components/settings/providerStatus.ts | 27 ++- packages/contracts/src/server.test.ts | 1 + packages/contracts/src/server.ts | 20 ++ 20 files changed, 644 insertions(+), 30 deletions(-) create mode 100644 apps/server/scripts/generate-provider-tested-versions.ts create mode 100644 apps/server/src/provider/providerTestedVersions.generated.json create mode 100644 apps/server/src/provider/providerVersionLifecycle.test.ts create mode 100644 apps/server/src/provider/providerVersionLifecycle.ts diff --git a/apps/server/package.json b/apps/server/package.json index c562e0401e..26f4368e42 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,7 +16,7 @@ "type": "module", "scripts": { "dev": "node --watch src/bin.ts", - "build": "node scripts/cli.ts build", + "build": "node scripts/generate-provider-tested-versions.ts && node scripts/cli.ts build", "build:bundle": "tsdown", "start": "node dist/bin.mjs", "prepare": "effect-language-service patch", diff --git a/apps/server/scripts/generate-provider-tested-versions.ts b/apps/server/scripts/generate-provider-tested-versions.ts new file mode 100644 index 0000000000..1bb2fa282c --- /dev/null +++ b/apps/server/scripts/generate-provider-tested-versions.ts @@ -0,0 +1,151 @@ +#!/usr/bin/env node + +import { exec, execFile } from "node:child_process"; +import { mkdir, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const execAsync = promisify(exec); + +const OUTPUT_PATH = path.join( + import.meta.dirname, + "..", + "src", + "provider", + "providerTestedVersions.generated.json", +); + +const PROVIDERS = { + codex: { + binary: process.env.T3CODE_TESTED_CODEX_BINARY || "codex", + args: ["--version"], + parse: parseSemver, + }, + claudeAgent: { + binary: process.env.T3CODE_TESTED_CLAUDE_BINARY || "claude", + args: ["--version"], + parse: parseSemver, + }, + cursor: { + binary: process.env.T3CODE_TESTED_CURSOR_BINARY || "agent", + args: ["about", "--format", "json"], + parse: parseCursorAboutVersion, + }, + opencode: { + binary: process.env.T3CODE_TESTED_OPENCODE_BINARY || "opencode", + args: ["--version"], + parse: parseSemver, + }, +} as const; + +function quoteWindowsCommandArg(value: string): string { + return `"${value.replace(/"/g, '\\"')}"`; +} + +async function resolveWindowsBinary(binary: string): Promise { + if (binary.includes("\\") || binary.includes("/") || path.extname(binary)) { + return binary; + } + + try { + const result = await execFileAsync("where.exe", [binary], { + timeout: 2_000, + windowsHide: true, + }); + const candidates = result.stdout + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean); + return ( + candidates.find((candidate) => candidate.toLowerCase().endsWith(".exe")) ?? + candidates.find((candidate) => candidate.toLowerCase().endsWith(".cmd")) ?? + candidates[0] ?? + binary + ); + } catch { + return binary; + } +} + +async function execProviderCommand(binary: string, args: ReadonlyArray) { + if (process.platform !== "win32") { + return execFileAsync(binary, [...args], { + timeout: 5_000, + windowsHide: true, + }); + } + + const resolvedBinary = await resolveWindowsBinary(binary); + if (resolvedBinary.toLowerCase().endsWith(".exe")) { + return execFileAsync(resolvedBinary, [...args], { + timeout: 5_000, + windowsHide: true, + }); + } + + const commandLine = [resolvedBinary, ...args].map(quoteWindowsCommandArg).join(" "); + return execAsync(commandLine, { + timeout: 5_000, + windowsHide: true, + }); +} + +function parseSemver(output: string): string | null { + return output.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/)?.[1] ?? null; +} + +function parseCursorAboutVersion(output: string): string | null { + try { + const parsed = JSON.parse(output) as { cliVersion?: unknown }; + return typeof parsed.cliVersion === "string" && parsed.cliVersion.trim() + ? parsed.cliVersion.trim() + : null; + } catch { + return output.match(/^CLI Version\s{2,}(.+)$/im)?.[1]?.trim() ?? null; + } +} + +async function probeProviderVersion(provider: keyof typeof PROVIDERS): Promise { + const config = PROVIDERS[provider]; + const envKey = `T3CODE_TESTED_${provider.toUpperCase()}_VERSION`; + const override = process.env[envKey]?.trim(); + if (override) { + return override; + } + + try { + const result = await execProviderCommand(config.binary, config.args); + return config.parse(`${result.stdout}\n${result.stderr}`); + } catch { + if (provider === "cursor") { + try { + const result = await execProviderCommand(config.binary, ["about"]); + return config.parse(`${result.stdout}\n${result.stderr}`); + } catch { + return null; + } + } + return null; + } +} + +async function main(): Promise { + const entries = await Promise.all( + Object.keys(PROVIDERS).map(async (provider) => [ + provider, + { testedVersion: await probeProviderVersion(provider as keyof typeof PROVIDERS) }, + ]), + ); + const manifest = { + generatedAt: new Date().toISOString(), + providers: Object.fromEntries(entries), + }; + + await mkdir(path.dirname(OUTPUT_PATH), { recursive: true }); + const tmpPath = `${OUTPUT_PATH}.tmp`; + await writeFile(tmpPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + await rename(tmpPath, OUTPUT_PATH); +} + +await main(); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 311f495865..4f3bd9d94f 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -34,6 +34,10 @@ import { } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + getProviderVersionLifecycle, +} from "../providerVersionLifecycle.ts"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -121,11 +125,16 @@ export const ClaudeDriver: ProviderDriver = { ); const snapshot = yield* makeManagedServerProvider({ + versionLifecycle: getProviderVersionLifecycle(DRIVER_KIND), getSettings: Effect.succeed(effectiveConfig), streamSettings: Stream.never, haveSettingsChanged: () => false, initialSnapshot: (settings) => stampIdentity(makePendingClaudeProvider(settings)), checkProvider, + enrichSnapshot: ({ snapshot, publishSnapshot }) => + Effect.promise(() => enrichProviderSnapshotWithVersionAdvisory(snapshot)).pipe( + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 26fffd5e21..d180e74847 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -35,6 +35,10 @@ import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + getProviderVersionLifecycle, +} from "../providerVersionLifecycle.ts"; import { codexContinuationIdentity, materializeCodexShadowHome, @@ -138,11 +142,16 @@ export const CodexDriver: ProviderDriver = { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const snapshot = yield* makeManagedServerProvider({ + versionLifecycle: getProviderVersionLifecycle(DRIVER_KIND), getSettings: Effect.succeed(effectiveConfig), streamSettings: Stream.never, haveSettingsChanged: () => false, initialSnapshot: (settings) => stampIdentity(makePendingCodexProvider(settings)), checkProvider, + enrichSnapshot: ({ snapshot, publishSnapshot }) => + Effect.promise(() => enrichProviderSnapshotWithVersionAdvisory(snapshot)).pipe( + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index cd058800f2..36f8dea190 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -34,6 +34,7 @@ import { } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { getProviderVersionLifecycle } from "../providerVersionLifecycle.ts"; const DRIVER_KIND = ProviderDriverKind.make("cursor"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); @@ -103,6 +104,7 @@ export const CursorDriver: ProviderDriver = { ); const snapshot = yield* makeManagedServerProvider({ + versionLifecycle: getProviderVersionLifecycle(DRIVER_KIND), getSettings: Effect.succeed(effectiveConfig), streamSettings: Stream.never, haveSettingsChanged: () => false, diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 27f98a9830..ee304b3338 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -34,6 +34,10 @@ import { } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + getProviderVersionLifecycle, +} from "../providerVersionLifecycle.ts"; const DRIVER_KIND = ProviderDriverKind.make("opencode"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); @@ -102,11 +106,16 @@ export const OpenCodeDriver: ProviderDriver ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); const snapshot = yield* makeManagedServerProvider({ + versionLifecycle: getProviderVersionLifecycle(DRIVER_KIND), getSettings: Effect.succeed(effectiveConfig), streamSettings: Stream.never, haveSettingsChanged: () => false, initialSnapshot: (settings) => stampIdentity(makePendingOpenCodeProvider(settings)), checkProvider, + enrichSnapshot: ({ snapshot, publishSnapshot }) => + Effect.promise(() => enrichProviderSnapshotWithVersionAdvisory(snapshot)).pipe( + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 0917d842a6..618103883a 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -15,7 +15,6 @@ import type { import { ServerSettingsError } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; - import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { scopedSafeTeardown } from "./scopedSafeTeardown.ts"; diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index ad52f63fbb..f290477c6f 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -29,6 +29,7 @@ import { type CommandResult, type ServerProviderDraft, } from "../providerSnapshot.ts"; +import { enrichProviderSnapshotWithVersionAdvisory } from "../providerVersionLifecycle.ts"; import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; const PROVIDER = ProviderDriverKind.make("cursor"); @@ -1222,36 +1223,57 @@ export const enrichCursorSnapshot = (input: { const { settings, snapshot, publishSnapshot } = input; const stampIdentity = input.stampIdentity ?? ((value) => value); - if ( - !settings.enabled || - snapshot.auth.status === "unauthenticated" || - !hasUncapturedCursorModels(snapshot) - ) { - return Effect.void; - } + const enrichVersionAdvisory = Effect.promise(() => + enrichProviderSnapshotWithVersionAdvisory(snapshot), + ).pipe( + Effect.flatMap((enrichedSnapshot) => + publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)), + ), + Effect.catchCause((cause) => + Effect.logWarning("Cursor version advisory enrichment failed", { + cause: Cause.pretty(cause), + }).pipe(Effect.as(snapshot)), + ), + ); - return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models, input.environment).pipe( - Effect.flatMap((discoveredModels) => { - if (discoveredModels.length === 0) { + return enrichVersionAdvisory.pipe( + Effect.flatMap((baseSnapshot) => { + if ( + !settings.enabled || + baseSnapshot.auth.status === "unauthenticated" || + !hasUncapturedCursorModels(baseSnapshot) + ) { return Effect.void; } - return publishSnapshot( - stampIdentity({ - ...snapshot, - models: providerModelsFromSettings( - discoveredModels, - PROVIDER, - settings.customModels, - EMPTY_CAPABILITIES, - ), + + return discoverCursorModelCapabilitiesViaAcp( + settings, + baseSnapshot.models, + input.environment, + ).pipe( + Effect.flatMap((discoveredModels) => { + if (discoveredModels.length === 0) { + return Effect.void; + } + return publishSnapshot( + stampIdentity({ + ...baseSnapshot, + models: providerModelsFromSettings( + discoveredModels, + PROVIDER, + settings.customModels, + EMPTY_CAPABILITIES, + ), + }), + ); }), + Effect.catchCause((cause) => + Effect.logWarning("Cursor ACP background capability enrichment failed", { + models: baseSnapshot.models.map((model) => model.slug), + cause: Cause.pretty(cause), + }).pipe(Effect.asVoid), + ), ); }), - Effect.catchCause((cause) => - Effect.logWarning("Cursor ACP background capability enrichment failed", { - models: snapshot.models.map((model) => model.slug), - cause: Cause.pretty(cause), - }).pipe(Effect.asVoid), - ), ); }; diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index c7487d7d52..6431282d63 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -7,7 +7,6 @@ import { import { Cause, Data, Effect } from "effect"; import { createModelCapabilities } from "@t3tools/shared/model"; - import { buildServerProvider, nonEmptyTrimmed, diff --git a/apps/server/src/provider/Services/ServerProvider.ts b/apps/server/src/provider/Services/ServerProvider.ts index 4df0bc8fc2..fabd2d003f 100644 --- a/apps/server/src/provider/Services/ServerProvider.ts +++ b/apps/server/src/provider/Services/ServerProvider.ts @@ -1,7 +1,9 @@ import type { ServerProvider } from "@t3tools/contracts"; import type { Effect, Stream } from "effect"; +import type { ProviderVersionLifecycle } from "../providerVersionLifecycle.ts"; export interface ServerProviderShape { + readonly versionLifecycle: ProviderVersionLifecycle; readonly getSnapshot: Effect.Effect; readonly refresh: Effect.Effect; readonly streamChanges: Stream.Stream; diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index ff66476380..f440ea1c64 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -20,6 +20,12 @@ interface TestSettings { readonly enabled: boolean; } +const versionLifecycle = { + provider: "codex", + packageName: "@openai/codex", + updateCommand: "npm install -g @openai/codex@latest", +} as const; + const initialSnapshot: ServerProvider = { instanceId: ProviderInstanceId.make("codex"), driver: ProviderDriverKind.make("codex"), @@ -90,6 +96,7 @@ describe("makeManagedServerProvider", () => { const checkCalls = yield* Ref.make(0); const releaseCheck = yield* Deferred.make(); const provider = yield* makeManagedServerProvider({ + versionLifecycle, getSettings: Effect.succeed({ enabled: true }), streamSettings: Stream.empty, haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, @@ -131,6 +138,7 @@ describe("makeManagedServerProvider", () => { const releaseInitialCheck = yield* Deferred.make(); const releaseSettingsCheck = yield* Deferred.make(); const provider = yield* makeManagedServerProvider({ + versionLifecycle, getSettings: Ref.get(settingsRef), streamSettings: Stream.fromPubSub(settingsChanges), haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, @@ -172,6 +180,7 @@ describe("makeManagedServerProvider", () => { const releaseEnrichment = yield* Deferred.make(); const releaseCheck = yield* Deferred.make(); const provider = yield* makeManagedServerProvider({ + versionLifecycle, getSettings: Effect.succeed({ enabled: true }), streamSettings: Stream.empty, haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, @@ -212,6 +221,7 @@ describe("makeManagedServerProvider", () => { const secondCallbackReady = yield* Deferred.make(); const allowFirstRefresh = yield* Deferred.make(); const provider = yield* makeManagedServerProvider({ + versionLifecycle, getSettings: Effect.succeed({ enabled: true }), streamSettings: Stream.empty, haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 4787a9f9cb..115ec04b14 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -13,6 +13,7 @@ interface ProviderSnapshotState { export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < Settings, >(input: { + readonly versionLifecycle: ServerProviderShape["versionLifecycle"]; readonly getSettings: Effect.Effect; readonly streamSettings: Stream.Stream; readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; @@ -143,6 +144,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ); return { + versionLifecycle: input.versionLifecycle, getSnapshot: input.getSettings.pipe( Effect.flatMap(applySnapshot), Effect.tapError(Effect.logError), diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index af0c91274c..1db7f219a2 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -12,6 +12,7 @@ import { Effect, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { isWindowsCommandNotFound } from "../processRunner.ts"; +import { createProviderVersionAdvisory } from "./providerVersionLifecycle.ts"; export const DEFAULT_TIMEOUT_MS = 4_000; // Auth status checks involve disk/network lookups and can be slow on first run (especially Windows) @@ -182,6 +183,7 @@ export function buildBooleanOptionDescriptor(input: { } export function buildServerProvider(input: { + driver?: ProviderDriverKind; presentation: ServerProviderPresentation; enabled: boolean; checkedAt: string; @@ -190,6 +192,13 @@ export function buildServerProvider(input: { skills?: ReadonlyArray; probe: ProviderProbeResult; }): ServerProviderDraft { + const versionAdvisory = input.driver + ? createProviderVersionAdvisory({ + driver: input.driver, + currentVersion: input.probe.version, + checkedAt: input.checkedAt, + }) + : undefined; return { displayName: input.presentation.displayName, ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), @@ -206,6 +215,7 @@ export function buildServerProvider(input: { models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], + ...(versionAdvisory ? { versionAdvisory } : {}), }; } diff --git a/apps/server/src/provider/providerTestedVersions.generated.json b/apps/server/src/provider/providerTestedVersions.generated.json new file mode 100644 index 0000000000..4fc87be0aa --- /dev/null +++ b/apps/server/src/provider/providerTestedVersions.generated.json @@ -0,0 +1,17 @@ +{ + "generatedAt": "2026-04-22T15:07:09.413Z", + "providers": { + "codex": { + "testedVersion": "0.121.0" + }, + "claudeAgent": { + "testedVersion": "2.0.14" + }, + "cursor": { + "testedVersion": null + }, + "opencode": { + "testedVersion": null + } + } +} diff --git a/apps/server/src/provider/providerVersionLifecycle.test.ts b/apps/server/src/provider/providerVersionLifecycle.test.ts new file mode 100644 index 0000000000..9c751395b8 --- /dev/null +++ b/apps/server/src/provider/providerVersionLifecycle.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { + createProviderVersionAdvisory, + getProviderVersionLifecycle, +} from "./providerVersionLifecycle.ts"; + +describe("providerVersionLifecycle", () => { + it("marks providers with unknown current versions as unknown", () => { + expect( + createProviderVersionAdvisory({ + driver: "codex", + currentVersion: null, + latestVersion: "9.9.9", + }), + ).toMatchObject({ + status: "unknown", + currentVersion: null, + latestVersion: "9.9.9", + }); + }); + + it("marks installed providers behind latest when no stronger tested-version advisory applies", () => { + expect( + createProviderVersionAdvisory({ + driver: "claudeAgent", + currentVersion: "2.1.110", + latestVersion: "2.1.117", + }), + ).toMatchObject({ + status: "behind_latest", + currentVersion: "2.1.110", + latestVersion: "2.1.117", + updateCommand: "npm install -g @anthropic-ai/claude-code@latest", + }); + }); + + it("keeps update commands owned by provider lifecycle metadata", () => { + expect(getProviderVersionLifecycle("cursor")).toEqual({ + provider: "cursor", + packageName: null, + updateCommand: "agent update", + }); + }); +}); diff --git a/apps/server/src/provider/providerVersionLifecycle.ts b/apps/server/src/provider/providerVersionLifecycle.ts new file mode 100644 index 0000000000..d0d3c90d3f --- /dev/null +++ b/apps/server/src/provider/providerVersionLifecycle.ts @@ -0,0 +1,224 @@ +import type { + ProviderDriverKind, + ServerProvider, + ServerProviderVersionAdvisory, + ServerProviderVersionAdvisoryStatus, +} from "@t3tools/contracts"; + +import testedVersions from "./providerTestedVersions.generated.json" with { type: "json" }; +import { compareCliVersions } from "./cliVersion.ts"; + +const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; +const LATEST_VERSION_TIMEOUT_MS = 4_000; + +type VersionLifecycleProvider = "codex" | "claudeAgent" | "cursor" | "opencode"; + +type TestedVersionsManifest = { + readonly providers?: Partial< + Record + >; +}; + +export interface ProviderVersionLifecycle { + readonly provider: ProviderDriverKind; + readonly packageName: string | null; + readonly updateCommand: string | null; +} + +const PROVIDER_VERSION_LIFECYCLES = { + codex: { + provider: "codex", + packageName: "@openai/codex", + updateCommand: "npm install -g @openai/codex@latest", + }, + claudeAgent: { + provider: "claudeAgent", + packageName: "@anthropic-ai/claude-code", + updateCommand: "npm install -g @anthropic-ai/claude-code@latest", + }, + cursor: { + provider: "cursor", + packageName: null, + updateCommand: "agent update", + }, + opencode: { + provider: "opencode", + packageName: "opencode-ai", + updateCommand: "npm install -g opencode-ai@latest", + }, +} as const satisfies Record; + +interface LatestVersionCacheEntry { + readonly expiresAt: number; + readonly version: string | null; +} + +const latestVersionCache = new Map(); + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function isVersionLifecycleProvider( + provider: ProviderDriverKind | string, +): provider is VersionLifecycleProvider { + return provider in PROVIDER_VERSION_LIFECYCLES; +} + +export function getProviderVersionLifecycle(provider: ProviderDriverKind): ProviderVersionLifecycle { + if (isVersionLifecycleProvider(provider)) { + return PROVIDER_VERSION_LIFECYCLES[provider]; + } + return { + provider, + packageName: null, + updateCommand: null, + }; +} + +export function getProviderTestedVersion(provider: ProviderDriverKind): string | null { + if (!isVersionLifecycleProvider(provider)) { + return null; + } + const manifest = testedVersions as TestedVersionsManifest; + return nonEmptyString(manifest.providers?.[provider]?.testedVersion); +} + +function formatVersion(value: string): string { + return value.startsWith("v") ? value : `v${value}`; +} + +function deriveVersionAdvisoryStatus(input: { + readonly currentVersion: string | null; + readonly testedVersion: string | null; + readonly latestVersion: string | null; +}): ServerProviderVersionAdvisoryStatus { + if (!input.currentVersion) { + return "unknown"; + } + if (input.testedVersion && compareCliVersions(input.currentVersion, input.testedVersion) < 0) { + return "behind_tested"; + } + if (input.latestVersion && compareCliVersions(input.currentVersion, input.latestVersion) < 0) { + return "behind_latest"; + } + return "current"; +} + +function advisoryMessage(input: { + readonly status: ServerProviderVersionAdvisoryStatus; + readonly testedVersion: string | null; + readonly latestVersion: string | null; +}): string | null { + switch (input.status) { + case "behind_tested": + return input.testedVersion + ? `Recommended update: this T3 Code build was tested with ${formatVersion(input.testedVersion)}.` + : "Recommended update: this provider is behind the version tested with this T3 Code build."; + case "behind_latest": + return input.latestVersion + ? `Update available: latest is ${formatVersion(input.latestVersion)}.` + : "Update available."; + case "unknown": + return null; + case "current": + return null; + } +} + +export function createProviderVersionAdvisory(input: { + readonly driver: ProviderDriverKind; + readonly currentVersion: string | null; + readonly latestVersion?: string | null; + readonly checkedAt?: string | null; +}): ServerProviderVersionAdvisory { + const lifecycle = getProviderVersionLifecycle(input.driver); + const testedVersion = getProviderTestedVersion(input.driver); + const latestVersion = input.latestVersion ?? null; + const status = deriveVersionAdvisoryStatus({ + currentVersion: input.currentVersion, + testedVersion, + latestVersion, + }); + + return { + status, + currentVersion: input.currentVersion, + testedVersion, + latestVersion, + updateCommand: lifecycle.updateCommand, + checkedAt: input.checkedAt ?? null, + message: advisoryMessage({ status, testedVersion, latestVersion }), + }; +} + +async function fetchNpmLatestVersion(packageName: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), LATEST_VERSION_TIMEOUT_MS); + try { + const response = await fetch( + `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, + { + signal: controller.signal, + headers: { accept: "application/json" }, + }, + ); + if (!response.ok) { + return null; + } + const payload = (await response.json()) as { version?: unknown }; + return nonEmptyString(payload.version); + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +export async function resolveLatestProviderVersion( + provider: ProviderDriverKind, +): Promise { + const lifecycle = getProviderVersionLifecycle(provider); + if (!lifecycle.packageName) { + return null; + } + + const cached = latestVersionCache.get(lifecycle.packageName); + const now = Date.now(); + if (cached && cached.expiresAt > now) { + return cached.version; + } + + const version = await fetchNpmLatestVersion(lifecycle.packageName); + latestVersionCache.set(lifecycle.packageName, { + expiresAt: now + LATEST_VERSION_CACHE_TTL_MS, + version, + }); + return version; +} + +export async function enrichProviderSnapshotWithVersionAdvisory( + snapshot: ServerProvider, +): Promise { + if (!snapshot.enabled || !snapshot.installed || !snapshot.version) { + return { + ...snapshot, + versionAdvisory: createProviderVersionAdvisory({ + driver: snapshot.driver, + currentVersion: snapshot.version, + checkedAt: snapshot.checkedAt, + }), + }; + } + + const latestVersion = await resolveLatestProviderVersion(snapshot.driver); + return { + ...snapshot, + versionAdvisory: createProviderVersionAdvisory({ + driver: snapshot.driver, + currentVersion: snapshot.version, + latestVersion, + checkedAt: new Date().toISOString(), + }), + }; +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 236e1db565..f09907d768 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; +import { ChevronDownIcon, CopyIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; import { useEffect, useState, type ReactNode } from "react"; import { isProviderDriverKind, @@ -13,12 +13,14 @@ import { } from "@t3tools/contracts"; import { cn } from "../../lib/utils"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { DraftInput } from "../ui/draft-input"; import { Switch } from "../ui/switch"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { DriverOption } from "./providerDriverMeta"; import { ProviderSettingsForm } from "./ProviderSettingsForm"; @@ -26,6 +28,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { + getProviderVersionAdvisoryPresentation, PROVIDER_STATUS_STYLES, getProviderSummary, getProviderVersionLabel, @@ -464,10 +467,29 @@ export function ProviderInstanceCard({ : null; const summary = rawSummary; const versionLabel = getProviderVersionLabel(liveProvider?.version); + const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); const FallbackIconComponent = driverOption?.icon; const displayName = instance.displayName?.trim() || driverOption?.label || String(instance.driver); const accentColor = normalizeProviderAccentColor(instance.accentColor); + const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({ + onCopy: ({ providerName }) => { + toastManager.add({ + type: "success", + title: `${providerName} update command copied`, + description: "Run it in a terminal when you are ready to update.", + }); + }, + onError: (error, { providerName }) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not copy ${providerName} update command`, + description: error.message, + }), + ); + }, + }); // Narrow `instance.driver` for callers that key on the closed // `ProviderDriverKind` union (e.g. `normalizeModelSlug`'s alias table). Custom @@ -626,6 +648,42 @@ export function ProviderInstanceCard({ )} {summary.detail ? - {summary.detail} : null}

+ {versionAdvisory ? ( +
+ + {versionAdvisory.detail} + + {versionAdvisory.updateCommand ? ( + + + copyToClipboard(versionAdvisory.updateCommand, { + providerName: displayName, + }) + } + > + + Copy command + + } + /> + {versionAdvisory.updateCommand} + + ) : null} +
+ ) : null}
+ } + /> + {displayedView.description} + + {displayedView.dismissible && ( + + startExit(displayedView.key, null, displayedView.key)} + > + + + } + /> + Dismiss until provider status changes + + )} +
+ ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index edf71b675b..0f0417d438 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -485,6 +485,19 @@ label:has(> select#reasoning-effort) select { text-align: left; } +@keyframes provider-update-pill-countdown { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } +} + +.provider-update-pill-progress { + animation: provider-update-pill-countdown var(--provider-update-pill-dismiss-ms) linear forwards; +} + /* Diffs theme bridge (match diff surfaces to app palette) */ .diff-panel-viewport { background: color-mix(in srgb, var(--background) 94%, var(--card)); diff --git a/package.json b/package.json index 2d3b090ad7..b42c5bf7e3 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,11 @@ }, "type": "module", "scripts": { - "dev": "node scripts/dev-runner.ts dev", - "dev:server": "node scripts/dev-runner.ts dev:server", - "dev:web": "node scripts/dev-runner.ts dev:web", + "dev": "node --env-file-if-exists=.env scripts/dev-runner.ts dev", + "dev:server": "node --env-file-if-exists=.env scripts/dev-runner.ts dev:server", + "dev:web": "node --env-file-if-exists=.env scripts/dev-runner.ts dev:web", "dev:marketing": "turbo run dev --filter=@t3tools/marketing", - "dev:desktop": "node scripts/dev-runner.ts dev:desktop", + "dev:desktop": "node --env-file-if-exists=.env scripts/dev-runner.ts dev:desktop", "start": "turbo run start --filter=t3", "start:desktop": "turbo run start --filter=@t3tools/desktop", "start:marketing": "turbo run preview --filter=@t3tools/marketing", From 597b8b52d786077c1f8f58803b398bd9860da8b8 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:05:38 +0100 Subject: [PATCH 06/30] dev path removal --- apps/server/package.json | 4 +- .../src/provider/Layers/ProviderRegistry.ts | 7 +- .../provider/providerUpdateDevOverrides.ts | 103 ------------------ .../src/provider/providerUpdater.test.ts | 31 ------ apps/server/src/provider/providerUpdater.ts | 31 ------ .../provider/providerVersionLifecycle.test.ts | 39 ------- .../src/provider/providerVersionLifecycle.ts | 61 +++-------- ...iderUpdateLaunchNotification.logic.test.ts | 10 +- .../components/settings/SettingsPanels.tsx | 4 +- .../sidebar/SidebarProviderUpdatePill.tsx | 3 - package.json | 8 +- 11 files changed, 30 insertions(+), 271 deletions(-) delete mode 100644 apps/server/src/provider/providerUpdateDevOverrides.ts diff --git a/apps/server/package.json b/apps/server/package.json index f508f5a6c4..c562e0401e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,10 +15,10 @@ ], "type": "module", "scripts": { - "dev": "node --env-file-if-exists=../../.env --watch src/bin.ts", + "dev": "node --watch src/bin.ts", "build": "node scripts/cli.ts build", "build:bundle": "tsdown", - "start": "node --env-file-if-exists=../../.env dist/bin.mjs", + "start": "node dist/bin.mjs", "prepare": "effect-language-service patch", "typecheck": "tsc --noEmit", "test": "vitest run", diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 1ae92989a4..6792b180cb 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -35,7 +35,6 @@ import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "../../config.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; -import { applyDevProviderVersionAdvisoryOverride } from "../providerVersionLifecycle.ts"; import { hydrateCachedProvider, isCachedProviderCorrelated, @@ -240,9 +239,7 @@ export const ProviderRegistryLive = Layer.effect( cachedDriver: cachedProvider.driver ?? null, }).pipe(Effect.as(undefined as ServerProvider | undefined)); } - return Effect.succeed( - applyDevProviderVersionAdvisoryOverride(hydrateCachedProvider(correlation)), - ); + return Effect.succeed(hydrateCachedProvider(correlation)); }), ); }), @@ -319,7 +316,7 @@ export const ProviderRegistryLive = Layer.effect( }, ) { const nextProvidersWithUpdateState = yield* Effect.forEach( - nextProviders.map((provider) => applyDevProviderVersionAdvisoryOverride(provider)), + nextProviders, applyProviderUpdateState, { concurrency: "unbounded", diff --git a/apps/server/src/provider/providerUpdateDevOverrides.ts b/apps/server/src/provider/providerUpdateDevOverrides.ts deleted file mode 100644 index fd30cc50d6..0000000000 --- a/apps/server/src/provider/providerUpdateDevOverrides.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { ProviderDriverKind, ServerProviderUpdateState } from "@t3tools/contracts"; - -const DEV_PROVIDER_UPDATE_ADVISORY_ENV = "T3CODE_DEV_PROVIDER_UPDATE_ADVISORY"; -const DEV_PROVIDER_UPDATE_RESULT_ENV = "T3CODE_DEV_PROVIDER_UPDATE_RESULT"; -const DEV_PROVIDER_UPDATE_DELAY_MS_ENV = "T3CODE_DEV_PROVIDER_UPDATE_DELAY_MS"; - -const PROVIDER_KINDS = new Set(["codex", "claudeAgent", "cursor", "opencode"]); -const SIMULATED_PROVIDER_UPDATE_STATUSES = new Set< - Extract ->(["succeeded", "failed", "unchanged"]); - -function nonEmptyString(value: string | undefined): string | null { - if (!value) { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function isProviderDriverKind(value: string): value is ProviderDriverKind { - return PROVIDER_KINDS.has(value as ProviderDriverKind); -} - -function splitProviderOverrideEntry(entry: string): readonly [string, string] | null { - const separatorIndex = entry.indexOf(":"); - if (separatorIndex <= 0 || separatorIndex === entry.length - 1) { - return null; - } - - const key = entry.slice(0, separatorIndex).trim(); - const value = entry.slice(separatorIndex + 1).trim(); - if (key.length === 0 || value.length === 0) { - return null; - } - - return [key, value]; -} - -function resolveProviderOverrideValue( - rawValue: string | undefined, - provider: ProviderDriverKind, -): string | null { - const raw = nonEmptyString(rawValue); - if (!raw) { - return null; - } - - let wildcardValue: string | null = null; - for (const entry of raw.split(",")) { - const parsed = splitProviderOverrideEntry(entry); - if (!parsed) { - continue; - } - - const [key, value] = parsed; - if (key === "*") { - wildcardValue = value; - continue; - } - if (isProviderDriverKind(key) && key === provider) { - return value; - } - } - - return wildcardValue; -} - -export function resolveDevLatestProviderVersionOverride( - provider: ProviderDriverKind, - env: NodeJS.ProcessEnv = process.env, -): string | null { - return resolveProviderOverrideValue(env[DEV_PROVIDER_UPDATE_ADVISORY_ENV], provider); -} - -export function resolveDevSimulatedProviderUpdateStatus( - provider: ProviderDriverKind, - env: NodeJS.ProcessEnv = process.env, -): Extract | null { - const value = resolveProviderOverrideValue(env[DEV_PROVIDER_UPDATE_RESULT_ENV], provider); - if (!value || !SIMULATED_PROVIDER_UPDATE_STATUSES.has(value as never)) { - return null; - } - return value as Extract< - ServerProviderUpdateState["status"], - "succeeded" | "failed" | "unchanged" - >; -} - -export function resolveDevSimulatedProviderUpdateDelayMs( - env: NodeJS.ProcessEnv = process.env, -): number { - const raw = nonEmptyString(env[DEV_PROVIDER_UPDATE_DELAY_MS_ENV]); - if (!raw) { - return 0; - } - - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed < 0) { - return 0; - } - - return Math.floor(parsed); -} diff --git a/apps/server/src/provider/providerUpdater.test.ts b/apps/server/src/provider/providerUpdater.test.ts index 6ad0ec6a71..5e5e3fa1b4 100644 --- a/apps/server/src/provider/providerUpdater.test.ts +++ b/apps/server/src/provider/providerUpdater.test.ts @@ -145,37 +145,6 @@ describe("providerUpdater", () => { }), ); - it.effect("simulates provider update results from dev env overrides", () => - Effect.gen(function* () { - const { registry, updateStatesRef } = yield* makeRegistry(); - let runUpdateCalled = false; - const updater = yield* makeProviderUpdater({ - providerRegistry: registry, - runUpdate: async () => { - runUpdateCalled = true; - return okResult(); - }, - env: { - T3CODE_DEV_PROVIDER_UPDATE_RESULT: "codex:failed", - T3CODE_DEV_PROVIDER_UPDATE_DELAY_MS: "0", - }, - }); - - const result = yield* updater.updateProvider("codex"); - - assert.strictEqual(runUpdateCalled, false); - assert.strictEqual(result.providers[0]?.updateState?.status, "failed"); - assert.strictEqual( - result.providers[0]?.updateState?.message, - "Simulated provider update failed.", - ); - assert.deepStrictEqual( - (yield* Ref.get(updateStatesRef)).map((state) => state.status), - ["queued", "running", "failed"], - ); - }), - ); - it.effect( "marks successful commands as unchanged when the refreshed provider is still outdated", () => diff --git a/apps/server/src/provider/providerUpdater.ts b/apps/server/src/provider/providerUpdater.ts index d7601af7b8..659f41dc74 100644 --- a/apps/server/src/provider/providerUpdater.ts +++ b/apps/server/src/provider/providerUpdater.ts @@ -15,10 +15,6 @@ import { enrichProviderSnapshotWithVersionAdvisory, getProviderVersionLifecycle, } from "./providerVersionLifecycle.ts"; -import { - resolveDevSimulatedProviderUpdateDelayMs, - resolveDevSimulatedProviderUpdateStatus, -} from "./providerUpdateDevOverrides.ts"; const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; @@ -107,7 +103,6 @@ function makeUpdateState(input: { export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (input: { readonly providerRegistry: ProviderRegistryShape; readonly runUpdate?: ProviderUpdateRunner; - readonly env?: NodeJS.ProcessEnv; }) { const runningProvidersRef = yield* Ref.make>(new Set()); const updateLocks = new Map(); @@ -118,7 +113,6 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i } } const runUpdate = input.runUpdate ?? defaultRunner; - const env = input.env ?? process.env; const acquireProvider = Effect.fn("acquireProvider")(function* (provider: ProviderDriverKind) { return yield* Ref.modify(runningProvidersRef, (runningProviders) => { @@ -231,8 +225,6 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i .setProviderUpdateState(provider, state) .pipe(Effect.map((providers) => ({ providers }))); const startedAtRef = yield* Ref.make(null); - const simulatedStatus = resolveDevSimulatedProviderUpdateStatus(provider, env); - const simulatedDelayMs = resolveDevSimulatedProviderUpdateDelayMs(env); const run = Effect.gen(function* () { const startedAt = new Date().toISOString(); @@ -247,29 +239,6 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i }), ); - if (simulatedStatus) { - if (simulatedDelayMs > 0) { - yield* Effect.sleep(`${simulatedDelayMs} millis`); - } - return yield* finish( - makeUpdateState({ - status: simulatedStatus, - startedAt, - finishedAt: new Date().toISOString(), - message: - simulatedStatus === "succeeded" - ? "Provider updated." - : simulatedStatus === "unchanged" - ? "Update command completed, but T3 Code still detects an outdated provider version." - : "Simulated provider update failed.", - output: - simulatedStatus === "failed" - ? "Simulated update via T3CODE_DEV_PROVIDER_UPDATE_RESULT." - : null, - }), - ); - } - const result = yield* Effect.promise(() => runUpdate(updateExecutable, lifecycle.updateArgs), ); diff --git a/apps/server/src/provider/providerVersionLifecycle.test.ts b/apps/server/src/provider/providerVersionLifecycle.test.ts index a2b8a74905..bc3dc1fc86 100644 --- a/apps/server/src/provider/providerVersionLifecycle.test.ts +++ b/apps/server/src/provider/providerVersionLifecycle.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { ServerProvider } from "@t3tools/contracts"; - import { createProviderVersionAdvisory, - enrichProviderSnapshotWithVersionAdvisory, getProviderVersionLifecycle, } from "./providerVersionLifecycle.ts"; @@ -49,40 +46,4 @@ describe("providerVersionLifecycle", () => { updateLockKey: "cursor-agent", }); }); - - it("honors dev advisory overrides without querying the registry", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (() => { - throw new Error("fetch should not be called when a dev advisory override is present"); - }) as unknown as typeof fetch; - - const snapshot: ServerProvider = { - provider: "codex", - enabled: true, - installed: true, - version: "1.0.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-04-23T12:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - }; - - try { - await expect( - enrichProviderSnapshotWithVersionAdvisory(snapshot, { - T3CODE_DEV_PROVIDER_UPDATE_ADVISORY: "codex:9.9.9", - }), - ).resolves.toMatchObject({ - versionAdvisory: { - status: "behind_latest", - latestVersion: "9.9.9", - currentVersion: "1.0.0", - }, - }); - } finally { - globalThis.fetch = originalFetch; - } - }); }); diff --git a/apps/server/src/provider/providerVersionLifecycle.ts b/apps/server/src/provider/providerVersionLifecycle.ts index 9b1ac03de2..7fa936d57f 100644 --- a/apps/server/src/provider/providerVersionLifecycle.ts +++ b/apps/server/src/provider/providerVersionLifecycle.ts @@ -5,7 +5,6 @@ import type { } from "@t3tools/contracts"; import { compareCliVersions } from "./cliVersion.ts"; -import { resolveDevLatestProviderVersionOverride } from "./providerUpdateDevOverrides.ts"; const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; const LATEST_VERSION_TIMEOUT_MS = 4_000; @@ -128,26 +127,6 @@ export function createProviderVersionAdvisory(input: { }; } -export function applyDevProviderVersionAdvisoryOverride( - snapshot: ServerProvider, - env: NodeJS.ProcessEnv = process.env, -): ServerProvider { - const forcedLatestVersion = resolveDevLatestProviderVersionOverride(snapshot.driver, env); - if (!forcedLatestVersion) { - return snapshot; - } - - return { - ...snapshot, - versionAdvisory: createProviderVersionAdvisory({ - driver: snapshot.driver, - currentVersion: snapshot.version, - latestVersion: forcedLatestVersion, - checkedAt: snapshot.checkedAt, - }), - }; -} - async function fetchNpmLatestVersion(packageName: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), LATEST_VERSION_TIMEOUT_MS); @@ -195,36 +174,26 @@ export async function resolveLatestProviderVersion( export async function enrichProviderSnapshotWithVersionAdvisory( snapshot: ServerProvider, - env: NodeJS.ProcessEnv = process.env, ): Promise { - const forcedLatestVersion = resolveDevLatestProviderVersionOverride(snapshot.driver, env); if (!snapshot.enabled || !snapshot.installed || !snapshot.version) { - return applyDevProviderVersionAdvisoryOverride( - { - ...snapshot, - versionAdvisory: createProviderVersionAdvisory({ - driver: snapshot.driver, - currentVersion: snapshot.version, - ...(forcedLatestVersion ? { latestVersion: forcedLatestVersion } : {}), - checkedAt: snapshot.checkedAt, - }), - }, - env, - ); - } - - const latestVersion = - forcedLatestVersion ?? (await resolveLatestProviderVersion(snapshot.driver)); - return applyDevProviderVersionAdvisoryOverride( - { + return { ...snapshot, versionAdvisory: createProviderVersionAdvisory({ driver: snapshot.driver, currentVersion: snapshot.version, - latestVersion, - checkedAt: new Date().toISOString(), + checkedAt: snapshot.checkedAt, }), - }, - env, - ); + }; + } + + const latestVersion = await resolveLatestProviderVersion(snapshot.driver); + return { + ...snapshot, + versionAdvisory: createProviderVersionAdvisory({ + driver: snapshot.driver, + currentVersion: snapshot.version, + latestVersion, + checkedAt: new Date().toISOString(), + }), + }; } diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 42109f221b..c765454d75 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -367,7 +367,7 @@ describe("provider update launch notification logic", () => { status: "failed", startedAt: checkedAt, finishedAt: checkedAt, - message: "Simulated provider update failed.", + message: "Update command exited with code 1.", output: null, }, }), @@ -376,10 +376,10 @@ describe("provider update launch notification logic", () => { ); expect(view).toMatchObject({ - key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Simulated provider update failed.", + key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Update command exited with code 1.", tone: "error", title: "Claude v1.1.0 update failed", - description: "Simulated provider update failed.", + description: "Update command exited with code 1.", dismissible: true, }); }); @@ -466,7 +466,7 @@ describe("provider update launch notification logic", () => { status: "failed", startedAt: checkedAt, finishedAt: checkedAt, - message: "Simulated provider update failed.", + message: "Update command exited with code 1.", output: null, }, }), @@ -499,7 +499,7 @@ describe("provider update launch notification logic", () => { dismissedKeys: new Set(["succeeded:codex:2026-04-23T10:01:00.000Z:Provider updated."]), }); expect(failureView).toMatchObject({ - key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Simulated provider update failed.", + key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Update command exited with code 1.", tone: "error", title: "Claude v1.1.0 update failed", }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fc199af77b..0d614ac3d3 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -476,7 +476,7 @@ export function GeneralSettingsPanel() { return; } const frame = window.requestAnimationFrame(() => { - document.getElementById("providers")?.scrollIntoView({ + document.querySelector('[data-settings-section="providers"]')?.scrollIntoView({ block: "start", behavior: "smooth", }); @@ -1183,7 +1183,7 @@ export function GeneralSettingsPanel() { { diff --git a/package.json b/package.json index b42c5bf7e3..2d3b090ad7 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,11 @@ }, "type": "module", "scripts": { - "dev": "node --env-file-if-exists=.env scripts/dev-runner.ts dev", - "dev:server": "node --env-file-if-exists=.env scripts/dev-runner.ts dev:server", - "dev:web": "node --env-file-if-exists=.env scripts/dev-runner.ts dev:web", + "dev": "node scripts/dev-runner.ts dev", + "dev:server": "node scripts/dev-runner.ts dev:server", + "dev:web": "node scripts/dev-runner.ts dev:web", "dev:marketing": "turbo run dev --filter=@t3tools/marketing", - "dev:desktop": "node --env-file-if-exists=.env scripts/dev-runner.ts dev:desktop", + "dev:desktop": "node scripts/dev-runner.ts dev:desktop", "start": "turbo run start --filter=t3", "start:desktop": "turbo run start --filter=@t3tools/desktop", "start:marketing": "turbo run preview --filter=@t3tools/marketing", From 06ead248af548ee80c6e5459c0e69e45b39fa449 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:31:57 +0100 Subject: [PATCH 07/30] unknown version case --- .../src/provider/providerVersionLifecycle.test.ts | 15 +++++++++++++++ .../src/provider/providerVersionLifecycle.ts | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/providerVersionLifecycle.test.ts b/apps/server/src/provider/providerVersionLifecycle.test.ts index bc3dc1fc86..4516ff9cb8 100644 --- a/apps/server/src/provider/providerVersionLifecycle.test.ts +++ b/apps/server/src/provider/providerVersionLifecycle.test.ts @@ -19,6 +19,21 @@ describe("providerVersionLifecycle", () => { }); }); + it("marks providers with unknown latest versions as unknown", () => { + expect( + createProviderVersionAdvisory({ + provider: "codex", + currentVersion: "1.0.0", + latestVersion: null, + }), + ).toMatchObject({ + status: "unknown", + currentVersion: "1.0.0", + latestVersion: null, + message: null, + }); + }); + it("marks installed providers behind latest when a newer provider version is available", () => { expect( createProviderVersionAdvisory({ diff --git a/apps/server/src/provider/providerVersionLifecycle.ts b/apps/server/src/provider/providerVersionLifecycle.ts index 7fa936d57f..4c348f96a5 100644 --- a/apps/server/src/provider/providerVersionLifecycle.ts +++ b/apps/server/src/provider/providerVersionLifecycle.ts @@ -94,7 +94,10 @@ function deriveVersionAdvisory(input: { if (!input.currentVersion) { return { status: "unknown", message: null }; } - if (input.latestVersion && compareCliVersions(input.currentVersion, input.latestVersion) < 0) { + if (!input.latestVersion) { + return { status: "unknown", message: null }; + } + if (compareCliVersions(input.currentVersion, input.latestVersion) < 0) { return { status: "behind_latest", message: PROVIDER_UPDATE_ACTION_TOAST_MESSAGE, From 2db6e4a1cdf1f49c8c4c2375a2a58ed9aa0c2230 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:44:57 +0100 Subject: [PATCH 08/30] update success copy --- .../ProviderUpdateLaunchNotification.logic.test.ts | 4 +++- .../ProviderUpdateLaunchNotification.logic.ts | 13 ++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index c765454d75..9410dec91c 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -249,6 +249,7 @@ describe("provider update launch notification logic", () => { phase: "succeeded", type: "success", title: "Provider updated", + description: "New sessions will use the updated provider.", dismissAfterVisibleMs: 3_000, }); }); @@ -274,6 +275,7 @@ describe("provider update launch notification logic", () => { phase: "succeeded", type: "success", title: "Codex updated: v1.1.0", + description: "New sessions will use the updated provider.", }); }); @@ -408,7 +410,7 @@ describe("provider update launch notification logic", () => { key: "succeeded:codex:2026-04-23T10:00:00.000Z:Provider updated.", tone: "success", title: "Codex updated: v1.1.0", - description: "Codex updated successfully.", + description: "New sessions will use the updated provider.", dismissAfterVisibleMs: 3_000, }); }); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index c631691578..a07427d239 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -82,6 +82,12 @@ function getProviderUpdatedTitle(provider: Pick, ): string { @@ -225,7 +231,7 @@ export function getProviderUpdateProgressToastView(input: { phase: "succeeded", type: "success", title: input.providerCount === 1 ? "Provider updated" : "Provider updates finished", - description: "Provider status will refresh automatically.", + description: getProviderUpdatedDescription(input.providerCount), dismissAfterVisibleMs: PROVIDER_UPDATE_SUCCESS_VISIBLE_MS, }; } @@ -433,10 +439,7 @@ export function getProviderUpdateSidebarPillView( succeededProviders.length === 1 ? getProviderUpdatedTitle(succeededProvider) : `${succeededProviders.length} providers updated`, - description: - succeededProviders.length === 1 - ? `${PROVIDER_DISPLAY_NAMES[succeededProvider.driver] ?? succeededProvider.driver} updated successfully.` - : `${formatProviderList(succeededProviders)} updated successfully.`, + description: getProviderUpdatedDescription(succeededProviders.length), dismissAfterVisibleMs: PROVIDER_UPDATE_SUCCESS_VISIBLE_MS, }); } From 289a64f15c35b82adf7c55f2929fd9c9a65e7fc9 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:07:41 +0100 Subject: [PATCH 09/30] Fix provider registry test capabilities after rebase --- .../src/provider/Layers/ProviderRegistry.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index f83b87a867..e16308d496 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -518,13 +518,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( slug: "claude-opus-4-6", name: "Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [{ value: "high", label: "High", isDefault: true }], - supportsFastMode: true, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), }, ], slashCommands: [], From d34bde0c96a22a3eccdc86eb53210e71df9d2696 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:47:09 +0100 Subject: [PATCH 10/30] Fix settings browser tests after smooth scroll --- .../web/src/components/settings/SettingsPanels.tsx | 12 +++++++----- apps/web/src/routes/settings.general.tsx | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 0d614ac3d3..f299f52366 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,6 +1,5 @@ import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; -import { useLocation } from "@tanstack/react-router"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { defaultInstanceIdForDriver, @@ -448,8 +447,11 @@ export function useSettingsRestore(onRestored?: () => void) { }; } -export function GeneralSettingsPanel() { - const location = useLocation(); +export function GeneralSettingsPanel({ + initialScrollTarget, +}: { + initialScrollTarget?: "providers"; +}) { const { theme, setTheme } = useTheme(); const settings = useSettings(); const { updateSettings } = useUpdateSettings(); @@ -472,7 +474,7 @@ export function GeneralSettingsPanel() { const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); const refreshingRef = useRef(false); useEffect(() => { - if (location.hash !== "providers") { + if (initialScrollTarget !== "providers") { return; } const frame = window.requestAnimationFrame(() => { @@ -482,7 +484,7 @@ export function GeneralSettingsPanel() { }); }); return () => window.cancelAnimationFrame(frame); - }, [location.hash]); + }, [initialScrollTarget]); const refreshProviders = useCallback(() => { if (refreshingRef.current) return; refreshingRef.current = true; diff --git a/apps/web/src/routes/settings.general.tsx b/apps/web/src/routes/settings.general.tsx index 7fb503e0a2..397b1089b9 100644 --- a/apps/web/src/routes/settings.general.tsx +++ b/apps/web/src/routes/settings.general.tsx @@ -1,7 +1,17 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useLocation } from "@tanstack/react-router"; import { GeneralSettingsPanel } from "../components/settings/SettingsPanels"; +function SettingsGeneralRoute() { + const location = useLocation(); + + return ( + + ); +} + export const Route = createFileRoute("/settings/general")({ - component: GeneralSettingsPanel, + component: SettingsGeneralRoute, }); From 149a5c10dfb17db93d8ae9e302a4ba1664395105 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:55:19 +0100 Subject: [PATCH 11/30] Fix provider advisory rebase fallout --- .../Layers/ProviderAdapterRegistry.test.ts | 4 +- .../provider/Layers/ProviderRegistry.test.ts | 5 +- .../src/provider/Layers/ProviderRegistry.ts | 4 +- .../makeManagedServerProvider.test.ts | 2 +- .../src/provider/providerUpdater.test.ts | 30 +++--- apps/server/src/provider/providerUpdater.ts | 23 +++-- .../provider/providerVersionLifecycle.test.ts | 13 ++- .../src/provider/providerVersionLifecycle.ts | 32 ++++--- ...iderUpdateLaunchNotification.logic.test.ts | 93 +++++++++++-------- .../ProviderUpdateLaunchNotification.logic.ts | 7 +- .../ProviderUpdateLaunchNotification.tsx | 8 ++ .../settings/ProviderInstanceCard.tsx | 7 +- .../src/components/settings/providerStatus.ts | 6 +- apps/web/src/localApi.test.ts | 8 +- 14 files changed, 151 insertions(+), 91 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index eeba158ab4..ab3eb6c711 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -14,7 +14,8 @@ import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; -import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; +import { getProviderVersionLifecycle } from "../providerVersionLifecycle.ts"; +import type { TextGenerationShape } from "../../git/Services/TextGeneration.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -111,6 +112,7 @@ const makeFakeInstance = ( displayName: undefined, enabled: true, snapshot: { + versionLifecycle: getProviderVersionLifecycle(driverKind), getSnapshot: Effect.succeed({} as unknown as ServerProvider), refresh: Effect.succeed({} as unknown as ServerProvider), streamChanges: Stream.empty, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index e16308d496..78662fa819 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -35,6 +35,7 @@ import { ServerSettingsService, type ServerSettingsShape } from "../../serverSet import type { ProviderInstance } from "../ProviderDriver.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; +import { getProviderVersionLifecycle } from "../providerVersionLifecycle.ts"; const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); @@ -555,7 +556,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( const mergedProviders = mergeProviderSnapshots(previousProviders, [refreshedCursor]); const persistedProviders = selectProvidersByKind( mergedProviders, - new Set(["cursor"]), + new Set([ProviderDriverKind.make("cursor")]), ); assert.deepStrictEqual(persistedProviders, [ @@ -593,6 +594,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( displayName: undefined, enabled: true, snapshot: { + versionLifecycle: getProviderVersionLifecycle(codexDriver), getSnapshot: Effect.succeed(cachedProvider), refresh: Effect.die(new Error("simulated refresh failure")), streamChanges: Stream.empty, @@ -678,6 +680,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( displayName: undefined, enabled: true, snapshot: { + versionLifecycle: getProviderVersionLifecycle(provider.driver), getSnapshot: Effect.succeed(provider), refresh: Effect.succeed(provider), streamChanges: Stream.empty, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 6792b180cb..b36a23b54c 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -383,7 +383,9 @@ export const ProviderRegistryLive = Layer.effect( }); const existingProviders = yield* Ref.get(providersRef); - const matchingProviders = existingProviders.filter((candidate) => candidate.driver === provider); + const matchingProviders = existingProviders.filter( + (candidate) => candidate.driver === provider, + ); if (matchingProviders.length === 0) { return existingProviders; } diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 76e98288a6..22f79c4eb2 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -21,7 +21,7 @@ interface TestSettings { } const versionLifecycle = { - provider: "codex", + provider: ProviderDriverKind.make("codex"), packageName: "@openai/codex", updateCommand: "npm install -g @openai/codex@latest", updateExecutable: "npm", diff --git a/apps/server/src/provider/providerUpdater.test.ts b/apps/server/src/provider/providerUpdater.test.ts index 5e5e3fa1b4..99e1fb97ba 100644 --- a/apps/server/src/provider/providerUpdater.test.ts +++ b/apps/server/src/provider/providerUpdater.test.ts @@ -1,8 +1,9 @@ import { describe, it, assert } from "@effect/vitest"; -import type { +import { ProviderDriverKind, - ServerProvider, - ServerProviderUpdateState, + ProviderInstanceId, + type ServerProvider, + type ServerProviderUpdateState, } from "@t3tools/contracts"; import { ServerProviderUpdateError } from "@t3tools/contracts"; import { Cause, Effect, Exit, Fiber, Ref, Schema, Stream } from "effect"; @@ -11,9 +12,14 @@ import type { ProcessRunResult } from "../processRunner.ts"; import type { ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; import { makeProviderUpdater, type ProviderUpdateRunner } from "./providerUpdater.ts"; +const CODEX_DRIVER = ProviderDriverKind.make("codex"); +const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); +const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex"); +const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor"); + const baseProvider: ServerProvider = { - instanceId: "codex", - driver: "codex", + instanceId: CODEX_INSTANCE_ID, + driver: CODEX_DRIVER, enabled: true, installed: true, version: null, @@ -27,8 +33,8 @@ const baseProvider: ServerProvider = { const baseCursorProvider: ServerProvider = { ...baseProvider, - instanceId: "cursor", - driver: "cursor", + instanceId: CURSOR_INSTANCE_ID, + driver: CURSOR_DRIVER, }; const okResult = (stdout = ""): ProcessRunResult => ({ @@ -113,7 +119,7 @@ describe("providerUpdater", () => { }, }); - const result = yield* updater.updateProvider("cursor"); + const result = yield* updater.updateProvider(CURSOR_DRIVER); assert.deepStrictEqual(calls, [ { command: "agent", @@ -136,7 +142,7 @@ describe("providerUpdater", () => { runUpdate: async () => failedResult("permission denied"), }); - const result = yield* updater.updateProvider("codex"); + const result = yield* updater.updateProvider(CODEX_DRIVER); const updateState = result.providers[0]?.updateState; assert.strictEqual(updateState?.status, "failed"); @@ -167,7 +173,7 @@ describe("providerUpdater", () => { runUpdate: async () => okResult(), }); - const result = yield* updater.updateProvider("codex"); + const result = yield* updater.updateProvider(CODEX_DRIVER); assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); assert.include(result.providers[0]?.updateState?.message ?? "", "still detects"); @@ -198,10 +204,10 @@ describe("providerUpdater", () => { runUpdate: runner, }); - const first = yield* updater.updateProvider("codex").pipe(Effect.forkScoped); + const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); yield* Effect.promise(() => started); - const second = yield* updater.updateProvider("codex").pipe(Effect.exit); + const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit); assert.strictEqual(Exit.isFailure(second), true); if (Exit.isFailure(second)) { const error = Cause.squash(second.cause); diff --git a/apps/server/src/provider/providerUpdater.ts b/apps/server/src/provider/providerUpdater.ts index 659f41dc74..172af77451 100644 --- a/apps/server/src/provider/providerUpdater.ts +++ b/apps/server/src/provider/providerUpdater.ts @@ -1,6 +1,6 @@ import { + ProviderDriverKind, ServerProviderUpdateError, - type ProviderDriverKind, type ServerProvider, type ServerProviderUpdatedPayload, type ServerProviderUpdateState, @@ -36,10 +36,10 @@ interface VerifiedProviderRefresh { } const UPDATE_LOCK_PROVIDERS = [ - "codex", - "claudeAgent", - "cursor", - "opencode", + ProviderDriverKind.make("codex"), + ProviderDriverKind.make("claudeAgent"), + ProviderDriverKind.make("cursor"), + ProviderDriverKind.make("opencode"), ] as const satisfies ReadonlyArray; const defaultRunner: ProviderUpdateRunner = (command, args) => @@ -171,10 +171,12 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i concurrency: "unbounded", }, ).pipe( - Effect.map((verifiedProviders): VerifiedProviderRefresh => ({ - providers, - verifiedProviders, - })), + Effect.map( + (verifiedProviders): VerifiedProviderRefresh => ({ + providers, + verifiedProviders, + }), + ), Effect.catchCause((cause) => Effect.logWarning("Provider post-update version verification failed", { provider, @@ -258,7 +260,8 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i const { verifiedProviders } = yield* verifyRefreshedProvider(provider); const couldNotVerify = verifiedProviders.length === 0; const stillOutdated = - couldNotVerify || verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); + couldNotVerify || + verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); return yield* finish( makeUpdateState({ status: stillOutdated ? "unchanged" : "succeeded", diff --git a/apps/server/src/provider/providerVersionLifecycle.test.ts b/apps/server/src/provider/providerVersionLifecycle.test.ts index 4516ff9cb8..380e84f741 100644 --- a/apps/server/src/provider/providerVersionLifecycle.test.ts +++ b/apps/server/src/provider/providerVersionLifecycle.test.ts @@ -1,14 +1,17 @@ import { describe, expect, it } from "vitest"; +import { ProviderDriverKind } from "@t3tools/contracts"; import { createProviderVersionAdvisory, getProviderVersionLifecycle, } from "./providerVersionLifecycle.ts"; +const driver = (value: string) => ProviderDriverKind.make(value); + describe("providerVersionLifecycle", () => { it("marks providers with unknown current versions as unknown", () => { expect( createProviderVersionAdvisory({ - driver: "codex", + driver: driver("codex"), currentVersion: null, latestVersion: "9.9.9", }), @@ -22,7 +25,7 @@ describe("providerVersionLifecycle", () => { it("marks providers with unknown latest versions as unknown", () => { expect( createProviderVersionAdvisory({ - provider: "codex", + driver: driver("codex"), currentVersion: "1.0.0", latestVersion: null, }), @@ -37,7 +40,7 @@ describe("providerVersionLifecycle", () => { it("marks installed providers behind latest when a newer provider version is available", () => { expect( createProviderVersionAdvisory({ - driver: "claudeAgent", + driver: driver("claudeAgent"), currentVersion: "2.1.110", latestVersion: "2.1.117", }), @@ -52,8 +55,8 @@ describe("providerVersionLifecycle", () => { }); it("keeps update commands owned by provider lifecycle metadata", () => { - expect(getProviderVersionLifecycle("cursor")).toEqual({ - provider: "cursor", + expect(getProviderVersionLifecycle(driver("cursor"))).toEqual({ + provider: driver("cursor"), packageName: null, updateCommand: "agent update", updateExecutable: "agent", diff --git a/apps/server/src/provider/providerVersionLifecycle.ts b/apps/server/src/provider/providerVersionLifecycle.ts index 4c348f96a5..991a80a056 100644 --- a/apps/server/src/provider/providerVersionLifecycle.ts +++ b/apps/server/src/provider/providerVersionLifecycle.ts @@ -1,7 +1,7 @@ -import type { +import { ProviderDriverKind, - ServerProvider, - ServerProviderVersionAdvisory, + type ServerProvider, + type ServerProviderVersionAdvisory, } from "@t3tools/contracts"; import { compareCliVersions } from "./cliVersion.ts"; @@ -12,6 +12,11 @@ const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review p type VersionLifecycleProvider = "codex" | "claudeAgent" | "cursor" | "opencode"; +const CODEX_DRIVER = ProviderDriverKind.make("codex"); +const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); +const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); +const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); + export interface ProviderVersionLifecycle { readonly provider: ProviderDriverKind; readonly packageName: string | null; @@ -23,7 +28,7 @@ export interface ProviderVersionLifecycle { const PROVIDER_VERSION_LIFECYCLES = { codex: { - provider: "codex", + provider: CODEX_DRIVER, packageName: "@openai/codex", updateCommand: "npm install -g @openai/codex@latest", updateExecutable: "npm", @@ -31,7 +36,7 @@ const PROVIDER_VERSION_LIFECYCLES = { updateLockKey: "npm-global", }, claudeAgent: { - provider: "claudeAgent", + provider: CLAUDE_AGENT_DRIVER, packageName: "@anthropic-ai/claude-code", updateCommand: "npm install -g @anthropic-ai/claude-code@latest", updateExecutable: "npm", @@ -39,7 +44,7 @@ const PROVIDER_VERSION_LIFECYCLES = { updateLockKey: "npm-global", }, cursor: { - provider: "cursor", + provider: CURSOR_DRIVER, packageName: null, updateCommand: "agent update", updateExecutable: "agent", @@ -47,7 +52,7 @@ const PROVIDER_VERSION_LIFECYCLES = { updateLockKey: "cursor-agent", }, opencode: { - provider: "opencode", + provider: OPENCODE_DRIVER, packageName: "opencode-ai", updateCommand: "npm install -g opencode-ai@latest", updateExecutable: "npm", @@ -67,15 +72,16 @@ function nonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -function isVersionLifecycleProvider( - provider: ProviderDriverKind | string, -): provider is VersionLifecycleProvider { +function isVersionLifecycleProvider(provider: string): provider is VersionLifecycleProvider { return provider in PROVIDER_VERSION_LIFECYCLES; } -export function getProviderVersionLifecycle(provider: ProviderDriverKind): ProviderVersionLifecycle { - if (isVersionLifecycleProvider(provider)) { - return PROVIDER_VERSION_LIFECYCLES[provider]; +export function getProviderVersionLifecycle( + provider: ProviderDriverKind, +): ProviderVersionLifecycle { + const providerKey = String(provider); + if (isVersionLifecycleProvider(providerKey)) { + return PROVIDER_VERSION_LIFECYCLES[providerKey]; } return { provider, diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 9410dec91c..7efcfa90ef 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { ProviderDriverKind, ServerProvider } from "@t3tools/contracts"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { collectProviderUpdateCandidates, @@ -19,9 +19,12 @@ const checkedAt = "2026-04-23T10:00:00.000Z"; const sessionStartedAtMs = Date.parse("2026-04-23T09:59:00.000Z"); const laterCheckedAt = "2026-04-23T10:01:00.000Z"; +const driver = (value: string) => ProviderDriverKind.make(value); +const instanceId = (value: string) => ProviderInstanceId.make(value); + function provider(input: { - readonly driver: ProviderDriverKind; - readonly instanceId?: string; + readonly driver: ReturnType; + readonly instanceId?: ReturnType; readonly enabled?: boolean; readonly version?: string | null; readonly latestVersion?: string | null; @@ -30,7 +33,7 @@ function provider(input: { readonly advisoryStatus?: NonNullable["status"]; }): ServerProvider { const result: ServerProvider = { - instanceId: input.instanceId ?? input.driver, + instanceId: input.instanceId ?? instanceId(String(input.driver)), driver: input.driver, enabled: input.enabled ?? true, installed: true, @@ -51,9 +54,11 @@ function provider(input: { message: "Update available.", }, }; + if (input.updateState) { return { ...result, updateState: input.updateState }; } + return result; } @@ -63,36 +68,46 @@ function updateCandidate(input: Parameters[0]): ProviderUpdateC describe("provider update launch notification logic", () => { it("detects enabled providers with a latest-version advisory", () => { - expect(isProviderUpdateCandidate(provider({ driver: "codex" }))).toBe(true); - expect(isProviderUpdateCandidate(provider({ driver: "codex", enabled: false }))).toBe(false); + expect(isProviderUpdateCandidate(provider({ driver: driver("codex") }))).toBe(true); + expect(isProviderUpdateCandidate(provider({ driver: driver("codex"), enabled: false }))).toBe( + false, + ); expect( isProviderUpdateCandidate( - provider({ driver: "codex", advisoryStatus: "current", latestVersion: null }), + provider({ driver: driver("codex"), advisoryStatus: "current", latestVersion: null }), ), ).toBe(false); - expect(isProviderUpdateCandidate(provider({ driver: "codex", latestVersion: null }))).toBe( - false, - ); + expect( + isProviderUpdateCandidate(provider({ driver: driver("codex"), latestVersion: null })), + ).toBe(false); }); it("deduplicates multi-instance provider candidates by driver", () => { expect( collectProviderUpdateCandidates([ - provider({ driver: "codex", instanceId: "codex_personal", latestVersion: "1.1.0" }), - provider({ driver: "codex", instanceId: "codex", latestVersion: "1.1.0" }), - provider({ driver: "cursor", latestVersion: "0.3.0" }), + provider({ + driver: driver("codex"), + instanceId: instanceId("codex_personal"), + latestVersion: "1.1.0", + }), + provider({ + driver: driver("codex"), + instanceId: instanceId("codex"), + latestVersion: "1.1.0", + }), + provider({ driver: driver("cursor"), latestVersion: "0.3.0" }), ]), ).toHaveLength(2); }); it("builds a notification key from the update advisory fields", () => { const codex = updateCandidate({ - driver: "codex", + driver: driver("codex"), version: "1.0.0", latestVersion: "1.1.0", }); const cursor = updateCandidate({ - driver: "cursor", + driver: driver("cursor"), version: "0.2.0", latestVersion: "0.3.0", }); @@ -105,8 +120,8 @@ describe("provider update launch notification logic", () => { it("describes a single one-click update", () => { const view = getProviderUpdateInitialToastView({ - updateProviders: [updateCandidate({ driver: "codex", latestVersion: "1.1.0" })], - oneClickProviders: [updateCandidate({ driver: "codex", latestVersion: "1.1.0" })], + updateProviders: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], + oneClickProviders: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], }); expect(view).toMatchObject({ @@ -120,8 +135,8 @@ describe("provider update launch notification logic", () => { it("describes settings-only updates without one-click support", () => { const view = getProviderUpdateInitialToastView({ updateProviders: [ - updateCandidate({ driver: "codex", canUpdate: false }), - updateCandidate({ driver: "cursor", canUpdate: false }), + updateCandidate({ driver: driver("codex"), canUpdate: false }), + updateCandidate({ driver: driver("cursor"), canUpdate: false }), ], oneClickProviders: [], }); @@ -133,7 +148,7 @@ describe("provider update launch notification logic", () => { const view = getProviderUpdateProgressToastView({ providers: [ provider({ - driver: "codex", + driver: driver("codex"), updateState: { status: "running", startedAt: checkedAt, @@ -157,7 +172,7 @@ describe("provider update launch notification logic", () => { const view = getProviderUpdateProgressToastView({ providers: [ provider({ - driver: "codex", + driver: driver("codex"), updateState: { status: "failed", startedAt: checkedAt, @@ -181,7 +196,7 @@ describe("provider update launch notification logic", () => { it("resolves a single-provider completion view from the returned provider snapshot", () => { const view = getSingleProviderUpdateProgressToastView( provider({ - provider: "codex", + driver: driver("codex"), updateState: { status: "failed", startedAt: checkedAt, @@ -204,7 +219,7 @@ describe("provider update launch notification logic", () => { const view = getProviderUpdateProgressToastView({ providers: [ provider({ - driver: "cursor", + driver: driver("cursor"), updateState: { status: "unchanged", startedAt: checkedAt, @@ -229,7 +244,7 @@ describe("provider update launch notification logic", () => { const view = getProviderUpdateProgressToastView({ providers: [ provider({ - driver: "codex", + driver: driver("codex"), version: "1.1.0", latestVersion: "1.1.0", advisoryStatus: "current", @@ -257,7 +272,7 @@ describe("provider update launch notification logic", () => { it("uses the updated version in the single-provider success toast title", () => { const view = getSingleProviderUpdateProgressToastView( provider({ - provider: "codex", + driver: driver("codex"), version: "1.1.0", latestVersion: "1.1.0", advisoryStatus: "current", @@ -293,8 +308,8 @@ describe("provider update launch notification logic", () => { }); it("collects only attempted provider snapshots from update responses", () => { - const codex = provider({ driver: "codex" }); - const cursor = provider({ driver: "cursor" }); + const codex = provider({ driver: driver("codex") }); + const cursor = provider({ driver: driver("cursor") }); const results: PromiseSettledResult<{ readonly providers: ReadonlyArray }>[] = [ { status: "fulfilled", value: { providers: [codex, cursor] } }, ]; @@ -302,7 +317,7 @@ describe("provider update launch notification logic", () => { expect( collectUpdatedProviderSnapshots({ results, - providerKinds: new Set(["cursor"]), + providerKinds: new Set([driver("cursor")]), }), ).toEqual([cursor]); }); @@ -310,7 +325,7 @@ describe("provider update launch notification logic", () => { it("summarizes active provider updates for the sidebar pill", () => { const view = getProviderUpdateSidebarPillView([ provider({ - provider: "codex", + driver: driver("codex"), updateState: { status: "running", startedAt: checkedAt, @@ -320,7 +335,7 @@ describe("provider update launch notification logic", () => { }, }), provider({ - provider: "cursor", + driver: driver("cursor"), updateState: { status: "queued", startedAt: null, @@ -341,7 +356,7 @@ describe("provider update launch notification logic", () => { it("uses the provider name for single active sidebar pill updates", () => { const view = getProviderUpdateSidebarPillView([ provider({ - provider: "codex", + driver: driver("codex"), updateState: { status: "running", startedAt: checkedAt, @@ -364,7 +379,7 @@ describe("provider update launch notification logic", () => { const view = getProviderUpdateSidebarPillView( [ provider({ - provider: "claudeAgent", + driver: driver("claudeAgent"), updateState: { status: "failed", startedAt: checkedAt, @@ -390,7 +405,7 @@ describe("provider update launch notification logic", () => { const view = getProviderUpdateSidebarPillView( [ provider({ - provider: "codex", + driver: driver("codex"), version: "1.1.0", latestVersion: "1.1.0", advisoryStatus: "current", @@ -419,7 +434,7 @@ describe("provider update launch notification logic", () => { const view = getProviderUpdateSidebarPillView( [ provider({ - provider: "cursor", + driver: driver("cursor"), updateState: { status: "unchanged", startedAt: checkedAt, @@ -445,7 +460,7 @@ describe("provider update launch notification logic", () => { getProviderUpdateSidebarPillView( [ provider({ - provider: "codex", + driver: driver("codex"), updateState: { status: "failed", startedAt: checkedAt, @@ -463,7 +478,7 @@ describe("provider update launch notification logic", () => { it("shows a newer success before falling back to an older failure", () => { const providers = [ provider({ - provider: "claudeAgent", + driver: driver("claudeAgent"), updateState: { status: "failed", startedAt: checkedAt, @@ -473,7 +488,7 @@ describe("provider update launch notification logic", () => { }, }), provider({ - provider: "codex", + driver: driver("codex"), version: "1.2.0", latestVersion: "1.2.0", advisoryStatus: "current", @@ -510,8 +525,8 @@ describe("provider update launch notification logic", () => { it("does not show a sidebar pill for passive update availability", () => { expect( getProviderUpdateSidebarPillView([ - provider({ provider: "codex", canUpdate: true }), - provider({ provider: "cursor", canUpdate: false }), + provider({ driver: driver("codex"), canUpdate: true }), + provider({ driver: driver("cursor"), canUpdate: false }), ]), ).toBeNull(); }); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index a07427d239..fbd4fb1740 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -140,7 +140,9 @@ export function providerUpdateCandidateKey(provider: ProviderUpdateCandidate): s } export function formatProviderList(providers: ReadonlyArray>) { - const names = providers.map((provider) => PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver); + const names = providers.map( + (provider) => PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver, + ); if (names.length <= 2) { return names.join(" and "); } @@ -348,7 +350,8 @@ export function getProviderUpdateSidebarPillView( const activeProviders = dedupedProviders.filter(isProviderUpdateActive); if (activeProviders.length > 0) { const activeProvider = activeProviders[0]!; - const activeProviderName = PROVIDER_DISPLAY_NAMES[activeProvider.driver] ?? activeProvider.driver; + const activeProviderName = + PROVIDER_DISPLAY_NAMES[activeProvider.driver] ?? activeProvider.driver; return { key: `loading:${activeProviders .map((provider) => `${provider.driver}:${provider.updateState?.status ?? "idle"}`) diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx index 16546f074e..b877660198 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx @@ -35,6 +35,14 @@ type ActiveProviderUpdateToast = function ProviderUpdateToastIcon({ provider }: { provider: ProviderDriverKind }) { const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[provider]; + if (!ProviderIcon) { + return ( + + + ); + } + return ( - {versionAdvisory.updateCommand ? ( + {updateCommand ? ( - copyToClipboard(versionAdvisory.updateCommand, { + copyToClipboard(updateCommand, { providerName: displayName, }) } @@ -679,7 +680,7 @@ export function ProviderInstanceCard({ } /> - {versionAdvisory.updateCommand} + {updateCommand} ) : null} diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index f0b34a0f7c..06622a761b 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -92,7 +92,11 @@ export function getProviderVersionLabel(version: string | null | undefined) { export function getProviderVersionAdvisoryPresentation( advisory: ServerProviderVersionAdvisory | undefined, -) { +): { + readonly detail: string; + readonly updateCommand: string | null; + readonly emphasis: "normal" | "strong"; +} | null { if (!advisory || advisory.status === "current" || advisory.status === "unknown") { return null; } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 7211fe27cc..88c0c86a80 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -533,10 +533,14 @@ describe("wsApi", () => { const api = createLocalApi(rpcClientMock as never); - await expect(api.server.updateProvider({ provider: "codex" })).resolves.toEqual({ + await expect( + api.server.updateProvider({ provider: ProviderDriverKind.make("codex") }), + ).resolves.toEqual({ providers: nextProviders, }); - expect(rpcClientMock.server.updateProvider).toHaveBeenCalledWith({ provider: "codex" }); + expect(rpcClientMock.server.updateProvider).toHaveBeenCalledWith({ + provider: ProviderDriverKind.make("codex"), + }); }); it("forwards server settings updates directly to the RPC client", async () => { From e75b6e6212a18687ac0272b31b23f88fd0e004bc Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:11:31 +0100 Subject: [PATCH 12/30] Make provider updates binary-resolution aware --- .../src/provider/Drivers/ClaudeDriver.ts | 12 +- .../src/provider/Drivers/CodexDriver.ts | 12 +- .../src/provider/Drivers/CursorDriver.ts | 6 +- .../src/provider/Drivers/OpenCodeDriver.ts | 12 +- .../src/provider/Layers/ProviderRegistry.ts | 31 ++++ .../src/provider/Services/ProviderRegistry.ts | 10 ++ .../src/provider/providerUpdater.test.ts | 47 +++++ apps/server/src/provider/providerUpdater.ts | 38 ++-- .../provider/providerVersionLifecycle.test.ts | 48 +++++ .../src/provider/providerVersionLifecycle.ts | 169 +++++++++++++++--- apps/server/src/server.test.ts | 3 + ...iderUpdateLaunchNotification.logic.test.ts | 25 ++- .../ProviderUpdateLaunchNotification.logic.ts | 30 ++++ .../ProviderUpdateLaunchNotification.tsx | 10 +- packages/shared/src/shell.test.ts | 12 ++ packages/shared/src/shell.ts | 29 ++- 16 files changed, 422 insertions(+), 72 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 4f3bd9d94f..6e61e5f5e4 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -86,6 +86,10 @@ export const ClaudeDriver: ProviderDriver = { instanceId, }); const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; + const versionLifecycle = getProviderVersionLifecycle(DRIVER_KIND, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); const stampIdentity = withInstanceIdentity({ instanceId, @@ -125,16 +129,16 @@ export const ClaudeDriver: ProviderDriver = { ); const snapshot = yield* makeManagedServerProvider({ - versionLifecycle: getProviderVersionLifecycle(DRIVER_KIND), + versionLifecycle, getSettings: Effect.succeed(effectiveConfig), streamSettings: Stream.never, haveSettingsChanged: () => false, initialSnapshot: (settings) => stampIdentity(makePendingClaudeProvider(settings)), checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => - Effect.promise(() => enrichProviderSnapshotWithVersionAdvisory(snapshot)).pipe( - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), - ), + Effect.promise(() => + enrichProviderSnapshotWithVersionAdvisory(snapshot, versionLifecycle), + ).pipe(Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot))), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index d180e74847..804cd69532 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -119,6 +119,10 @@ export const CodexDriver: ProviderDriver = { enabled, homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; + const versionLifecycle = getProviderVersionLifecycle(DRIVER_KIND, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error // channels at construction time — their failure modes are all on the @@ -142,16 +146,16 @@ export const CodexDriver: ProviderDriver = { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const snapshot = yield* makeManagedServerProvider({ - versionLifecycle: getProviderVersionLifecycle(DRIVER_KIND), + versionLifecycle, getSettings: Effect.succeed(effectiveConfig), streamSettings: Stream.never, haveSettingsChanged: () => false, initialSnapshot: (settings) => stampIdentity(makePendingCodexProvider(settings)), checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => - Effect.promise(() => enrichProviderSnapshotWithVersionAdvisory(snapshot)).pipe( - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), - ), + Effect.promise(() => + enrichProviderSnapshotWithVersionAdvisory(snapshot, versionLifecycle), + ).pipe(Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot))), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 36f8dea190..171ef41830 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -88,6 +88,10 @@ export const CursorDriver: ProviderDriver = { continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies CursorSettings; + const versionLifecycle = getProviderVersionLifecycle(DRIVER_KIND, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); const adapter = yield* makeCursorAdapter(effectiveConfig, { environment: processEnv, @@ -104,7 +108,7 @@ export const CursorDriver: ProviderDriver = { ); const snapshot = yield* makeManagedServerProvider({ - versionLifecycle: getProviderVersionLifecycle(DRIVER_KIND), + versionLifecycle, getSettings: Effect.succeed(effectiveConfig), streamSettings: Stream.never, haveSettingsChanged: () => false, diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index ee304b3338..f73bbc794c 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -91,6 +91,10 @@ export const OpenCodeDriver: ProviderDriver continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; + const versionLifecycle = getProviderVersionLifecycle(DRIVER_KIND, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); const adapter = yield* makeOpenCodeAdapter(effectiveConfig, { instanceId, @@ -106,16 +110,16 @@ export const OpenCodeDriver: ProviderDriver ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); const snapshot = yield* makeManagedServerProvider({ - versionLifecycle: getProviderVersionLifecycle(DRIVER_KIND), + versionLifecycle, getSettings: Effect.succeed(effectiveConfig), streamSettings: Stream.never, haveSettingsChanged: () => false, initialSnapshot: (settings) => stampIdentity(makePendingOpenCodeProvider(settings)), checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => - Effect.promise(() => enrichProviderSnapshotWithVersionAdvisory(snapshot)).pipe( - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), - ), + Effect.promise(() => + enrichProviderSnapshotWithVersionAdvisory(snapshot, versionLifecycle), + ).pipe(Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot))), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index b36a23b54c..8bcfcf809a 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -44,6 +44,11 @@ import { writeProviderStatusCache, } from "../providerStatusCache.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; +import { + disableProviderVersionLifecycleUpdates, + getProviderVersionLifecycle as getDefaultProviderVersionLifecycle, + haveProviderVersionLifecyclesEqual, +} from "../providerVersionLifecycle.ts"; import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; const loadProviders = ( @@ -445,6 +450,31 @@ export const ProviderRegistryLive = Layer.effect( return yield* refreshOneSource(providerSource); }); + const getProviderVersionLifecycle = Effect.fn("getProviderVersionLifecycle")(function* ( + provider: ProviderDriverKind, + ) { + const instances = Array.from((yield* Ref.get(liveSubsRef)).values()).filter( + (instance) => instance.driverKind === provider, + ); + if (instances.length === 0) { + return getDefaultProviderVersionLifecycle(provider); + } + + const [firstInstance, ...restInstances] = instances; + const firstLifecycle = firstInstance?.snapshot.versionLifecycle; + if (!firstLifecycle) { + return getDefaultProviderVersionLifecycle(provider); + } + + const hasMixedLifecycles = restInstances.some( + (instance) => + !haveProviderVersionLifecyclesEqual(firstLifecycle, instance.snapshot.versionLifecycle), + ); + return hasMixedLifecycles + ? disableProviderVersionLifecycleUpdates(firstLifecycle) + : firstLifecycle; + }); + /** * Diff the aggregator's live-source set against the current * `ProviderInstanceRegistry` and: @@ -635,6 +665,7 @@ export const ProviderRegistryLive = Layer.effect( refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + getProviderVersionLifecycle, setProviderUpdateState, get streamChanges() { return Stream.fromPubSub(changesPubSub); diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index 5f4028bc98..a45a9563fc 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -14,6 +14,7 @@ import type { } from "@t3tools/contracts"; import { Context } from "effect"; import type { Effect, Stream } from "effect"; +import type { ProviderVersionLifecycle } from "../providerVersionLifecycle.ts"; export interface ProviderRegistryShape { /** @@ -44,6 +45,15 @@ export interface ProviderRegistryShape { instanceId: ProviderInstanceId, ) => Effect.Effect>; + /** + * Resolve the currently active update lifecycle for a provider driver + * across its live instances. Mixed or unsafe instance strategies are + * downgraded to a manual-only lifecycle. + */ + readonly getProviderVersionLifecycle: ( + provider: ProviderDriverKind, + ) => Effect.Effect; + /** * Apply volatile provider-update state to every live snapshot for the * specified driver. Used for one-click update progress and results; this diff --git a/apps/server/src/provider/providerUpdater.test.ts b/apps/server/src/provider/providerUpdater.test.ts index 99e1fb97ba..906fb969bb 100644 --- a/apps/server/src/provider/providerUpdater.test.ts +++ b/apps/server/src/provider/providerUpdater.test.ts @@ -11,6 +11,7 @@ import { Cause, Effect, Exit, Fiber, Ref, Schema, Stream } from "effect"; import type { ProcessRunResult } from "../processRunner.ts"; import type { ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; import { makeProviderUpdater, type ProviderUpdateRunner } from "./providerUpdater.ts"; +import { getProviderVersionLifecycle } from "./providerVersionLifecycle.ts"; const CODEX_DRIVER = ProviderDriverKind.make("codex"); const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); @@ -95,6 +96,8 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + getProviderVersionLifecycle: (provider) => + Effect.succeed(getProviderVersionLifecycle(provider)), setProviderUpdateState, streamChanges: Stream.empty, }; @@ -134,6 +137,50 @@ describe("providerUpdater", () => { }), ); + it.effect("uses the resolved provider lifecycle when choosing the update executable", () => + Effect.gen(function* () { + const { registry } = yield* makeRegistry({ + ...baseProvider, + versionAdvisory: { + status: "behind_latest", + currentVersion: "2.0.14", + latestVersion: "2.1.123", + updateCommand: "bun add -g @anthropic-ai/claude-code@latest", + canUpdate: true, + checkedAt: "2026-04-30T12:00:00.000Z", + message: "Update available.", + }, + }); + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + const updater = yield* makeProviderUpdater({ + providerRegistry: { + ...registry, + getProviderVersionLifecycle: () => + Effect.succeed({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateCommand: "bun add -g @openai/codex@latest", + updateExecutable: "bun", + updateArgs: ["add", "-g", "@openai/codex@latest"], + updateLockKey: "bun-global", + }), + }, + runUpdate: async (command, args) => { + calls.push({ command, args }); + return okResult("updated"); + }, + }); + + yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(calls, [ + { + command: "bun", + args: ["add", "-g", "@openai/codex@latest"], + }, + ]); + }), + ); + it.effect("records command failure output in provider update state", () => Effect.gen(function* () { const { registry } = yield* makeRegistry(); diff --git a/apps/server/src/provider/providerUpdater.ts b/apps/server/src/provider/providerUpdater.ts index 172af77451..92f487e16c 100644 --- a/apps/server/src/provider/providerUpdater.ts +++ b/apps/server/src/provider/providerUpdater.ts @@ -11,10 +11,8 @@ import * as Semaphore from "effect/Semaphore"; import type { ProcessRunResult } from "../processRunner.ts"; import { runProcess } from "../processRunner.ts"; import type { ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; -import { - enrichProviderSnapshotWithVersionAdvisory, - getProviderVersionLifecycle, -} from "./providerVersionLifecycle.ts"; +import { enrichProviderSnapshotWithVersionAdvisory } from "./providerVersionLifecycle.ts"; +import type { ProviderVersionLifecycle } from "./providerVersionLifecycle.ts"; const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; @@ -35,13 +33,6 @@ interface VerifiedProviderRefresh { readonly verifiedProviders: ReadonlyArray; } -const UPDATE_LOCK_PROVIDERS = [ - ProviderDriverKind.make("codex"), - ProviderDriverKind.make("claudeAgent"), - ProviderDriverKind.make("cursor"), - ProviderDriverKind.make("opencode"), -] as const satisfies ReadonlyArray; - const defaultRunner: ProviderUpdateRunner = (command, args) => runProcess(command, args, { timeoutMs: UPDATE_TIMEOUT_MS, @@ -106,12 +97,6 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i }) { const runningProvidersRef = yield* Ref.make>(new Set()); const updateLocks = new Map(); - for (const provider of UPDATE_LOCK_PROVIDERS) { - const lifecycle = getProviderVersionLifecycle(provider); - if (lifecycle.updateLockKey && !updateLocks.has(lifecycle.updateLockKey)) { - updateLocks.set(lifecycle.updateLockKey, yield* Semaphore.make(1)); - } - } const runUpdate = input.runUpdate ?? defaultRunner; const acquireProvider = Effect.fn("acquireProvider")(function* (provider: ProviderDriverKind) { @@ -132,8 +117,19 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i return next; }); + const getUpdateLock = Effect.fn("getUpdateLock")(function* (updateLockKey: string) { + const existing = updateLocks.get(updateLockKey); + if (existing) { + return existing; + } + const next = yield* Semaphore.make(1); + updateLocks.set(updateLockKey, next); + return next; + }); + const verifyRefreshedProvider = ( provider: ProviderDriverKind, + versionLifecycle: ProviderVersionLifecycle, ): Effect.Effect => input.providerRegistry.getProviders.pipe( Effect.map((providers) => @@ -165,7 +161,7 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i refreshedProviders, (refreshedProvider) => Effect.promise(() => - enrichProviderSnapshotWithVersionAdvisory(refreshedProvider), + enrichProviderSnapshotWithVersionAdvisory(refreshedProvider, versionLifecycle), ), { concurrency: "unbounded", @@ -194,7 +190,7 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i const updateProvider: ProviderUpdaterShape["updateProvider"] = (provider) => Effect.gen(function* () { - const lifecycle = getProviderVersionLifecycle(provider); + const lifecycle = yield* input.providerRegistry.getProviderVersionLifecycle(provider); const updateExecutable = lifecycle.updateExecutable; const updateLockKey = lifecycle.updateLockKey; if (!updateExecutable || !updateLockKey) { @@ -257,7 +253,7 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i ); } - const { verifiedProviders } = yield* verifyRefreshedProvider(provider); + const { verifiedProviders } = yield* verifyRefreshedProvider(provider, lifecycle); const couldNotVerify = verifiedProviders.length === 0; const stillOutdated = couldNotVerify || @@ -276,7 +272,7 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i }), ); }); - const lock = updateLocks.get(updateLockKey)!; + const lock = yield* getUpdateLock(updateLockKey); return yield* lock .withPermits(1)(run) diff --git a/apps/server/src/provider/providerVersionLifecycle.test.ts b/apps/server/src/provider/providerVersionLifecycle.test.ts index 380e84f741..5fea0bc29e 100644 --- a/apps/server/src/provider/providerVersionLifecycle.test.ts +++ b/apps/server/src/provider/providerVersionLifecycle.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "vitest"; +import { mkdirSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { ProviderDriverKind } from "@t3tools/contracts"; import { createProviderVersionAdvisory, @@ -64,4 +67,49 @@ describe("providerVersionLifecycle", () => { updateLockKey: "cursor-agent", }); }); + + it("switches package-managed providers to bun updates when the resolved binary lives in bun's global bin", () => { + const tempDir = path.join(os.tmpdir(), `t3-bun-lifecycle-${Date.now()}`); + const bunBinDir = path.join(tempDir, ".bun", "bin"); + mkdirSync(bunBinDir, { recursive: true }); + writeFileSync(path.join(bunBinDir, "claude.exe"), "MZ"); + + expect( + getProviderVersionLifecycle(driver("claudeAgent"), { + binaryPath: "claude", + platform: "win32", + env: { + PATH: bunBinDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ).toEqual({ + provider: driver("claudeAgent"), + packageName: "@anthropic-ai/claude-code", + updateCommand: "bun add -g @anthropic-ai/claude-code@latest", + updateExecutable: "bun", + updateArgs: ["add", "-g", "@anthropic-ai/claude-code@latest"], + updateLockKey: "bun-global", + }); + }); + + it("disables one-click updates for explicit custom binary paths it cannot safely map", () => { + expect( + getProviderVersionLifecycle(driver("codex"), { + binaryPath: "C:\\Tools\\codex\\codex.exe", + platform: "win32", + env: { + PATH: "", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ).toEqual({ + provider: driver("codex"), + packageName: "@openai/codex", + updateCommand: null, + updateExecutable: null, + updateArgs: [], + updateLockKey: null, + }); + }); }); diff --git a/apps/server/src/provider/providerVersionLifecycle.ts b/apps/server/src/provider/providerVersionLifecycle.ts index 991a80a056..8ba5c9f23b 100644 --- a/apps/server/src/provider/providerVersionLifecycle.ts +++ b/apps/server/src/provider/providerVersionLifecycle.ts @@ -3,6 +3,7 @@ import { type ServerProvider, type ServerProviderVersionAdvisory, } from "@t3tools/contracts"; +import { resolveCommandPath } from "@t3tools/shared/shell"; import { compareCliVersions } from "./cliVersion.ts"; @@ -26,22 +27,25 @@ export interface ProviderVersionLifecycle { readonly updateLockKey: string | null; } +interface ProviderVersionLifecycleResolutionOptions { + readonly binaryPath?: string | null; + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; +} + +interface PackageManagedProviderVersionLifecycleDefinition { + readonly provider: ProviderDriverKind; + readonly packageName: string; +} + const PROVIDER_VERSION_LIFECYCLES = { codex: { provider: CODEX_DRIVER, packageName: "@openai/codex", - updateCommand: "npm install -g @openai/codex@latest", - updateExecutable: "npm", - updateArgs: ["install", "-g", "@openai/codex@latest"], - updateLockKey: "npm-global", }, claudeAgent: { provider: CLAUDE_AGENT_DRIVER, packageName: "@anthropic-ai/claude-code", - updateCommand: "npm install -g @anthropic-ai/claude-code@latest", - updateExecutable: "npm", - updateArgs: ["install", "-g", "@anthropic-ai/claude-code@latest"], - updateLockKey: "npm-global", }, cursor: { provider: CURSOR_DRIVER, @@ -54,12 +58,13 @@ const PROVIDER_VERSION_LIFECYCLES = { opencode: { provider: OPENCODE_DRIVER, packageName: "opencode-ai", - updateCommand: "npm install -g opencode-ai@latest", - updateExecutable: "npm", - updateArgs: ["install", "-g", "opencode-ai@latest"], - updateLockKey: "npm-global", }, -} as const satisfies Record; +} as const satisfies Record< + Exclude, + PackageManagedProviderVersionLifecycleDefinition +> & { + readonly cursor: ProviderVersionLifecycle; +}; interface LatestVersionCacheEntry { readonly expiresAt: number; @@ -76,21 +81,138 @@ function isVersionLifecycleProvider(provider: string): provider is VersionLifecy return provider in PROVIDER_VERSION_LIFECYCLES; } +function makeProviderVersionLifecycle(input: { + readonly provider: ProviderDriverKind; + readonly packageName: string | null; + readonly updateExecutable: string | null; + readonly updateArgs: ReadonlyArray; + readonly updateLockKey: string | null; +}): ProviderVersionLifecycle { + return { + provider: input.provider, + packageName: input.packageName, + updateCommand: + input.updateExecutable === null + ? null + : [input.updateExecutable, ...input.updateArgs].join(" "), + updateExecutable: input.updateExecutable, + updateArgs: input.updateArgs, + updateLockKey: input.updateLockKey, + }; +} + +function makeManualOnlyProviderVersionLifecycle(input: { + readonly provider: ProviderDriverKind; + readonly packageName: string | null; +}): ProviderVersionLifecycle { + return makeProviderVersionLifecycle({ + provider: input.provider, + packageName: input.packageName, + updateExecutable: null, + updateArgs: [], + updateLockKey: null, + }); +} + +function makeNpmGlobalProviderVersionLifecycle( + definition: PackageManagedProviderVersionLifecycleDefinition, +): ProviderVersionLifecycle { + return makeProviderVersionLifecycle({ + provider: definition.provider, + packageName: definition.packageName, + updateExecutable: "npm", + updateArgs: ["install", "-g", `${definition.packageName}@latest`], + updateLockKey: "npm-global", + }); +} + +function makeBunGlobalProviderVersionLifecycle( + definition: PackageManagedProviderVersionLifecycleDefinition, +): ProviderVersionLifecycle { + return makeProviderVersionLifecycle({ + provider: definition.provider, + packageName: definition.packageName, + updateExecutable: "bun", + updateArgs: ["add", "-g", `${definition.packageName}@latest`], + updateLockKey: "bun-global", + }); +} + +function hasPathSeparator(value: string): boolean { + return value.includes("/") || value.includes("\\"); +} + +function isBunGlobalCommandPath(commandPath: string): boolean { + return commandPath.replaceAll("\\", "/").toLowerCase().includes("/.bun/bin/"); +} + +function resolvePackageManagedProviderVersionLifecycle( + definition: PackageManagedProviderVersionLifecycleDefinition, + options?: ProviderVersionLifecycleResolutionOptions, +): ProviderVersionLifecycle { + const binaryPath = nonEmptyString(options?.binaryPath); + if (!binaryPath) { + return makeNpmGlobalProviderVersionLifecycle(definition); + } + + const resolvedCommandPath = + resolveCommandPath(binaryPath, { + ...(options?.platform ? { platform: options.platform } : {}), + ...(options?.env ? { env: options.env } : {}), + }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); + if (resolvedCommandPath && isBunGlobalCommandPath(resolvedCommandPath)) { + return makeBunGlobalProviderVersionLifecycle(definition); + } + + if (!hasPathSeparator(binaryPath)) { + return makeNpmGlobalProviderVersionLifecycle(definition); + } + + return makeManualOnlyProviderVersionLifecycle(definition); +} + +export function haveProviderVersionLifecyclesEqual( + left: ProviderVersionLifecycle, + right: ProviderVersionLifecycle, +): boolean { + return ( + left.provider === right.provider && + left.packageName === right.packageName && + left.updateCommand === right.updateCommand && + left.updateExecutable === right.updateExecutable && + left.updateLockKey === right.updateLockKey && + left.updateArgs.length === right.updateArgs.length && + left.updateArgs.every((value, index) => value === right.updateArgs[index]) + ); +} + +export function disableProviderVersionLifecycleUpdates( + lifecycle: ProviderVersionLifecycle, +): ProviderVersionLifecycle { + return makeManualOnlyProviderVersionLifecycle({ + provider: lifecycle.provider, + packageName: lifecycle.packageName, + }); +} + export function getProviderVersionLifecycle( provider: ProviderDriverKind, + options?: ProviderVersionLifecycleResolutionOptions, ): ProviderVersionLifecycle { const providerKey = String(provider); if (isVersionLifecycleProvider(providerKey)) { - return PROVIDER_VERSION_LIFECYCLES[providerKey]; + if (providerKey === "cursor") { + return PROVIDER_VERSION_LIFECYCLES.cursor; + } + return resolvePackageManagedProviderVersionLifecycle( + PROVIDER_VERSION_LIFECYCLES[providerKey], + options, + ); } - return { + return makeManualOnlyProviderVersionLifecycle({ provider, packageName: null, - updateCommand: null, - updateExecutable: null, - updateArgs: [], - updateLockKey: null, - }; + }); } function deriveVersionAdvisory(input: { @@ -117,8 +239,9 @@ export function createProviderVersionAdvisory(input: { readonly currentVersion: string | null; readonly latestVersion?: string | null; readonly checkedAt?: string | null; + readonly versionLifecycle?: ProviderVersionLifecycle; }): ServerProviderVersionAdvisory { - const lifecycle = getProviderVersionLifecycle(input.driver); + const lifecycle = input.versionLifecycle ?? getProviderVersionLifecycle(input.driver); const latestVersion = input.latestVersion ?? null; const advisory = deriveVersionAdvisory({ currentVersion: input.currentVersion, @@ -183,7 +306,9 @@ export async function resolveLatestProviderVersion( export async function enrichProviderSnapshotWithVersionAdvisory( snapshot: ServerProvider, + versionLifecycle?: ProviderVersionLifecycle, ): Promise { + const lifecycle = versionLifecycle ?? getProviderVersionLifecycle(snapshot.driver); if (!snapshot.enabled || !snapshot.installed || !snapshot.version) { return { ...snapshot, @@ -191,6 +316,7 @@ export async function enrichProviderSnapshotWithVersionAdvisory( driver: snapshot.driver, currentVersion: snapshot.version, checkedAt: snapshot.checkedAt, + versionLifecycle: lifecycle, }), }; } @@ -203,6 +329,7 @@ export async function enrichProviderSnapshotWithVersionAdvisory( currentVersion: snapshot.version, latestVersion, checkedAt: new Date().toISOString(), + versionLifecycle: lifecycle, }), }; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 23457d901b..d2b187cba3 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -80,6 +80,7 @@ import { ProviderRegistry, type ProviderRegistryShape, } from "./provider/Services/ProviderRegistry.ts"; +import { getProviderVersionLifecycle } from "./provider/providerVersionLifecycle.ts"; import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; @@ -518,6 +519,8 @@ const buildAppUnderTest = (options?: { getProviders: Effect.succeed([]), refresh: () => Effect.succeed([]), refreshInstance: () => Effect.succeed([]), + getProviderVersionLifecycle: (provider) => + Effect.succeed(getProviderVersionLifecycle(provider)), setProviderUpdateState: () => Effect.succeed([]), streamChanges: Stream.empty, ...options?.layers?.providerRegistry, diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 7efcfa90ef..a498bff849 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { + canOneClickUpdateProviderCandidate, collectProviderUpdateCandidates, collectUpdatedProviderSnapshots, firstRejectedProviderUpdateMessage, @@ -29,6 +30,7 @@ function provider(input: { readonly version?: string | null; readonly latestVersion?: string | null; readonly canUpdate?: boolean; + readonly updateCommand?: string | null; readonly updateState?: ServerProvider["updateState"]; readonly advisoryStatus?: NonNullable["status"]; }): ServerProvider { @@ -48,7 +50,7 @@ function provider(input: { status: input.advisoryStatus ?? "behind_latest", currentVersion: input.version ?? "1.0.0", latestVersion: "latestVersion" in input ? input.latestVersion : "1.1.0", - updateCommand: "npm install -g provider", + updateCommand: "updateCommand" in input ? input.updateCommand : "npm install -g provider", canUpdate: input.canUpdate ?? true, checkedAt, message: "Update available.", @@ -100,6 +102,27 @@ describe("provider update launch notification logic", () => { ).toHaveLength(2); }); + it("disables one-click updates when provider instances disagree on the update command", () => { + const candidate = updateCandidate({ + driver: driver("claudeAgent"), + instanceId: instanceId("claude_personal"), + latestVersion: "2.1.123", + }); + + expect( + canOneClickUpdateProviderCandidate(candidate, [ + candidate, + provider({ + driver: driver("claudeAgent"), + instanceId: instanceId("claude_work"), + latestVersion: "2.1.123", + canUpdate: true, + updateCommand: "bun add -g @anthropic-ai/claude-code@latest", + }), + ]), + ).toBe(false); + }); + it("builds a notification key from the update advisory fields", () => { const codex = updateCandidate({ driver: driver("codex"), diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index fbd4fb1740..1870220fab 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -118,6 +118,36 @@ export function collectProviderUpdateCandidates( return dedupeProvidersByDriver(providers.filter(isProviderUpdateCandidate)); } +export function canOneClickUpdateProviderCandidate( + candidate: ProviderUpdateCandidate, + providers: ReadonlyArray, +): boolean { + if ( + candidate.versionAdvisory.canUpdate !== true || + candidate.versionAdvisory.updateCommand === null || + candidate.updateState?.status === "queued" || + candidate.updateState?.status === "running" + ) { + return false; + } + + const driverProviders = providers.filter((provider) => provider.driver === candidate.driver); + if (driverProviders.length === 0) { + return false; + } + + const updateCommands = new Set(); + for (const provider of driverProviders) { + const advisory = provider.versionAdvisory; + if (!advisory || advisory.canUpdate !== true || advisory.updateCommand === null) { + return false; + } + updateCommands.add(advisory.updateCommand); + } + + return updateCommands.size === 1; +} + export function providerUpdateNotificationKey( providers: ReadonlyArray, ): string | null { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx index b877660198..0bc8abacd2 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx @@ -7,6 +7,7 @@ import { ensureLocalApi } from "../localApi"; import { useServerProviders } from "../rpc/serverState"; import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; import { + canOneClickUpdateProviderCandidate, collectProviderUpdateCandidates, collectUpdatedProviderSnapshots, firstRejectedProviderUpdateMessage, @@ -109,13 +110,8 @@ export function ProviderUpdateLaunchNotification() { ); const oneClickProviders = useMemo( () => - updateProviders.filter( - (provider) => - provider.versionAdvisory.canUpdate === true && - provider.updateState?.status !== "queued" && - provider.updateState?.status !== "running", - ), - [updateProviders], + updateProviders.filter((provider) => canOneClickUpdateProviderCandidate(provider, providers)), + [providers, updateProviders], ); const openProviderSettings = useCallback( diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 3acc8b7b47..214c03a7e5 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -10,6 +10,7 @@ import { readEnvironmentFromWindowsShell, readPathFromLaunchctl, readPathFromLoginShell, + resolveCommandPath, resolveKnownWindowsCliDirs, resolveWindowsEnvironment, } from "./shell.ts"; @@ -332,6 +333,17 @@ describe("isCommandAvailable", () => { }); }); +describe("resolveCommandPath", () => { + it("returns the first executable resolved from PATH", () => { + expect( + resolveCommandPath("definitely-not-installed", { + platform: "win32", + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }), + ).toBeNull(); + }); +}); + describe("resolveWindowsEnvironment", () => { it("returns the baseline no-profile PATH patch when node is already available", () => { const readEnvironment = vi.fn( diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 6edfdcffff..ecb88f837c 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -376,23 +376,26 @@ function isExecutableFile( } } -export function isCommandAvailable( +export function resolveCommandPath( command: string, options: CommandAvailabilityOptions = {}, -): boolean { +): string | null { const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); if (command.includes("/") || command.includes("\\")) { - return commandCandidates.some((candidate) => - isExecutableFile(candidate, platform, windowsPathExtensions), - ); + for (const candidate of commandCandidates) { + if (isExecutableFile(candidate, platform, windowsPathExtensions)) { + return candidate; + } + } + return null; } const pathValue = resolvePathEnvironmentVariable(env); - if (pathValue.length === 0) return false; + if (pathValue.length === 0) return null; const pathEntries = pathValue .split(pathDelimiterForPlatform(platform)) .map((entry) => stripWrappingQuotes(entry.trim())) @@ -400,12 +403,20 @@ export function isCommandAvailable( for (const pathEntry of pathEntries) { for (const candidate of commandCandidates) { - if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { - return true; + const candidatePath = join(pathEntry, candidate); + if (isExecutableFile(candidatePath, platform, windowsPathExtensions)) { + return candidatePath; } } } - return false; + return null; +} + +export function isCommandAvailable( + command: string, + options: CommandAvailabilityOptions = {}, +): boolean { + return resolveCommandPath(command, options) !== null; } export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray { From 6824618f7f8ebb286ed312310504bbbd33559d3c Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:08:57 +0100 Subject: [PATCH 13/30] Fix shared provider update lock race --- .../src/provider/providerUpdater.test.ts | 67 +++++++++++++++++++ apps/server/src/provider/providerUpdater.ts | 25 +++---- 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/providerUpdater.test.ts b/apps/server/src/provider/providerUpdater.test.ts index 906fb969bb..b278420441 100644 --- a/apps/server/src/provider/providerUpdater.test.ts +++ b/apps/server/src/provider/providerUpdater.test.ts @@ -15,8 +15,10 @@ import { getProviderVersionLifecycle } from "./providerVersionLifecycle.ts"; const CODEX_DRIVER = ProviderDriverKind.make("codex"); const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); +const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex"); const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor"); +const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); const baseProvider: ServerProvider = { instanceId: CODEX_INSTANCE_ID, @@ -38,6 +40,12 @@ const baseCursorProvider: ServerProvider = { driver: CURSOR_DRIVER, }; +const baseOpenCodeProvider: ServerProvider = { + ...baseProvider, + instanceId: OPENCODE_INSTANCE_ID, + driver: OPENCODE_DRIVER, +}; + const okResult = (stdout = ""): ProcessRunResult => ({ stdout, stderr: "", @@ -268,4 +276,63 @@ describe("providerUpdater", () => { yield* Fiber.join(first); }), ); + + it.effect("serializes different providers that share the same update lock key", () => + Effect.gen(function* () { + const { registry } = yield* makeRegistry([baseProvider, baseOpenCodeProvider]); + const firstStartedLatch: { resolve: () => void } = { resolve: () => {} }; + const releaseFirstLatch: { resolve: () => void } = { resolve: () => {} }; + const firstStarted = new Promise((resolve) => { + firstStartedLatch.resolve = resolve; + }); + const releaseFirst = new Promise((resolve) => { + releaseFirstLatch.resolve = resolve; + }); + const calls: Array = []; + const updater = yield* makeProviderUpdater({ + providerRegistry: { + ...registry, + getProviderVersionLifecycle: (provider) => + Effect.succeed({ + provider, + packageName: provider === OPENCODE_DRIVER ? "opencode-ai" : "@openai/codex", + updateCommand: + provider === OPENCODE_DRIVER + ? "npm install -g opencode-ai@latest" + : "npm install -g @openai/codex@latest", + updateExecutable: "npm", + updateArgs: + provider === OPENCODE_DRIVER + ? ["install", "-g", "opencode-ai@latest"] + : ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global", + }), + }, + runUpdate: async (_command, args) => { + calls.push(args.join(" ")); + if (calls.length === 1) { + firstStartedLatch.resolve(); + await releaseFirst; + } + return okResult(); + }, + }); + + const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); + yield* Effect.promise(() => firstStarted); + + const second = yield* updater.updateProvider(OPENCODE_DRIVER).pipe(Effect.forkScoped); + yield* Effect.promise(() => Promise.resolve()); + yield* Effect.promise(() => Promise.resolve()); + assert.deepStrictEqual(calls, ["install -g @openai/codex@latest"]); + + releaseFirstLatch.resolve(); + yield* Fiber.join(first); + yield* Fiber.join(second); + assert.deepStrictEqual(calls, [ + "install -g @openai/codex@latest", + "install -g opencode-ai@latest", + ]); + }), + ); }); diff --git a/apps/server/src/provider/providerUpdater.ts b/apps/server/src/provider/providerUpdater.ts index 92f487e16c..d3706097e1 100644 --- a/apps/server/src/provider/providerUpdater.ts +++ b/apps/server/src/provider/providerUpdater.ts @@ -16,6 +16,7 @@ import type { ProviderVersionLifecycle } from "./providerVersionLifecycle.ts"; const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; +const SHARED_UPDATE_LOCK_KEYS = ["npm-global", "bun-global", "cursor-agent"] as const; export type ProviderUpdateRunner = ( command: string, @@ -96,7 +97,11 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i readonly runUpdate?: ProviderUpdateRunner; }) { const runningProvidersRef = yield* Ref.make>(new Set()); - const updateLocks = new Map(); + const updateLocks = new Map( + yield* Effect.forEach(SHARED_UPDATE_LOCK_KEYS, (updateLockKey) => + Semaphore.make(1).pipe(Effect.map((semaphore) => [updateLockKey, semaphore] as const)), + ), + ); const runUpdate = input.runUpdate ?? defaultRunner; const acquireProvider = Effect.fn("acquireProvider")(function* (provider: ProviderDriverKind) { @@ -117,16 +122,6 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i return next; }); - const getUpdateLock = Effect.fn("getUpdateLock")(function* (updateLockKey: string) { - const existing = updateLocks.get(updateLockKey); - if (existing) { - return existing; - } - const next = yield* Semaphore.make(1); - updateLocks.set(updateLockKey, next); - return next; - }); - const verifyRefreshedProvider = ( provider: ProviderDriverKind, versionLifecycle: ProviderVersionLifecycle, @@ -272,7 +267,13 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i }), ); }); - const lock = yield* getUpdateLock(updateLockKey); + const lock = updateLocks.get(updateLockKey); + if (!lock) { + return yield* new ServerProviderUpdateError({ + provider, + reason: `Unsupported provider update lock key: ${updateLockKey}`, + }); + } return yield* lock .withPermits(1)(run) From 8b10d0e1c4a09801f3f638e5b68803c2dcfa393a Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:18:01 +0100 Subject: [PATCH 14/30] Release provider updates on unsupported lock keys --- .../src/provider/providerUpdater.test.ts | 35 +++++++++++++++++++ apps/server/src/provider/providerUpdater.ts | 1 + 2 files changed, 36 insertions(+) diff --git a/apps/server/src/provider/providerUpdater.test.ts b/apps/server/src/provider/providerUpdater.test.ts index b278420441..3f40b34798 100644 --- a/apps/server/src/provider/providerUpdater.test.ts +++ b/apps/server/src/provider/providerUpdater.test.ts @@ -335,4 +335,39 @@ describe("providerUpdater", () => { ]); }), ); + + it.effect("releases the running-provider marker when the update lock key is unsupported", () => + Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const updater = yield* makeProviderUpdater({ + providerRegistry: { + ...registry, + getProviderVersionLifecycle: (provider) => + Effect.succeed({ + provider, + packageName: "@openai/codex", + updateCommand: "npm install -g @openai/codex@latest", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@openai/codex@latest"], + updateLockKey: "unknown-lock-key", + }), + }, + }); + + const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit); + assert.strictEqual(Exit.isFailure(first), true); + + const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit); + assert.strictEqual(Exit.isFailure(second), true); + + if (Exit.isFailure(second)) { + const error = Cause.squash(second.cause); + assert.strictEqual(Schema.is(ServerProviderUpdateError)(error), true); + if (Schema.is(ServerProviderUpdateError)(error)) { + assert.include(error.reason, "Unsupported provider update lock key"); + assert.notInclude(error.reason, "already running"); + } + } + }), + ); }); diff --git a/apps/server/src/provider/providerUpdater.ts b/apps/server/src/provider/providerUpdater.ts index d3706097e1..2c1b88cc9b 100644 --- a/apps/server/src/provider/providerUpdater.ts +++ b/apps/server/src/provider/providerUpdater.ts @@ -269,6 +269,7 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i }); const lock = updateLocks.get(updateLockKey); if (!lock) { + yield* releaseProvider(provider); return yield* new ServerProviderUpdateError({ provider, reason: `Unsupported provider update lock key: ${updateLockKey}`, From 80fb17fd242af2891a10fe6dcec583cfeeed286d Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:31:45 +0100 Subject: [PATCH 15/30] Avoid stale queued state on unsupported update locks --- .../server/src/provider/providerUpdater.test.ts | 1 + apps/server/src/provider/providerUpdater.ts | 17 +++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/providerUpdater.test.ts b/apps/server/src/provider/providerUpdater.test.ts index 3f40b34798..a36206ed0e 100644 --- a/apps/server/src/provider/providerUpdater.test.ts +++ b/apps/server/src/provider/providerUpdater.test.ts @@ -359,6 +359,7 @@ describe("providerUpdater", () => { const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit); assert.strictEqual(Exit.isFailure(second), true); + assert.deepStrictEqual(yield* registry.getProviders, [baseProvider]); if (Exit.isFailure(second)) { const error = Cause.squash(second.cause); diff --git a/apps/server/src/provider/providerUpdater.ts b/apps/server/src/provider/providerUpdater.ts index 2c1b88cc9b..715b4d0792 100644 --- a/apps/server/src/provider/providerUpdater.ts +++ b/apps/server/src/provider/providerUpdater.ts @@ -203,6 +203,15 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i }); } + const lock = updateLocks.get(updateLockKey); + if (!lock) { + yield* releaseProvider(provider); + return yield* new ServerProviderUpdateError({ + provider, + reason: `Unsupported provider update lock key: ${updateLockKey}`, + }); + } + yield* input.providerRegistry.setProviderUpdateState( provider, makeUpdateState({ @@ -267,14 +276,6 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i }), ); }); - const lock = updateLocks.get(updateLockKey); - if (!lock) { - yield* releaseProvider(provider); - return yield* new ServerProviderUpdateError({ - provider, - reason: `Unsupported provider update lock key: ${updateLockKey}`, - }); - } return yield* lock .withPermits(1)(run) From 1179cdc7b953d4dc14b5dc4a9614f837fd55f81c Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Mon, 4 May 2026 11:22:24 +0100 Subject: [PATCH 16/30] Fix post-rebase import cleanup --- apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts | 2 +- apps/web/src/components/settings/SettingsPanels.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index ab3eb6c711..d4d562a39f 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -15,7 +15,7 @@ import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts" import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import { getProviderVersionLifecycle } from "../providerVersionLifecycle.ts"; -import type { TextGenerationShape } from "../../git/Services/TextGeneration.ts"; +import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f299f52366..7afbe9f00a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,6 +1,6 @@ import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { defaultInstanceIdForDriver, type DesktopUpdateChannel, From 5591cfcfc1e5ee1dc1f53b94c2aba4c122724912 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Mon, 4 May 2026 12:09:44 +0100 Subject: [PATCH 17/30] Improve provider update notification UX --- apps/desktop/src/clientPersistence.test.ts | 1 + ...iderUpdateLaunchNotification.logic.test.ts | 17 ++++ .../ProviderUpdateLaunchNotification.logic.ts | 15 +++- .../ProviderUpdateLaunchNotification.tsx | 19 +++- .../settings/ProviderInstanceCard.tsx | 31 ++++++- .../settings/SettingsPanels.browser.tsx | 63 ++++++++++++++ .../components/settings/SettingsPanels.tsx | 87 +++++++++++++++++++ apps/web/src/components/ui/toast.tsx | 22 ++++- apps/web/src/hooks/useSettings.ts | 38 +++++++- apps/web/src/localApi.test.ts | 2 + apps/web/src/providerUpdateDismissal.ts | 31 +++++++ packages/contracts/src/settings.ts | 3 + 12 files changed, 320 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/providerUpdateDismissal.ts diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index d4c4768d2c..f0c7c30e20 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -52,6 +52,7 @@ const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, favorites: [], diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index a498bff849..588107f97d 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -11,6 +11,7 @@ import { getProviderUpdateRejectedToastView, getProviderUpdateSidebarPillView, getSingleProviderUpdateProgressToastView, + hasOneClickUpdateProviderCandidate, isProviderUpdateCandidate, providerUpdateNotificationKey, type ProviderUpdateCandidate, @@ -123,6 +124,22 @@ describe("provider update launch notification logic", () => { ).toBe(false); }); + it("keeps the inline update action available while a provider update is already running", () => { + const candidate = updateCandidate({ + driver: driver("codex"), + updateState: { + status: "running", + startedAt: checkedAt, + finishedAt: null, + message: "Updating provider.", + output: null, + }, + }); + + expect(hasOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(true); + expect(canOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(false); + }); + it("builds a notification key from the update advisory fields", () => { const codex = updateCandidate({ driver: driver("codex"), diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 1870220fab..7e3fbfa2e5 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -118,15 +118,13 @@ export function collectProviderUpdateCandidates( return dedupeProvidersByDriver(providers.filter(isProviderUpdateCandidate)); } -export function canOneClickUpdateProviderCandidate( +export function hasOneClickUpdateProviderCandidate( candidate: ProviderUpdateCandidate, providers: ReadonlyArray, ): boolean { if ( candidate.versionAdvisory.canUpdate !== true || - candidate.versionAdvisory.updateCommand === null || - candidate.updateState?.status === "queued" || - candidate.updateState?.status === "running" + candidate.versionAdvisory.updateCommand === null ) { return false; } @@ -148,6 +146,15 @@ export function canOneClickUpdateProviderCandidate( return updateCommands.size === 1; } +export function canOneClickUpdateProviderCandidate( + candidate: ProviderUpdateCandidate, + providers: ReadonlyArray, +): boolean { + return ( + !isProviderUpdateActive(candidate) && hasOneClickUpdateProviderCandidate(candidate, providers) + ); +} + export function providerUpdateNotificationKey( providers: ReadonlyArray, ): string | null { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx index 0bc8abacd2..4b0578c23a 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { type ProviderDriverKind } from "@t3tools/contracts"; import { ensureLocalApi } from "../localApi"; +import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; import { useServerProviders } from "../rpc/serverState"; import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; import { @@ -102,6 +103,8 @@ export function ProviderUpdateLaunchNotification() { const navigate = useNavigate(); const providers = useServerProviders(); const activeToastRef = useRef(null); + const { clientSettingsHydrated, dismissedNotificationKeys, dismissNotificationKey } = + useDismissedProviderUpdateNotificationKeys(); const updateProviders = useMemo(() => collectProviderUpdateCandidates(providers), [providers]); const notificationKey = useMemo( @@ -162,7 +165,9 @@ export function ProviderUpdateLaunchNotification() { } if ( + !clientSettingsHydrated || !notificationKey || + dismissedNotificationKeys.has(notificationKey) || seenProviderUpdateNotificationKeys.has(notificationKey) || activeToastRef.current ) { @@ -176,6 +181,9 @@ export function ProviderUpdateLaunchNotification() { let toastId!: ProviderUpdateToastId; let updateStarted = false; const openSettings = () => openProviderSettings(toastId); + const dismissPrompt = () => { + dismissNotificationKey(notificationKey); + }; const runUpdates = () => { if (updateStarted || oneClickProviders.length === 0) { @@ -263,6 +271,7 @@ export function ProviderUpdateLaunchNotification() { ) : undefined, hideCopyButton: true, + onClose: dismissPrompt, ...(oneClickProviders.length > 0 ? { secondaryActionProps: { @@ -276,7 +285,15 @@ export function ProviderUpdateLaunchNotification() { }), ); activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; - }, [notificationKey, oneClickProviders, openProviderSettings, updateProviders]); + }, [ + clientSettingsHydrated, + dismissNotificationKey, + dismissedNotificationKeys, + notificationKey, + oneClickProviders, + openProviderSettings, + updateProviders, + ]); return null; } diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index bd58225153..c508c97cb3 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1,6 +1,14 @@ "use client"; -import { ChevronDownIcon, CopyIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; +import { + ChevronDownIcon, + CopyIcon, + DownloadIcon, + LoaderIcon, + PlusIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; import { useEffect, useState, type ReactNode } from "react"; import { isProviderDriverKind, @@ -408,6 +416,8 @@ interface ProviderInstanceCardProps { readonly onHiddenModelsChange: (next: ReadonlyArray) => void; readonly onFavoriteModelsChange: (next: ReadonlyArray) => void; readonly onModelOrderChange: (next: ReadonlyArray) => void; + readonly onRunUpdate?: (() => void) | undefined; + readonly isUpdating?: boolean | undefined; } /** @@ -450,6 +460,8 @@ export function ProviderInstanceCard({ onHiddenModelsChange, onFavoriteModelsChange, onModelOrderChange, + onRunUpdate, + isUpdating = false, }: ProviderInstanceCardProps) { const enabled = instance.enabled ?? true; // The server-reported status wins when present; otherwise fall back to @@ -660,6 +672,23 @@ export function ProviderInstanceCard({ > {versionAdvisory.detail} + {onRunUpdate ? ( + + ) : null} {updateCommand ? ( { await expect.element(page.getByText("Server password")).toBeInTheDocument(); await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument(); }); + + it("runs one-click provider updates from the provider card", async () => { + const updateProvider = vi.fn().mockResolvedValue({ + providers: [createOutdatedProvider("codex")], + }); + window.nativeApi = { + persistence: { + getClientSettings: vi.fn().mockResolvedValue(null), + setClientSettings: vi.fn().mockResolvedValue(undefined), + }, + server: { + updateProvider, + }, + } as unknown as LocalApi; + + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [createOutdatedProvider("codex")], + }); + + mounted = await render( + + + , + ); + + await expect + .element(page.getByRole("button", { name: "Update", exact: true })) + .toBeInTheDocument(); + await page.getByRole("button", { name: "Update", exact: true }).click(); + + expect(updateProvider).toHaveBeenCalledWith({ + provider: ProviderDriverKind.make("codex"), + }); + }); }); describe("SourceControlSettingsPanel discovery states", () => { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 7afbe9f00a..fffc791ece 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { defaultInstanceIdForDriver, type DesktopUpdateChannel, + PROVIDER_DISPLAY_NAMES, ProviderDriverKind, type ProviderInstanceConfig, type ProviderInstanceId, @@ -56,6 +57,13 @@ import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; +import { + canOneClickUpdateProviderCandidate, + collectProviderUpdateCandidates, + hasOneClickUpdateProviderCandidate, + isProviderUpdateActive, + type ProviderUpdateCandidate, +} from "../ProviderUpdateLaunchNotification.logic"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic"; @@ -464,6 +472,9 @@ export function GeneralSettingsPanel({ >({}); const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); + const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< + ReadonlySet + >(() => new Set()); // Collapsible state per provider-instance card, keyed by the instance id. // `Record` so we don't need to preseed an entry for every // configured instance — an absent key reads as collapsed. Default-slot @@ -504,6 +515,14 @@ export function GeneralSettingsPanel({ const availableEditors = useServerAvailableEditors(); const observability = useServerObservability(); const serverProviders = useServerProviders(); + const providerUpdateCandidates = useMemo( + () => collectProviderUpdateCandidates(serverProviders), + [serverProviders], + ); + const providerUpdateCandidateByInstanceId = useMemo( + () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])), + [providerUpdateCandidates], + ); const visibleProviderSettings = PROVIDER_SETTINGS.filter( (providerSettings) => providerSettings.provider !== "cursor" || @@ -548,6 +567,46 @@ export function GeneralSettingsPanel({ DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); + const runProviderUpdate = useCallback(async (candidate: ProviderUpdateCandidate) => { + let started = false; + setUpdatingProviderDrivers((previous) => { + if (previous.has(candidate.driver)) { + return previous; + } + started = true; + const next = new Set(previous); + next.add(candidate.driver); + return next; + }); + if (!started) { + return; + } + + try { + await ensureLocalApi().server.updateProvider({ provider: candidate.driver }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, + description: + error instanceof Error + ? error.message + : "The provider update command could not be started.", + }), + ); + } finally { + setUpdatingProviderDrivers((previous) => { + if (!previous.has(candidate.driver)) { + return previous; + } + const next = new Set(previous); + next.delete(candidate.driver); + return next; + }); + } + }, []); + const openInPreferredEditor = useCallback( (target: "keybindings" | "logsDirectory", path: string | null, failureMessage: string) => { if (!path) return; @@ -1236,6 +1295,23 @@ export function GeneralSettingsPanel({ const liveProvider = serverProviders.find( (candidate) => candidate.instanceId === row.instanceId, ); + const updateCandidate = liveProvider + ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) + : undefined; + const isDriverUpdateRunning = + updateCandidate !== undefined && + (updatingProviderDrivers.has(updateCandidate.driver) || + serverProviders.some( + (provider) => + provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), + )); + const showInlineUpdateButton = + updateCandidate !== undefined && + hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); + const canRunInlineUpdate = + updateCandidate !== undefined && + canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && + !updatingProviderDrivers.has(updateCandidate.driver); const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { hiddenModels: [], modelOrder: [], @@ -1305,6 +1381,17 @@ export function GeneralSettingsPanel({ modelOrder, }) } + onRunUpdate={ + showInlineUpdateButton && updateCandidate + ? () => { + if (!canRunInlineUpdate) { + return; + } + void runProviderUpdate(updateCandidate); + } + : undefined + } + isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} /> ); })} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 3ca3183a89..0ebf0c8a7f 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -41,6 +41,7 @@ export type ThreadToastData = { threadId?: ThreadId | null; leadingIcon?: ReactNode; tooltipStyle?: boolean; + onClose?: (() => void) | undefined; dismissAfterVisibleMs?: number; hideCopyButton?: boolean; secondaryActionProps?: ComponentPropsWithoutRef<"button">; @@ -101,6 +102,15 @@ const toastCornerOrbClass = cn( "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", ); +function handleToastDismissClick( + manager: typeof toastManager | typeof anchoredToastManager, + toastId: ToastId, + onClose: (() => void) | undefined, +) { + onClose?.(); + manager.close(toastId); +} + function CopyErrorButton({ text }: { text: string }) { const { copyToClipboard, isCopied } = useCopyToClipboard(); @@ -619,7 +629,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { aria-label="Dismiss notification" className={toastCornerOrbClass} data-slot="toast-close" - onClick={() => toastManager.close(toast.id)} + onClick={() => + handleToastDismissClick(toastManager, toast.id, toast.data?.onClose) + } type="button" > @@ -710,7 +722,13 @@ function AnchoredToasts() { aria-label="Dismiss notification" className={toastCornerOrbClass} data-slot="toast-close" - onClick={() => anchoredToastManager.close(toast.id)} + onClick={() => + handleToastDismissClick( + anchoredToastManager, + toast.id, + toast.data?.onClose, + ) + } type="button" > diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 37a6872bd9..6541bb7a82 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -26,6 +26,7 @@ import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/ const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; const clientSettingsListeners = new Set<() => void>(); +const clientSettingsHydrationListeners = new Set<() => void>(); let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; let clientSettingsHydrated = false; let clientSettingsHydrationPromise: Promise | null = null; @@ -36,6 +37,12 @@ function emitClientSettingsChange() { } } +function emitClientSettingsHydrationChange() { + for (const listener of clientSettingsHydrationListeners) { + listener(); + } +} + function getClientSettingsSnapshot(): ClientSettings { return clientSettingsSnapshot; } @@ -45,6 +52,14 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void { emitClientSettingsChange(); } +function setClientSettingsHydrated(nextHydrated: boolean): void { + if (clientSettingsHydrated === nextHydrated) { + return; + } + clientSettingsHydrated = nextHydrated; + emitClientSettingsHydrationChange(); +} + function subscribeClientSettings(listener: () => void): () => void { clientSettingsListeners.add(listener); void hydrateClientSettings(); @@ -53,6 +68,18 @@ function subscribeClientSettings(listener: () => void): () => void { }; } +function getClientSettingsHydratedSnapshot(): boolean { + return clientSettingsHydrated; +} + +function subscribeClientSettingsHydration(listener: () => void): () => void { + clientSettingsHydrationListeners.add(listener); + void hydrateClientSettings(); + return () => { + clientSettingsHydrationListeners.delete(listener); + }; +} + async function hydrateClientSettings(): Promise { if (clientSettingsHydrated) { return; @@ -70,7 +97,7 @@ async function hydrateClientSettings(): Promise { } catch (error) { console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); } finally { - clientSettingsHydrated = true; + setClientSettingsHydrated(true); } })(); @@ -132,6 +159,14 @@ export function getClientSettings(): ClientSettings { return getClientSettingsSnapshot(); } +export function useClientSettingsHydrated(): boolean { + return useSyncExternalStore( + subscribeClientSettingsHydration, + getClientSettingsHydratedSnapshot, + () => false, + ); +} + export function useSettings(selector?: (s: UnifiedSettings) => T): T { const serverSettings = useServerSettings(); const clientSettings = useSyncExternalStore( @@ -193,4 +228,5 @@ export function __resetClientSettingsPersistenceForTests(): void { clientSettingsHydrated = false; clientSettingsHydrationPromise = null; clientSettingsListeners.clear(); + clientSettingsHydrationListeners.clear(); } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 88c0c86a80..a4dd3b8256 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -602,6 +602,7 @@ describe("wsApi", () => { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, favorites: [], @@ -663,6 +664,7 @@ describe("wsApi", () => { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, favorites: [], diff --git a/apps/web/src/providerUpdateDismissal.ts b/apps/web/src/providerUpdateDismissal.ts new file mode 100644 index 0000000000..b478a5a861 --- /dev/null +++ b/apps/web/src/providerUpdateDismissal.ts @@ -0,0 +1,31 @@ +import { useCallback, useMemo } from "react"; + +import { useClientSettingsHydrated, useSettings, useUpdateSettings } from "./hooks/useSettings"; + +export function useDismissedProviderUpdateNotificationKeys() { + const dismissedKeys = useSettings((settings) => settings.dismissedProviderUpdateNotificationKeys); + const { updateSettings } = useUpdateSettings(); + const hydrated = useClientSettingsHydrated(); + + const dismissedKeySet = useMemo(() => new Set(dismissedKeys), [dismissedKeys]); + + const dismissNotificationKey = useCallback( + (key: string) => { + const trimmedKey = key.trim(); + if (trimmedKey.length === 0 || dismissedKeySet.has(trimmedKey)) { + return; + } + + updateSettings({ + dismissedProviderUpdateNotificationKeys: [...dismissedKeys, trimmedKey], + }); + }, + [dismissedKeySet, dismissedKeys, updateSettings], + ); + + return { + clientSettingsHydrated: hydrated, + dismissedNotificationKeys: dismissedKeySet, + dismissNotificationKey, + }; +} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 90b9099d17..a4805494df 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -32,6 +32,9 @@ export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Model favorites. Historically keyed by provider kind, now From 58eb8f17d01dda044f42a2b2a5bad1e78a1954d2 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Mon, 4 May 2026 12:41:03 +0100 Subject: [PATCH 18/30] Fix provider update edge cases --- .../src/provider/providerUpdater.test.ts | 46 ++++++ apps/server/src/provider/providerUpdater.ts | 156 +++++++++--------- ...iderUpdateLaunchNotification.logic.test.ts | 38 +++++ .../ProviderUpdateLaunchNotification.logic.ts | 3 + 4 files changed, 165 insertions(+), 78 deletions(-) diff --git a/apps/server/src/provider/providerUpdater.test.ts b/apps/server/src/provider/providerUpdater.test.ts index a36206ed0e..8c61e73c55 100644 --- a/apps/server/src/provider/providerUpdater.test.ts +++ b/apps/server/src/provider/providerUpdater.test.ts @@ -371,4 +371,50 @@ describe("providerUpdater", () => { } }), ); + + it.effect( + "releases the running-provider marker when interrupted after queuing but before the lock run starts", + () => + Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + let blockQueuedState = true; + const queuedStateWrittenLatch: { resolve: () => void } = { resolve: () => {} }; + const releaseQueuedStateLatch: { resolve: () => void } = { resolve: () => {} }; + const queuedStateWritten = new Promise((resolve) => { + queuedStateWrittenLatch.resolve = resolve; + }); + const releaseQueuedState = new Promise((resolve) => { + releaseQueuedStateLatch.resolve = resolve; + }); + + const updater = yield* makeProviderUpdater({ + providerRegistry: { + ...registry, + setProviderUpdateState: (provider, updateState) => + Effect.gen(function* () { + const providers = yield* registry.setProviderUpdateState(provider, updateState); + if (updateState?.status === "queued" && blockQueuedState) { + queuedStateWrittenLatch.resolve(); + yield* Effect.promise(() => releaseQueuedState); + } + return providers; + }), + }, + runUpdate: async () => okResult(), + }); + + const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); + yield* Effect.promise(() => queuedStateWritten); + blockQueuedState = false; + + yield* Fiber.interrupt(first); + releaseQueuedStateLatch.resolve(); + + const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit); + assert.strictEqual(Exit.isSuccess(second), true); + if (Exit.isSuccess(second)) { + assert.strictEqual(second.value.providers[0]?.updateState?.status, "succeeded"); + } + }), + ); }); diff --git a/apps/server/src/provider/providerUpdater.ts b/apps/server/src/provider/providerUpdater.ts index 715b4d0792..8704a226f2 100644 --- a/apps/server/src/provider/providerUpdater.ts +++ b/apps/server/src/provider/providerUpdater.ts @@ -203,100 +203,100 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i }); } - const lock = updateLocks.get(updateLockKey); - if (!lock) { - yield* releaseProvider(provider); - return yield* new ServerProviderUpdateError({ - provider, - reason: `Unsupported provider update lock key: ${updateLockKey}`, - }); - } - - yield* input.providerRegistry.setProviderUpdateState( - provider, - makeUpdateState({ - status: "queued", - startedAt: null, - finishedAt: null, - message: "Waiting for another provider update to finish.", - }), - ); - - const finish = (state: ServerProviderUpdateState) => - input.providerRegistry - .setProviderUpdateState(provider, state) - .pipe(Effect.map((providers) => ({ providers }))); - const startedAtRef = yield* Ref.make(null); + return yield* Effect.gen(function* () { + const lock = updateLocks.get(updateLockKey); + if (!lock) { + return yield* new ServerProviderUpdateError({ + provider, + reason: `Unsupported provider update lock key: ${updateLockKey}`, + }); + } - const run = Effect.gen(function* () { - const startedAt = new Date().toISOString(); - yield* Ref.set(startedAtRef, startedAt); yield* input.providerRegistry.setProviderUpdateState( provider, makeUpdateState({ - status: "running", - startedAt, + status: "queued", + startedAt: null, finishedAt: null, - message: "Updating provider.", + message: "Waiting for another provider update to finish.", }), ); - const result = yield* Effect.promise(() => - runUpdate(updateExecutable, lifecycle.updateArgs), - ); - const finishedAt = new Date().toISOString(); - if (result.timedOut || result.code !== 0) { + const finish = (state: ServerProviderUpdateState) => + input.providerRegistry + .setProviderUpdateState(provider, state) + .pipe(Effect.map((providers) => ({ providers }))); + const startedAtRef = yield* Ref.make(null); + + const run = Effect.gen(function* () { + const startedAt = new Date().toISOString(); + yield* Ref.set(startedAtRef, startedAt); + yield* input.providerRegistry.setProviderUpdateState( + provider, + makeUpdateState({ + status: "running", + startedAt, + finishedAt: null, + message: "Updating provider.", + }), + ); + + const result = yield* Effect.promise(() => + runUpdate(updateExecutable, lifecycle.updateArgs), + ); + const finishedAt = new Date().toISOString(); + if (result.timedOut || result.code !== 0) { + return yield* finish( + makeUpdateState({ + status: "failed", + startedAt, + finishedAt, + message: failureMessage(result), + output: commandOutput(result), + }), + ); + } + + const { verifiedProviders } = yield* verifyRefreshedProvider(provider, lifecycle); + const couldNotVerify = verifiedProviders.length === 0; + const stillOutdated = + couldNotVerify || + verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); return yield* finish( makeUpdateState({ - status: "failed", + status: stillOutdated ? "unchanged" : "succeeded", startedAt, finishedAt, - message: failureMessage(result), + message: couldNotVerify + ? "Update command completed, but T3 Code could not verify the provider version." + : stillOutdated + ? "Update command completed, but T3 Code still detects an outdated provider version." + : "Provider updated.", output: commandOutput(result), }), ); - } - - const { verifiedProviders } = yield* verifyRefreshedProvider(provider, lifecycle); - const couldNotVerify = verifiedProviders.length === 0; - const stillOutdated = - couldNotVerify || - verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); - return yield* finish( - makeUpdateState({ - status: stillOutdated ? "unchanged" : "succeeded", - startedAt, - finishedAt, - message: couldNotVerify - ? "Update command completed, but T3 Code could not verify the provider version." - : stillOutdated - ? "Update command completed, but T3 Code still detects an outdated provider version." - : "Provider updated.", - output: commandOutput(result), - }), - ); - }); + }); - return yield* lock - .withPermits(1)(run) - .pipe( - Effect.catchCause((cause) => - Effect.gen(function* () { - const failure = Cause.squash(cause); - const startedAt = yield* Ref.get(startedAtRef); - return yield* finish( - makeUpdateState({ - status: "failed", - startedAt, - finishedAt: new Date().toISOString(), - message: failure instanceof Error ? failure.message : "Update command failed.", - output: null, - }), - ); - }), - ), - Effect.ensuring(releaseProvider(provider)), - ); + return yield* lock + .withPermits(1)(run) + .pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const failure = Cause.squash(cause); + const startedAt = yield* Ref.get(startedAtRef); + return yield* finish( + makeUpdateState({ + status: "failed", + startedAt, + finishedAt: new Date().toISOString(), + message: failure instanceof Error ? failure.message : "Update command failed.", + output: null, + }), + ); + }), + ), + ); + }).pipe(Effect.ensuring(releaseProvider(provider))); }); return { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 588107f97d..8040a8da9f 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -124,6 +124,44 @@ describe("provider update launch notification logic", () => { ).toBe(false); }); + it("keeps one-click updates enabled when sibling instances are already current", () => { + const candidate = updateCandidate({ + driver: driver("claudeAgent"), + instanceId: instanceId("claude_personal"), + latestVersion: "2.1.123", + updateCommand: "npm install -g @anthropic-ai/claude-code@latest", + }); + + expect( + hasOneClickUpdateProviderCandidate(candidate, [ + candidate, + provider({ + driver: driver("claudeAgent"), + instanceId: instanceId("claude_work"), + version: "2.1.123", + latestVersion: "2.1.123", + advisoryStatus: "current", + canUpdate: false, + updateCommand: null, + }), + ]), + ).toBe(true); + expect( + canOneClickUpdateProviderCandidate(candidate, [ + candidate, + provider({ + driver: driver("claudeAgent"), + instanceId: instanceId("claude_work"), + version: "2.1.123", + latestVersion: "2.1.123", + advisoryStatus: "current", + canUpdate: false, + updateCommand: null, + }), + ]), + ).toBe(true); + }); + it("keeps the inline update action available while a provider update is already running", () => { const candidate = updateCandidate({ driver: driver("codex"), diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 7e3fbfa2e5..d021fdd189 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -136,6 +136,9 @@ export function hasOneClickUpdateProviderCandidate( const updateCommands = new Set(); for (const provider of driverProviders) { + if (!isProviderUpdateCandidate(provider)) { + continue; + } const advisory = provider.versionAdvisory; if (!advisory || advisory.canUpdate !== true || advisory.updateCommand === null) { return false; From 1589e67aa0c11b2ad88c16a5e08b72b8f4ce195d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 4 May 2026 15:02:46 -0700 Subject: [PATCH 19/30] Add provider update advisories and lifecycle detection - Resolve provider lifecycles through Effect-based path handling - Support npm, bun, pnpm, Homebrew, and native update commands - Refresh update advisories and launch UI messaging --- .../src/provider/Drivers/ClaudeDriver.ts | 10 +- .../src/provider/Drivers/CodexDriver.ts | 10 +- .../src/provider/Drivers/CursorDriver.ts | 4 +- .../src/provider/Drivers/OpenCodeDriver.ts | 10 +- .../src/provider/Layers/CursorProvider.ts | 4 +- .../src/provider/providerUpdater.test.ts | 65 ++-- apps/server/src/provider/providerUpdater.ts | 15 +- .../provider/providerVersionLifecycle.test.ts | 245 ++++++++++++- .../src/provider/providerVersionLifecycle.ts | 341 +++++++++++++++--- ...iderUpdateLaunchNotification.logic.test.ts | 29 +- .../ProviderUpdateLaunchNotification.logic.ts | 16 +- .../ProviderUpdateLaunchNotification.tsx | 4 +- .../settings/ProviderInstanceCard.tsx | 315 +++++++++------- apps/web/src/providerUpdateDismissal.test.ts | 23 ++ apps/web/src/providerUpdateDismissal.ts | 77 +++- 15 files changed, 895 insertions(+), 273 deletions(-) create mode 100644 apps/web/src/providerUpdateDismissal.test.ts diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 6e61e5f5e4..18d9c58b76 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -36,7 +36,7 @@ import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, - getProviderVersionLifecycle, + getProviderVersionLifecycleEffect, } from "../providerVersionLifecycle.ts"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; @@ -86,7 +86,7 @@ export const ClaudeDriver: ProviderDriver = { instanceId, }); const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; - const versionLifecycle = getProviderVersionLifecycle(DRIVER_KIND, { + const versionLifecycle = yield* getProviderVersionLifecycleEffect(DRIVER_KIND, { binaryPath: effectiveConfig.binaryPath, env: processEnv, }); @@ -136,9 +136,9 @@ export const ClaudeDriver: ProviderDriver = { initialSnapshot: (settings) => stampIdentity(makePendingClaudeProvider(settings)), checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => - Effect.promise(() => - enrichProviderSnapshotWithVersionAdvisory(snapshot, versionLifecycle), - ).pipe(Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot))), + enrichProviderSnapshotWithVersionAdvisory(snapshot, versionLifecycle).pipe( + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 804cd69532..cb29debb4d 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -37,7 +37,7 @@ import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, - getProviderVersionLifecycle, + getProviderVersionLifecycleEffect, } from "../providerVersionLifecycle.ts"; import { codexContinuationIdentity, @@ -119,7 +119,7 @@ export const CodexDriver: ProviderDriver = { enabled, homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; - const versionLifecycle = getProviderVersionLifecycle(DRIVER_KIND, { + const versionLifecycle = yield* getProviderVersionLifecycleEffect(DRIVER_KIND, { binaryPath: effectiveConfig.binaryPath, env: processEnv, }); @@ -153,9 +153,9 @@ export const CodexDriver: ProviderDriver = { initialSnapshot: (settings) => stampIdentity(makePendingCodexProvider(settings)), checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => - Effect.promise(() => - enrichProviderSnapshotWithVersionAdvisory(snapshot, versionLifecycle), - ).pipe(Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot))), + enrichProviderSnapshotWithVersionAdvisory(snapshot, versionLifecycle).pipe( + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 171ef41830..601ee658b5 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -34,7 +34,7 @@ import { } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; -import { getProviderVersionLifecycle } from "../providerVersionLifecycle.ts"; +import { getProviderVersionLifecycleEffect } from "../providerVersionLifecycle.ts"; const DRIVER_KIND = ProviderDriverKind.make("cursor"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); @@ -88,7 +88,7 @@ export const CursorDriver: ProviderDriver = { continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies CursorSettings; - const versionLifecycle = getProviderVersionLifecycle(DRIVER_KIND, { + const versionLifecycle = yield* getProviderVersionLifecycleEffect(DRIVER_KIND, { binaryPath: effectiveConfig.binaryPath, env: processEnv, }); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index f73bbc794c..e833634852 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -36,7 +36,7 @@ import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, - getProviderVersionLifecycle, + getProviderVersionLifecycleEffect, } from "../providerVersionLifecycle.ts"; const DRIVER_KIND = ProviderDriverKind.make("opencode"); @@ -91,7 +91,7 @@ export const OpenCodeDriver: ProviderDriver continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; - const versionLifecycle = getProviderVersionLifecycle(DRIVER_KIND, { + const versionLifecycle = yield* getProviderVersionLifecycleEffect(DRIVER_KIND, { binaryPath: effectiveConfig.binaryPath, env: processEnv, }); @@ -117,9 +117,9 @@ export const OpenCodeDriver: ProviderDriver initialSnapshot: (settings) => stampIdentity(makePendingOpenCodeProvider(settings)), checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => - Effect.promise(() => - enrichProviderSnapshotWithVersionAdvisory(snapshot, versionLifecycle), - ).pipe(Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot))), + enrichProviderSnapshotWithVersionAdvisory(snapshot, versionLifecycle).pipe( + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index f290477c6f..1bbe261999 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -1223,9 +1223,7 @@ export const enrichCursorSnapshot = (input: { const { settings, snapshot, publishSnapshot } = input; const stampIdentity = input.stampIdentity ?? ((value) => value); - const enrichVersionAdvisory = Effect.promise(() => - enrichProviderSnapshotWithVersionAdvisory(snapshot), - ).pipe( + const enrichVersionAdvisory = enrichProviderSnapshotWithVersionAdvisory(snapshot).pipe( Effect.flatMap((enrichedSnapshot) => publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)), ), diff --git a/apps/server/src/provider/providerUpdater.test.ts b/apps/server/src/provider/providerUpdater.test.ts index 8c61e73c55..672490173e 100644 --- a/apps/server/src/provider/providerUpdater.test.ts +++ b/apps/server/src/provider/providerUpdater.test.ts @@ -6,7 +6,8 @@ import { type ServerProviderUpdateState, } from "@t3tools/contracts"; import { ServerProviderUpdateError } from "@t3tools/contracts"; -import { Cause, Effect, Exit, Fiber, Ref, Schema, Stream } from "effect"; +import { Cause, Effect, Exit, Fiber, Layer, Ref, Schema, Stream } from "effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import type { ProcessRunResult } from "../processRunner.ts"; import type { ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; @@ -66,6 +67,19 @@ const failedResult = (stderr: string): ProcessRunResult => ({ stderrTruncated: false, }); +const latestVersionHttpClient = (version: string) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ version }, { headers: { "content-type": "application/json" } }), + ), + ), + ), + ); + function makeRegistry( initialProviders: ServerProvider | ReadonlyArray = baseProvider, ) { @@ -153,7 +167,7 @@ describe("providerUpdater", () => { status: "behind_latest", currentVersion: "2.0.14", latestVersion: "2.1.123", - updateCommand: "bun add -g @anthropic-ai/claude-code@latest", + updateCommand: "bun i -g @anthropic-ai/claude-code@latest", canUpdate: true, checkedAt: "2026-04-30T12:00:00.000Z", message: "Update available.", @@ -167,9 +181,9 @@ describe("providerUpdater", () => { Effect.succeed({ provider: CODEX_DRIVER, packageName: "@openai/codex", - updateCommand: "bun add -g @openai/codex@latest", + updateCommand: "bun i -g @openai/codex@latest", updateExecutable: "bun", - updateArgs: ["add", "-g", "@openai/codex@latest"], + updateArgs: ["i", "-g", "@openai/codex@latest"], updateLockKey: "bun-global", }), }, @@ -183,7 +197,7 @@ describe("providerUpdater", () => { assert.deepStrictEqual(calls, [ { command: "bun", - args: ["add", "-g", "@openai/codex@latest"], + args: ["i", "-g", "@openai/codex@latest"], }, ]); }), @@ -210,32 +224,21 @@ describe("providerUpdater", () => { "marks successful commands as unchanged when the refreshed provider is still outdated", () => Effect.gen(function* () { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => - new Response(JSON.stringify({ version: "9.9.9" }), { - headers: { "content-type": "application/json" }, - status: 200, - })) as unknown as typeof fetch; - - try { - const { registry } = yield* makeRegistry({ - ...baseProvider, - installed: true, - version: "0.1.0", - }); - const updater = yield* makeProviderUpdater({ - providerRegistry: registry, - runUpdate: async () => okResult(), - }); - - const result = yield* updater.updateProvider(CODEX_DRIVER); - - assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); - assert.include(result.providers[0]?.updateState?.message ?? "", "still detects"); - } finally { - globalThis.fetch = originalFetch; - } - }), + const { registry } = yield* makeRegistry({ + ...baseProvider, + installed: true, + version: "0.1.0", + }); + const updater = yield* makeProviderUpdater({ + providerRegistry: registry, + runUpdate: async () => okResult(), + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + + assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); + assert.include(result.providers[0]?.updateState?.message ?? "", "still detects"); + }).pipe(Effect.provide(latestVersionHttpClient("9.9.9"))), ); it.effect("prevents concurrent updates for the same provider", () => diff --git a/apps/server/src/provider/providerUpdater.ts b/apps/server/src/provider/providerUpdater.ts index 8704a226f2..e086955f79 100644 --- a/apps/server/src/provider/providerUpdater.ts +++ b/apps/server/src/provider/providerUpdater.ts @@ -16,7 +16,16 @@ import type { ProviderVersionLifecycle } from "./providerVersionLifecycle.ts"; const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; -const SHARED_UPDATE_LOCK_KEYS = ["npm-global", "bun-global", "cursor-agent"] as const; +const SHARED_UPDATE_LOCK_KEYS = [ + "npm-global", + "bun-global", + "pnpm-global", + "vite-plus-global", + "homebrew", + "claude-native", + "opencode-native", + "cursor-agent", +] as const; export type ProviderUpdateRunner = ( command: string, @@ -155,9 +164,7 @@ export const makeProviderUpdater = Effect.fn("makeProviderUpdater")(function* (i return Effect.forEach( refreshedProviders, (refreshedProvider) => - Effect.promise(() => - enrichProviderSnapshotWithVersionAdvisory(refreshedProvider, versionLifecycle), - ), + enrichProviderSnapshotWithVersionAdvisory(refreshedProvider, versionLifecycle), { concurrency: "unbounded", }, diff --git a/apps/server/src/provider/providerVersionLifecycle.test.ts b/apps/server/src/provider/providerVersionLifecycle.test.ts index 5fea0bc29e..770753d6f9 100644 --- a/apps/server/src/provider/providerVersionLifecycle.test.ts +++ b/apps/server/src/provider/providerVersionLifecycle.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from "vitest"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import os from "node:os"; import path from "node:path"; import { ProviderDriverKind } from "@t3tools/contracts"; +import { Effect } from "effect"; import { createProviderVersionAdvisory, getProviderVersionLifecycle, + getProviderVersionLifecycleEffect, } from "./providerVersionLifecycle.ts"; const driver = (value: string) => ProviderDriverKind.make(value); @@ -68,6 +71,32 @@ describe("providerVersionLifecycle", () => { }); }); + it("switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin", () => { + const tempDir = path.join(os.tmpdir(), `t3-vite-plus-lifecycle-${Date.now()}`); + const vitePlusBinDir = path.join(tempDir, ".vite-plus", "bin"); + mkdirSync(vitePlusBinDir, { recursive: true }); + const codexPath = path.join(vitePlusBinDir, "codex"); + writeFileSync(codexPath, "#!/bin/sh\n"); + chmodSync(codexPath, 0o755); + + expect( + getProviderVersionLifecycle(driver("codex"), { + binaryPath: "codex", + platform: "darwin", + env: { + PATH: vitePlusBinDir, + }, + }), + ).toEqual({ + provider: driver("codex"), + packageName: "@openai/codex", + updateCommand: "vp i -g @openai/codex", + updateExecutable: "vp", + updateArgs: ["i", "-g", "@openai/codex"], + updateLockKey: "vite-plus-global", + }); + }); + it("switches package-managed providers to bun updates when the resolved binary lives in bun's global bin", () => { const tempDir = path.join(os.tmpdir(), `t3-bun-lifecycle-${Date.now()}`); const bunBinDir = path.join(tempDir, ".bun", "bin"); @@ -86,13 +115,223 @@ describe("providerVersionLifecycle", () => { ).toEqual({ provider: driver("claudeAgent"), packageName: "@anthropic-ai/claude-code", - updateCommand: "bun add -g @anthropic-ai/claude-code@latest", + updateCommand: "bun i -g @anthropic-ai/claude-code@latest", updateExecutable: "bun", - updateArgs: ["add", "-g", "@anthropic-ai/claude-code@latest"], + updateArgs: ["i", "-g", "@anthropic-ai/claude-code@latest"], updateLockKey: "bun-global", }); }); + it("switches package-managed providers to pnpm updates when the resolved binary lives in pnpm's global bin", () => { + const tempDir = path.join(os.tmpdir(), `t3-pnpm-lifecycle-${Date.now()}`); + const pnpmHomeDir = path.join(tempDir, ".local", "share", "pnpm"); + mkdirSync(pnpmHomeDir, { recursive: true }); + const opencodePath = path.join(pnpmHomeDir, "opencode"); + writeFileSync(opencodePath, "#!/bin/sh\n"); + chmodSync(opencodePath, 0o755); + + expect( + getProviderVersionLifecycle(driver("opencode"), { + binaryPath: "opencode", + platform: "darwin", + env: { + PATH: pnpmHomeDir, + }, + }), + ).toEqual({ + provider: driver("opencode"), + packageName: "opencode-ai", + updateCommand: "pnpm add -g opencode-ai@latest", + updateExecutable: "pnpm", + updateArgs: ["add", "-g", "opencode-ai@latest"], + updateLockKey: "pnpm-global", + }); + }); + + it("switches codex to Homebrew updates when the binary resolves through Homebrew", () => { + expect( + getProviderVersionLifecycle(driver("codex"), { + binaryPath: "/opt/homebrew/bin/codex", + platform: "darwin", + env: { + PATH: "", + }, + }), + ).toEqual({ + provider: driver("codex"), + packageName: "@openai/codex", + updateCommand: "brew upgrade codex", + updateExecutable: "brew", + updateArgs: ["upgrade", "codex"], + updateLockKey: "homebrew", + }); + }); + + it("switches claude to native updates when the binary resolves through the native installer", () => { + const tempDir = path.join(os.tmpdir(), `t3-claude-native-lifecycle-${Date.now()}`); + const nativeBinDir = path.join(tempDir, ".local", "bin"); + mkdirSync(nativeBinDir, { recursive: true }); + const claudePath = path.join(nativeBinDir, "claude"); + writeFileSync(claudePath, "#!/bin/sh\n"); + chmodSync(claudePath, 0o755); + + expect( + getProviderVersionLifecycle(driver("claudeAgent"), { + binaryPath: "claude", + platform: "darwin", + env: { + PATH: nativeBinDir, + }, + }), + ).toEqual({ + provider: driver("claudeAgent"), + packageName: "@anthropic-ai/claude-code", + updateCommand: "claude update", + updateExecutable: "claude", + updateArgs: ["update"], + updateLockKey: "claude-native", + }); + }); + + it("switches opencode to native upgrades when the binary resolves through the standalone installer", () => { + const tempDir = path.join(os.tmpdir(), `t3-opencode-native-lifecycle-${Date.now()}`); + const nativeBinDir = path.join(tempDir, ".opencode", "bin"); + mkdirSync(nativeBinDir, { recursive: true }); + const opencodePath = path.join(nativeBinDir, "opencode"); + writeFileSync(opencodePath, "#!/bin/sh\n"); + chmodSync(opencodePath, 0o755); + + expect( + getProviderVersionLifecycle(driver("opencode"), { + binaryPath: "opencode", + platform: "darwin", + env: { + PATH: nativeBinDir, + }, + }), + ).toEqual({ + provider: driver("opencode"), + packageName: "opencode-ai", + updateCommand: "opencode upgrade", + updateExecutable: "opencode", + updateArgs: ["upgrade"], + updateLockKey: "opencode-native", + }); + }); + + it("switches claude to Homebrew updates when the binary resolves through Homebrew", () => { + expect( + getProviderVersionLifecycle(driver("claudeAgent"), { + binaryPath: "/opt/homebrew/bin/claude", + platform: "darwin", + env: { + PATH: "", + }, + }), + ).toEqual({ + provider: driver("claudeAgent"), + packageName: "@anthropic-ai/claude-code", + updateCommand: "brew upgrade claude-code", + updateExecutable: "brew", + updateArgs: ["upgrade", "claude-code"], + updateLockKey: "homebrew", + }); + }); + + it("switches opencode to Homebrew updates when the binary resolves through Homebrew", () => { + expect( + getProviderVersionLifecycle(driver("opencode"), { + binaryPath: "/opt/homebrew/bin/opencode", + platform: "darwin", + env: { + PATH: "", + }, + }), + ).toEqual({ + provider: driver("opencode"), + packageName: "opencode-ai", + updateCommand: "brew upgrade anomalyco/tap/opencode", + updateExecutable: "brew", + updateArgs: ["upgrade", "anomalyco/tap/opencode"], + updateLockKey: "homebrew", + }); + }); + + it("keeps npm updates for binaries symlinked into npm's global node_modules tree", async () => { + const tempDir = path.join(os.tmpdir(), `t3-npm-lifecycle-${Date.now()}`); + const binDir = path.join(tempDir, "bin"); + const packageBinDir = path.join(tempDir, "lib", "node_modules", "@openai", "codex", "bin"); + mkdirSync(binDir, { recursive: true }); + mkdirSync(packageBinDir, { recursive: true }); + const packageBinPath = path.join(packageBinDir, "codex.js"); + const symlinkPath = path.join(binDir, "codex"); + writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); + chmodSync(packageBinPath, 0o755); + symlinkSync(packageBinPath, symlinkPath); + + await expect( + Effect.runPromise( + getProviderVersionLifecycleEffect(driver("codex"), { + binaryPath: symlinkPath, + platform: "darwin", + env: { + PATH: "", + }, + }).pipe(Effect.provide(NodeServices.layer)), + ), + ).resolves.toEqual({ + provider: driver("codex"), + packageName: "@openai/codex", + updateCommand: "npm install -g @openai/codex@latest", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global", + }); + }); + + it("uses Effect FileSystem realPath when detecting pnpm global symlinks", async () => { + const tempDir = path.join(os.tmpdir(), `t3-pnpm-realpath-lifecycle-${Date.now()}`); + const binDir = path.join(tempDir, "bin"); + const packageBinDir = path.join( + tempDir, + ".local", + "share", + "pnpm", + "global", + "5", + "node_modules", + "@openai", + "codex", + "bin", + ); + mkdirSync(binDir, { recursive: true }); + mkdirSync(packageBinDir, { recursive: true }); + const packageBinPath = path.join(packageBinDir, "codex.js"); + const symlinkPath = path.join(binDir, "codex"); + writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); + chmodSync(packageBinPath, 0o755); + symlinkSync(packageBinPath, symlinkPath); + + await expect( + Effect.runPromise( + getProviderVersionLifecycleEffect(driver("codex"), { + binaryPath: symlinkPath, + platform: "darwin", + env: { + PATH: "", + }, + }).pipe(Effect.provide(NodeServices.layer)), + ), + ).resolves.toEqual({ + provider: driver("codex"), + packageName: "@openai/codex", + updateCommand: "pnpm add -g @openai/codex@latest", + updateExecutable: "pnpm", + updateArgs: ["add", "-g", "@openai/codex@latest"], + updateLockKey: "pnpm-global", + }); + }); + it("disables one-click updates for explicit custom binary paths it cannot safely map", () => { expect( getProviderVersionLifecycle(driver("codex"), { diff --git a/apps/server/src/provider/providerVersionLifecycle.ts b/apps/server/src/provider/providerVersionLifecycle.ts index 8ba5c9f23b..5de9d62955 100644 --- a/apps/server/src/provider/providerVersionLifecycle.ts +++ b/apps/server/src/provider/providerVersionLifecycle.ts @@ -4,6 +4,8 @@ import { type ServerProviderVersionAdvisory, } from "@t3tools/contracts"; import { resolveCommandPath } from "@t3tools/shared/shell"; +import { Effect, FileSystem, Option, Schema } from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { compareCliVersions } from "./cliVersion.ts"; @@ -31,21 +33,38 @@ interface ProviderVersionLifecycleResolutionOptions { readonly binaryPath?: string | null; readonly env?: NodeJS.ProcessEnv; readonly platform?: NodeJS.Platform; + readonly realCommandPath?: string | null; } interface PackageManagedProviderVersionLifecycleDefinition { readonly provider: ProviderDriverKind; - readonly packageName: string; + readonly npmPackageName: string; + readonly homebrewFormula: string | null; + readonly nativeUpdate: { + readonly executable: string; + readonly args: ReadonlyArray; + readonly lockKey: string; + readonly isCommandPath: (commandPath: string) => boolean; + } | null; } const PROVIDER_VERSION_LIFECYCLES = { codex: { provider: CODEX_DRIVER, - packageName: "@openai/codex", + npmPackageName: "@openai/codex", + homebrewFormula: "codex", + nativeUpdate: null, }, claudeAgent: { provider: CLAUDE_AGENT_DRIVER, - packageName: "@anthropic-ai/claude-code", + npmPackageName: "@anthropic-ai/claude-code", + homebrewFormula: "claude-code", + nativeUpdate: { + executable: "claude", + args: ["update"], + lockKey: "claude-native", + isCommandPath: isClaudeNativeCommandPath, + }, }, cursor: { provider: CURSOR_DRIVER, @@ -57,7 +76,14 @@ const PROVIDER_VERSION_LIFECYCLES = { }, opencode: { provider: OPENCODE_DRIVER, - packageName: "opencode-ai", + npmPackageName: "opencode-ai", + homebrewFormula: "anomalyco/tap/opencode", + nativeUpdate: { + executable: "opencode", + args: ["upgrade"], + lockKey: "opencode-native", + isCommandPath: isOpenCodeNativeCommandPath, + }, }, } as const satisfies Record< Exclude, @@ -72,6 +98,9 @@ interface LatestVersionCacheEntry { } const latestVersionCache = new Map(); +const NpmLatestVersionResponse = Schema.Struct({ + version: Schema.optional(Schema.String), +}); function nonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; @@ -119,9 +148,9 @@ function makeNpmGlobalProviderVersionLifecycle( ): ProviderVersionLifecycle { return makeProviderVersionLifecycle({ provider: definition.provider, - packageName: definition.packageName, + packageName: definition.npmPackageName, updateExecutable: "npm", - updateArgs: ["install", "-g", `${definition.packageName}@latest`], + updateArgs: ["install", "-g", `${definition.npmPackageName}@latest`], updateLockKey: "npm-global", }); } @@ -131,19 +160,137 @@ function makeBunGlobalProviderVersionLifecycle( ): ProviderVersionLifecycle { return makeProviderVersionLifecycle({ provider: definition.provider, - packageName: definition.packageName, + packageName: definition.npmPackageName, updateExecutable: "bun", - updateArgs: ["add", "-g", `${definition.packageName}@latest`], + updateArgs: ["i", "-g", `${definition.npmPackageName}@latest`], updateLockKey: "bun-global", }); } +function makePnpmGlobalProviderVersionLifecycle( + definition: PackageManagedProviderVersionLifecycleDefinition, +): ProviderVersionLifecycle { + return makeProviderVersionLifecycle({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: "pnpm", + updateArgs: ["add", "-g", `${definition.npmPackageName}@latest`], + updateLockKey: "pnpm-global", + }); +} + +function makeVitePlusGlobalProviderVersionLifecycle( + definition: PackageManagedProviderVersionLifecycleDefinition, +): ProviderVersionLifecycle { + return makeProviderVersionLifecycle({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: "vp", + updateArgs: ["i", "-g", definition.npmPackageName], + updateLockKey: "vite-plus-global", + }); +} + +function makeHomebrewProviderVersionLifecycle( + definition: PackageManagedProviderVersionLifecycleDefinition, +): ProviderVersionLifecycle { + if (!definition.homebrewFormula) { + return makeManualOnlyProviderVersionLifecycle({ + provider: definition.provider, + packageName: definition.npmPackageName, + }); + } + + return makeProviderVersionLifecycle({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: "brew", + updateArgs: ["upgrade", definition.homebrewFormula], + updateLockKey: "homebrew", + }); +} + +function makeNativeProviderVersionLifecycle( + definition: PackageManagedProviderVersionLifecycleDefinition, +): ProviderVersionLifecycle | null { + if (!definition.nativeUpdate) { + return null; + } + + return makeProviderVersionLifecycle({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: definition.nativeUpdate.executable, + updateArgs: definition.nativeUpdate.args, + updateLockKey: definition.nativeUpdate.lockKey, + }); +} + function hasPathSeparator(value: string): boolean { return value.includes("/") || value.includes("\\"); } +function normalizeCommandPath(commandPath: string): string { + return commandPath.replaceAll("\\", "/").toLowerCase(); +} + function isBunGlobalCommandPath(commandPath: string): boolean { - return commandPath.replaceAll("\\", "/").toLowerCase().includes("/.bun/bin/"); + return normalizeCommandPath(commandPath).includes("/.bun/bin/"); +} + +function isVitePlusGlobalCommandPath(commandPath: string): boolean { + return normalizeCommandPath(commandPath).includes("/.vite-plus/bin/"); +} + +function isPnpmGlobalCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.includes("/.local/share/pnpm/") || + normalized.includes("/library/pnpm/") || + normalized.includes("/local/share/pnpm/") || + normalized.includes("/appdata/local/pnpm/") || + normalized.includes("/pnpm/global/") + ); +} + +function isNpmGlobalCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.includes("/node_modules/.bin/") || + normalized.includes("/lib/node_modules/") || + normalized.includes("/npm/node_modules/") + ); +} + +function isHomebrewCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.includes("/opt/homebrew/cellar/") || + normalized.includes("/usr/local/cellar/") || + normalized.includes("/homebrew/cellar/") || + normalized.includes("/opt/homebrew/caskroom/") || + normalized.includes("/usr/local/caskroom/") || + normalized.includes("/homebrew/caskroom/") || + normalized.startsWith("/opt/homebrew/bin/") || + normalized.startsWith("/usr/local/bin/") + ); +} + +function isClaudeNativeCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.endsWith("/.local/bin/claude") || + normalized.endsWith("/.local/bin/claude.exe") || + normalized.includes("/.local/share/claude/") + ); +} + +function isOpenCodeNativeCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.endsWith("/.opencode/bin/opencode") || + normalized.endsWith("/.opencode/bin/opencode.exe") + ); } function resolvePackageManagedProviderVersionLifecycle( @@ -160,15 +307,48 @@ function resolvePackageManagedProviderVersionLifecycle( ...(options?.platform ? { platform: options.platform } : {}), ...(options?.env ? { env: options.env } : {}), }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); - if (resolvedCommandPath && isBunGlobalCommandPath(resolvedCommandPath)) { - return makeBunGlobalProviderVersionLifecycle(definition); + + if (resolvedCommandPath) { + const commandPaths = [ + resolvedCommandPath, + ...(options?.realCommandPath ? [options.realCommandPath] : []), + ]; + + const nativeUpdate = definition.nativeUpdate; + if ( + nativeUpdate && + commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath)) + ) { + return ( + makeNativeProviderVersionLifecycle(definition) ?? + makeNpmGlobalProviderVersionLifecycle(definition) + ); + } + if (commandPaths.some(isVitePlusGlobalCommandPath)) { + return makeVitePlusGlobalProviderVersionLifecycle(definition); + } + if (commandPaths.some(isBunGlobalCommandPath)) { + return makeBunGlobalProviderVersionLifecycle(definition); + } + if (commandPaths.some(isPnpmGlobalCommandPath)) { + return makePnpmGlobalProviderVersionLifecycle(definition); + } + if (commandPaths.some(isNpmGlobalCommandPath)) { + return makeNpmGlobalProviderVersionLifecycle(definition); + } + if (commandPaths.some(isHomebrewCommandPath)) { + return makeHomebrewProviderVersionLifecycle(definition); + } } if (!hasPathSeparator(binaryPath)) { return makeNpmGlobalProviderVersionLifecycle(definition); } - return makeManualOnlyProviderVersionLifecycle(definition); + return makeManualOnlyProviderVersionLifecycle({ + provider: definition.provider, + packageName: definition.npmPackageName, + }); } export function haveProviderVersionLifecyclesEqual( @@ -215,6 +395,36 @@ export function getProviderVersionLifecycle( }); } +export function getProviderVersionLifecycleEffect( + provider: ProviderDriverKind, + options?: Omit, +): Effect.Effect { + const binaryPath = nonEmptyString(options?.binaryPath); + if (!binaryPath) { + return Effect.succeed(getProviderVersionLifecycle(provider, options)); + } + + const resolvedCommandPath = + resolveCommandPath(binaryPath, { + ...(options?.platform ? { platform: options.platform } : {}), + ...(options?.env ? { env: options.env } : {}), + }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); + if (!resolvedCommandPath) { + return Effect.succeed(getProviderVersionLifecycle(provider, options)); + } + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const realCommandPath = yield* fileSystem + .realPath(resolvedCommandPath) + .pipe(Effect.catch(() => Effect.succeed(resolvedCommandPath))); + return getProviderVersionLifecycle(provider, { + ...options, + realCommandPath, + }); + }); +} + function deriveVersionAdvisory(input: { readonly currentVersion: string | null; readonly latestVersion: string | null; @@ -259,77 +469,90 @@ export function createProviderVersionAdvisory(input: { }; } -async function fetchNpmLatestVersion(packageName: string): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), LATEST_VERSION_TIMEOUT_MS); - try { - const response = await fetch( +function fetchNpmLatestVersion(packageName: string): Effect.Effect { + return Effect.gen(function* () { + const clientOption = yield* Effect.serviceOption(HttpClient.HttpClient); + if (Option.isNone(clientOption)) { + return null; + } + const client = clientOption.value; + const request = HttpClientRequest.get( `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, - { - signal: controller.signal, - headers: { accept: "application/json" }, - }, + ).pipe(HttpClientRequest.setHeader("accept", "application/json")); + const response = yield* client.execute(request).pipe( + Effect.timeoutOption(LATEST_VERSION_TIMEOUT_MS), + Effect.catch(() => Effect.succeed(Option.none())), ); - if (!response.ok) { + if (Option.isNone(response)) { return null; } - const payload = (await response.json()) as { version?: unknown }; - return nonEmptyString(payload.version); - } catch { - return null; - } finally { - clearTimeout(timeout); - } + const httpResponse = response.value; + if (httpResponse.status < 200 || httpResponse.status >= 300) { + return null; + } + const payload = yield* httpResponse.json.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(NpmLatestVersionResponse)), + Effect.catch(() => Effect.succeed(null)), + ); + return payload ? nonEmptyString(payload.version) : null; + }); } -export async function resolveLatestProviderVersion( +export function resolveLatestProviderVersion( provider: ProviderDriverKind, -): Promise { +): Effect.Effect { const lifecycle = getProviderVersionLifecycle(provider); - if (!lifecycle.packageName) { - return null; + const packageName = lifecycle.packageName; + if (!packageName) { + return Effect.succeed(null); } - const cached = latestVersionCache.get(lifecycle.packageName); + const cached = latestVersionCache.get(packageName); const now = Date.now(); if (cached && cached.expiresAt > now) { - return cached.version; + return Effect.succeed(cached.version); } - const version = await fetchNpmLatestVersion(lifecycle.packageName); - latestVersionCache.set(lifecycle.packageName, { - expiresAt: now + LATEST_VERSION_CACHE_TTL_MS, - version, - }); - return version; + return fetchNpmLatestVersion(packageName).pipe( + Effect.tap((version) => + Effect.sync(() => { + latestVersionCache.set(packageName, { + expiresAt: now + LATEST_VERSION_CACHE_TTL_MS, + version, + }); + }), + ), + ); } -export async function enrichProviderSnapshotWithVersionAdvisory( +export function enrichProviderSnapshotWithVersionAdvisory( snapshot: ServerProvider, versionLifecycle?: ProviderVersionLifecycle, -): Promise { - const lifecycle = versionLifecycle ?? getProviderVersionLifecycle(snapshot.driver); - if (!snapshot.enabled || !snapshot.installed || !snapshot.version) { +): Effect.Effect { + return Effect.gen(function* () { + const lifecycle = versionLifecycle ?? getProviderVersionLifecycle(snapshot.driver); + if (!snapshot.enabled || !snapshot.installed || !snapshot.version) { + return { + ...snapshot, + versionAdvisory: createProviderVersionAdvisory({ + driver: snapshot.driver, + currentVersion: snapshot.version, + checkedAt: snapshot.checkedAt, + versionLifecycle: lifecycle, + }), + }; + } + + const latestVersion = yield* resolveLatestProviderVersion(snapshot.driver); return { ...snapshot, versionAdvisory: createProviderVersionAdvisory({ driver: snapshot.driver, currentVersion: snapshot.version, - checkedAt: snapshot.checkedAt, + latestVersion, + checkedAt: new Date().toISOString(), versionLifecycle: lifecycle, }), }; - } - - const latestVersion = await resolveLatestProviderVersion(snapshot.driver); - return { - ...snapshot, - versionAdvisory: createProviderVersionAdvisory({ - driver: snapshot.driver, - currentVersion: snapshot.version, - latestVersion, - checkedAt: new Date().toISOString(), - versionLifecycle: lifecycle, - }), - }; + }); } diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 8040a8da9f..ff8d249d21 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -178,7 +178,7 @@ describe("provider update launch notification logic", () => { expect(canOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(false); }); - it("builds a notification key from the update advisory fields", () => { + it("builds a notification key from provider latest versions", () => { const codex = updateCandidate({ driver: driver("codex"), version: "1.0.0", @@ -190,12 +190,33 @@ describe("provider update launch notification logic", () => { latestVersion: "0.3.0", }); - expect(providerUpdateNotificationKey([codex, cursor])).toBe( - "codex:behind_latest:1.0.0:1.1.0:Update available.|cursor:behind_latest:0.2.0:0.3.0:Update available.", - ); + expect(providerUpdateNotificationKey([codex, cursor])).toBe("codex:1.1.0|cursor:0.3.0"); expect(providerUpdateNotificationKey([])).toBeNull(); }); + it("keeps the same notification key while the published update version is unchanged", () => { + const first = updateCandidate({ + driver: driver("codex"), + version: "1.0.0", + latestVersion: "1.2.0", + }); + const second = updateCandidate({ + driver: driver("codex"), + version: "1.1.0", + latestVersion: "1.2.0", + }); + const nextPublishedVersion = updateCandidate({ + driver: driver("codex"), + version: "1.1.0", + latestVersion: "1.3.0", + }); + + expect(providerUpdateNotificationKey([first])).toBe(providerUpdateNotificationKey([second])); + expect(providerUpdateNotificationKey([nextPublishedVersion])).not.toBe( + providerUpdateNotificationKey([first]), + ); + }); + it("describes a single one-click update", () => { const view = getProviderUpdateInitialToastView({ updateProviders: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index d021fdd189..5a5a6822b6 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -161,16 +161,12 @@ export function canOneClickUpdateProviderCandidate( export function providerUpdateNotificationKey( providers: ReadonlyArray, ): string | null { - const parts = dedupeProvidersByDriver(providers).map((provider) => { - const advisory = provider.versionAdvisory; - return [ - provider.driver, - advisory.status, - advisory.currentVersion, - advisory.latestVersion, - advisory.message, - ].join(":"); - }); + const parts = dedupeProvidersByDriver(providers) + .map((provider) => { + const advisory = provider.versionAdvisory; + return [provider.driver, advisory.latestVersion].join(":"); + }) + .toSorted(); return parts.length > 0 ? parts.join("|") : null; } diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx index 4b0578c23a..534d0d0a0f 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx @@ -103,7 +103,7 @@ export function ProviderUpdateLaunchNotification() { const navigate = useNavigate(); const providers = useServerProviders(); const activeToastRef = useRef(null); - const { clientSettingsHydrated, dismissedNotificationKeys, dismissNotificationKey } = + const { dismissedNotificationKeys, dismissNotificationKey } = useDismissedProviderUpdateNotificationKeys(); const updateProviders = useMemo(() => collectProviderUpdateCandidates(providers), [providers]); @@ -165,7 +165,6 @@ export function ProviderUpdateLaunchNotification() { } if ( - !clientSettingsHydrated || !notificationKey || dismissedNotificationKeys.has(notificationKey) || seenProviderUpdateNotificationKeys.has(notificationKey) || @@ -286,7 +285,6 @@ export function ProviderUpdateLaunchNotification() { ); activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; }, [ - clientSettingsHydrated, dismissNotificationKey, dismissedNotificationKeys, notificationKey, diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index c508c97cb3..25ec33c855 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1,6 +1,7 @@ "use client"; import { + ArrowUpCircleIcon, ChevronDownIcon, CopyIcon, DownloadIcon, @@ -27,6 +28,7 @@ import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { DraftInput } from "../ui/draft-input"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -570,150 +572,201 @@ export function ProviderInstanceCard({ ); }; + const titleIconNode = driverKind ? ( + + ) : FallbackIconComponent ? ( + + + + + ) : ( + + ); + + const titleHeadNode = ( + <> + {titleIconNode} +

{displayName}

+ {String(instanceId) !== String(instance.driver) ? ( + + {instanceId} + + ) : null} + {driverOption?.badgeLabel ? ( + + {driverOption.badgeLabel} + + ) : null} + + ); + + const titleTailNode = ( + <> + {headerAction ? ( + + {headerAction} + + ) : null} + {onDelete ? ( + + + + + + } + /> + Delete instance + + + ) : null} + + ); + + const authRowNode = ( +

+ {hasAuthenticatedEmail ? ( + <> + Authenticated as + + {authenticatedDetail ? · {authenticatedDetail} : null} + + ) : ( + <> + {summary.headline} + + + )} + {summary.detail ? - {summary.detail} : null} +

+ ); + + const versionCodeNode = versionLabel ? ( + {versionLabel} + ) : null; + return (
- {driverKind ? ( - - ) : FallbackIconComponent ? ( - - - + + + + } /> - - ) : ( - - )} -

{displayName}

- {String(instanceId) !== String(instance.driver) ? ( - // Hide the id chip on a default slot whose id === the - // driver slug — it's redundant with the driver icon + - // label. Custom instances (and any instance the user has - // since renamed) keep the chip so their slug stays - // visible for copy/paste + disambiguation. - - {instanceId} - - ) : null} - {driverOption?.badgeLabel ? ( - - {driverOption.badgeLabel} - - ) : null} - {versionLabel ? ( - {versionLabel} - ) : null} - {headerAction ? ( - - {headerAction} - - ) : null} - {onDelete ? ( - - - +
+
+

+ Update available +

+

- - - } - /> - Delete instance - - - ) : null} -

-

- {hasAuthenticatedEmail ? ( - <> - Authenticated as - - {authenticatedDetail ? · {authenticatedDetail} : null} - - ) : ( - <> - {summary.headline} - - - )} - {summary.detail ? - {summary.detail} : null} -

- {versionAdvisory ? ( -
- - {versionAdvisory.detail} - - {onRunUpdate ? ( - - ) : null} - {updateCommand ? ( - - +
+ {onRunUpdate ? ( - } - /> - {updateCommand} - - ) : null} -
- ) : null} + ) : null} + {onRunUpdate && updateCommand ? ( +
+ + or, update manually using + +
+ ) : null} + {updateCommand ? ( +
+ + {updateCommand} + + + + copyToClipboard(updateCommand, { + providerName: displayName, + }) + } + aria-label="Copy update command" + > + + + } + /> + Copy command + +
+ ) : null} +
+ + + ) : null} + {titleTailNode} +
+ {authRowNode}