diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 5bfd3e14cfd7..0b5bb66ea058 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -40,6 +40,7 @@ interface TestSettings { const maintenanceCapabilities = { provider: ProviderDriverKind.make("codex"), packageName: "@openai/codex", + homebrewFormula: null, update: { command: "npm install -g @openai/codex@latest", diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 5683da2c1a82..02e3719c96a8 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -8,7 +8,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3 import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; -import { HttpClient } from "effect/unstable/http"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { createProviderVersionAdvisory, enrichProviderSnapshotWithVersionAdvisory, @@ -82,6 +82,21 @@ const installedPackageToolProvider: ServerProvider = { skills: [], }; +// Serves the given JSON bodies by URL, 404s everything else, and records every URL hit. +const jsonHttpClient = (requestedUrls: string[], responses: Record) => + HttpClient.make((request) => { + requestedUrls.push(request.url); + const body = responses[request.url]; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + body === undefined + ? new Response(null, { status: 404 }) + : Response.json(body, { headers: { "content-type": "application/json" } }), + ), + ); + }); + it.layer(NodeServices.layer)("providerMaintenance", (it) => { it.effect("reads cached versions through the injectable cache reference", () => resolveLatestProviderVersion(packageToolUpdate.resolve()).pipe( @@ -109,6 +124,87 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ), ); + it.effect("asks Homebrew for the latest version of Homebrew-managed installs", () => + Effect.gen(function* () { + const requestedUrls: string[] = []; + const version = yield* resolveLatestProviderVersion( + packageToolUpdate.resolve({ + binaryPath: "/opt/homebrew/bin/package-tool", + env: { PATH: "" }, + }), + ).pipe( + Effect.provideService(ProviderVersionCache, new Map()), + Effect.provideService( + HttpClient.HttpClient, + jsonHttpClient(requestedUrls, { + "https://formulae.brew.sh/api/cask/package-tool.json": { + token: "package-tool", + version: "2.0.0,4567", + }, + }), + ), + ); + + expect(version).toBe("2.0.0"); + expect(requestedUrls).toEqual(["https://formulae.brew.sh/api/cask/package-tool.json"]); + }), + ); + + it.effect("falls back to the Homebrew formula when the name is not a cask", () => + Effect.gen(function* () { + const requestedUrls: string[] = []; + const version = yield* resolveLatestProviderVersion( + packageToolUpdate.resolve({ + binaryPath: "/opt/homebrew/bin/package-tool", + env: { PATH: "" }, + }), + ).pipe( + Effect.provideService(ProviderVersionCache, new Map()), + Effect.provideService( + HttpClient.HttpClient, + jsonHttpClient(requestedUrls, { + "https://formulae.brew.sh/api/formula/package-tool.json": { + versions: { stable: "3.0.0" }, + }, + }), + ), + ); + + expect(version).toBe("3.0.0"); + expect(requestedUrls).toEqual([ + "https://formulae.brew.sh/api/cask/package-tool.json", + "https://formulae.brew.sh/api/formula/package-tool.json", + ]); + }), + ); + + it.effect("keeps npm as the latest-version source for third-party Homebrew taps", () => + Effect.gen(function* () { + const requestedUrls: string[] = []; + const version = yield* resolveLatestProviderVersion( + scopedPackageToolUpdate.resolve({ + binaryPath: "/opt/homebrew/bin/scoped-package-tool", + env: { PATH: "" }, + }), + ).pipe( + Effect.provideService(ProviderVersionCache, new Map()), + Effect.provideService( + HttpClient.HttpClient, + jsonHttpClient(requestedUrls, { + "https://registry.npmjs.org/%40example%2Fscoped-package-tool/latest": { + version: "4.0.0", + }, + }), + ), + ); + + expect(version).toBe("4.0.0"); + expect(requestedUrls).toEqual([ + "https://registry.npmjs.org/%40example%2Fscoped-package-tool/latest", + ]); + }), + ); + it.effect("does not fetch latest provider versions when update checks are disabled", () => enrichProviderSnapshotWithVersionAdvisory( installedPackageToolProvider, @@ -187,6 +283,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect(staticToolUpdate.resolve()).toEqual({ provider: driver("staticTool"), packageName: null, + homebrewFormula: null, update: { command: "static-tool update", @@ -223,6 +320,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect(capabilities).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", + homebrewFormula: null, update: { command: "vp i -g @example/package-tool", @@ -259,6 +357,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect(capabilities).toEqual({ provider: driver("nativePackageTool"), packageName: "@example/native-package-tool", + homebrewFormula: null, update: { command: "bun i -g @example/native-package-tool@latest", @@ -296,6 +395,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect(capabilities).toEqual({ provider: driver("scopedPackageTool"), packageName: "@example/scoped-package-tool", + homebrewFormula: null, update: { command: "pnpm add -g @example/scoped-package-tool@latest", @@ -320,6 +420,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", + homebrewFormula: "package-tool", update: { command: "brew upgrade package-tool", @@ -356,6 +457,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect(capabilities).toEqual({ provider: driver("nativePackageTool"), packageName: "@example/native-package-tool", + homebrewFormula: null, update: { command: "native-package-tool update", @@ -393,6 +495,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect(capabilities).toEqual({ provider: driver("scopedPackageTool"), packageName: "@example/scoped-package-tool", + homebrewFormula: null, update: { command: "scoped-package-tool upgrade", @@ -417,6 +520,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ).toEqual({ provider: driver("nativePackageTool"), packageName: "@example/native-package-tool", + homebrewFormula: "native-package-tool", update: { command: "brew upgrade native-package-tool", @@ -440,6 +544,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ).toEqual({ provider: driver("scopedPackageTool"), packageName: "@example/scoped-package-tool", + homebrewFormula: "example/tap/scoped-package-tool", update: { command: "brew upgrade example/tap/scoped-package-tool", @@ -482,6 +587,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect(capabilities).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", + homebrewFormula: null, update: { command: "npm install -g --allow-scripts=@example/package-tool @example/package-tool@latest", @@ -535,6 +641,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect(capabilities).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", + homebrewFormula: null, update: { command: "pnpm add -g @example/package-tool@latest", @@ -564,6 +671,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect(claudeUpdate.resolve()).toEqual({ provider: driver("claudeAgent"), packageName: "@anthropic-ai/claude-code", + homebrewFormula: null, update: { command: "npm install -g --allow-scripts=@anthropic-ai/claude-code @anthropic-ai/claude-code@latest", @@ -594,6 +702,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", + homebrewFormula: null, update: null, }); }); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 14d17cf365c3..61ef26360c47 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -40,6 +40,12 @@ const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() export interface ProviderMaintenanceCapabilities { readonly provider: ProviderDriverKind; readonly packageName: string | null; + /** + * Cask or formula name when the install is Homebrew-managed. `brew upgrade` + * can only reach what Homebrew has published, so latest-version checks for + * these installs ask Homebrew instead of npm. + */ + readonly homebrewFormula: string | null; readonly update: ProviderMaintenanceCommandAction | null; } @@ -89,6 +95,12 @@ export const ProviderVersionCache = Context.Reference 0 ? value.trim() : null; @@ -97,6 +109,7 @@ function nonEmptyString(value: unknown): string | null { export function makeProviderMaintenanceCapabilities(input: { readonly provider: ProviderDriverKind; readonly packageName: string | null; + readonly homebrewFormula?: string | null; readonly updateExecutable: string | null; readonly updateArgs: ReadonlyArray; readonly updateLockKey: string | null; @@ -113,6 +126,7 @@ export function makeProviderMaintenanceCapabilities(input: { return { provider: input.provider, packageName: input.packageName, + homebrewFormula: input.homebrewFormula ?? null, update, }; } @@ -201,6 +215,7 @@ function makeHomebrewProviderMaintenanceCapabilities( return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName: definition.npmPackageName, + homebrewFormula: definition.homebrewFormula, updateExecutable: "brew", updateArgs: ["upgrade", definition.homebrewFormula], updateLockKey: "homebrew", @@ -430,11 +445,14 @@ export function createProviderVersionAdvisory(input: { }; } -const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (packageName: string) { +const fetchJson = Effect.fn("fetchJson")(function* ( + url: string, + schema: Schema.Codec, +) { const client = yield* HttpClient.HttpClient; - const request = HttpClientRequest.get( - `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, - ).pipe(HttpClientRequest.setHeader("accept", "application/json")); + const request = HttpClientRequest.get(url).pipe( + HttpClientRequest.setHeader("accept", "application/json"), + ); const response = yield* client.execute(request).pipe( Effect.timeoutOption(LATEST_VERSION_TIMEOUT_MS), Effect.orElseSucceed(() => Option.none()), @@ -446,30 +464,82 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack if (httpResponse.status < 200 || httpResponse.status >= 300) { return null; } - const payload = yield* httpResponse.json.pipe( - Effect.flatMap(Schema.decodeUnknownEffect(NpmLatestVersionResponse)), + return yield* httpResponse.json.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(schema)), Effect.orElseSucceed(() => null), ); +}); + +const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (packageName: string) { + const payload = yield* fetchJson( + `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, + NpmLatestVersionResponse, + ); return payload ? nonEmptyString(payload.version) : null; }); +// formulae.brew.sh is the same JSON API `brew` itself installs from, so its +// version is exactly what `brew upgrade` can deliver. Casks are tried first +// because every Homebrew-packaged provider today ships as a cask. +const fetchHomebrewLatestVersion = Effect.fn("fetchHomebrewLatestVersion")(function* ( + name: string, +) { + const encoded = encodeURIComponent(name); + const cask = yield* fetchJson( + `https://formulae.brew.sh/api/cask/${encoded}.json`, + HomebrewCaskResponse, + ); + // Cask versions may carry a build suffix ("1.2.3,4567") that semver cannot read. + const caskVersion = cask ? nonEmptyString(cask.version?.split(",")[0]) : null; + if (caskVersion) { + return caskVersion; + } + const formula = yield* fetchJson( + `https://formulae.brew.sh/api/formula/${encoded}.json`, + HomebrewFormulaResponse, + ); + return formula ? nonEmptyString(formula.versions?.stable) : null; +}); + +type ProviderLatestVersionSource = + | { readonly kind: "npm"; readonly packageName: string } + | { readonly kind: "homebrew"; readonly name: string }; + +// Third-party taps ("owner/tap/name") are not on formulae.brew.sh, so npm +// stays the only signal we have for them. +function resolveLatestVersionSource( + capabilities: ProviderMaintenanceCapabilities, +): ProviderLatestVersionSource | null { + if (capabilities.homebrewFormula && !capabilities.homebrewFormula.includes("/")) { + return { kind: "homebrew", name: capabilities.homebrewFormula }; + } + if (capabilities.packageName) { + return { kind: "npm", packageName: capabilities.packageName }; + } + return null; +} + export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVersion")(function* ( maintenanceCapabilities: ProviderMaintenanceCapabilities, ) { - const packageName = maintenanceCapabilities.packageName; - if (!packageName) { + const source = resolveLatestVersionSource(maintenanceCapabilities); + if (!source) { return null; } const latestVersionCache = yield* ProviderVersionCache; - const cached = latestVersionCache.get(packageName); + const cacheKey = source.kind === "npm" ? source.packageName : `homebrew:${source.name}`; + const cached = latestVersionCache.get(cacheKey); const now = DateTime.toEpochMillis(yield* DateTime.now); if (cached && cached.expiresAt > now) { return cached.version; } - const version = yield* fetchNpmLatestVersion(packageName); - latestVersionCache.set(packageName, { + const version = + source.kind === "npm" + ? yield* fetchNpmLatestVersion(source.packageName) + : yield* fetchHomebrewLatestVersion(source.name); + latestVersionCache.set(cacheKey, { expiresAt: now + LATEST_VERSION_CACHE_TTL_MS, version, });