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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/provider/makeManagedServerProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Expand Down
111 changes: 110 additions & 1 deletion apps/server/src/provider/providerMaintenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>) =>
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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",

Expand Down Expand Up @@ -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",

Expand Down Expand Up @@ -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",

Expand Down Expand Up @@ -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",

Expand All @@ -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",

Expand Down Expand Up @@ -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",

Expand Down Expand Up @@ -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",

Expand All @@ -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",

Expand All @@ -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",

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -594,6 +702,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
).toEqual({
provider: driver("packageTool"),
packageName: "@example/package-tool",
homebrewFormula: null,
update: null,
});
});
Expand Down
92 changes: 81 additions & 11 deletions apps/server/src/provider/providerMaintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -89,6 +95,12 @@ export const ProviderVersionCache = Context.Reference<Map<string, ProviderVersio
const NpmLatestVersionResponse = Schema.Struct({
version: Schema.optional(Schema.String),
});
const HomebrewCaskResponse = Schema.Struct({
version: Schema.optional(Schema.String),
});
const HomebrewFormulaResponse = Schema.Struct({
versions: Schema.optional(Schema.Struct({ stable: Schema.optional(Schema.String) })),
});

function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
Expand All @@ -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<string>;
readonly updateLockKey: string | null;
Expand All @@ -113,6 +126,7 @@ export function makeProviderMaintenanceCapabilities(input: {
return {
provider: input.provider,
packageName: input.packageName,
homebrewFormula: input.homebrewFormula ?? null,
update,
};
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -430,11 +445,14 @@ export function createProviderVersionAdvisory(input: {
};
}

const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (packageName: string) {
const fetchJson = Effect.fn("fetchJson")(function* <A>(
url: string,
schema: Schema.Codec<A, unknown>,
) {
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()),
Expand All @@ -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,
});
Expand Down
Loading