diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 90e37cc66712b..944b3a4492d14 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -507,6 +507,7 @@ it.layer(layerTest)("CodexDriver", (it) => { const shimDir = NodePath.join(tempDir, fixture.dataRoot, "shims"); const npmPrefix = NodePath.join(tempDir, "mise", "installs", "node", "24.13.0"); const npmBin = NodePath.join(npmPrefix, "bin"); + const npmPath = NodePath.join(npmBin, "npm"); const npmEntry = NodePath.join( npmPrefix, "lib", @@ -516,7 +517,7 @@ it.layer(layerTest)("CodexDriver", (it) => { "bin", "codex.js", ); - for (const file of [brewPath, misePath, npmEntry]) { + for (const file of [brewPath, misePath, npmPath, npmEntry]) { yield* fs.makeDirectory(NodePath.dirname(file), { recursive: true }); yield* fs.writeFileString(file, "#!/bin/sh\n"); yield* fs.chmod(file, 0o755); @@ -531,12 +532,20 @@ it.layer(layerTest)("CodexDriver", (it) => { ].join(NodePath.delimiter); const probes: Array> = []; const metadataSpawner = ChildProcessSpawner.make((command) => { - if (!ChildProcess.isStandardCommand(command) || command.command !== brewPath) { + const isNpmOutdated = + ChildProcess.isStandardCommand(command) && + command.command === npmPath && + command.args[0] === "outdated"; + if ( + !ChildProcess.isStandardCommand(command) || + (command.command !== brewPath && !isNpmOutdated) + ) { return Effect.die("Provider resolution must not execute a provider or updater"); } - probes.push(command.args); - const stdout = - command.args[0] === "--prefix" + if (!isNpmOutdated) probes.push(command.args); + const stdout = isNpmOutdated + ? JSON.stringify({ "@openai/codex": { latest: "0.153.4" } }) + : command.args[0] === "--prefix" ? brewPrefix : JSON.stringify({ formulae: [{ versions: { stable: "2026.9.1" } }] }); return Effect.succeed( @@ -566,7 +575,9 @@ it.layer(layerTest)("CodexDriver", (it) => { homePath: NodePath.join(tempDir, "codex-home"), }, }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, metadataSpawner)); - const capabilities = yield* instance.snapshot.resolveMaintenance(); + const capabilities = yield* instance.snapshot + .resolveMaintenance() + .pipe(Effect.provideService(HostProcess.Environment, { PATH: lookupPath })); const latestVersion = yield* resolveLatestProviderVersion(capabilities).pipe( Effect.provideServiceEffect( ProviderLatestVersions.ProviderLatestVersions, diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index 60522e0c29f48..44bbb13dcc6b3 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -196,7 +196,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect(advisoryFor(pnpmTool, "1.1.0-beta.1")).toBe( "pnpm add -g --allow-build=@example/package-tool @example/package-tool@latest", ); - // Only pnpm's minimumReleaseAge skips fresh releases; npm keeps @latest. + // npm keeps @latest; its outdated probe already reports what that resolves to. expect( advisoryFor( makeProviderMaintenanceCapabilities({ @@ -288,6 +288,8 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect(capabilities).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", + latestVersion: null, + lookupLatestVersion: expect.anything(), update: { command: `npm install -g --prefix ${tempDir} --allow-scripts=@example/package-tool @example/package-tool@latest`, executable: "npm", @@ -707,6 +709,98 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { { skip: !symlinksSupported || windowsHost }, ); + it.effect.each([ + { + case: "the eligible release", + outdated: { "@example/package-tool": { current: "1.0.0", wanted: "1.1.0", latest: "1.1.0" } }, + exitCode: 1, + status: "behind_latest", + latestVersion: "1.1.0", + }, + { + case: "no update during cooldown", + outdated: {}, + exitCode: 0, + status: "current", + latestVersion: "1.0.0", + }, + { + case: "no update during cooldown beside a broken global", + outdated: {}, + exitCode: 0, + listExitCode: 1, + status: "current", + latestVersion: "1.0.0", + }, + { + case: "no registry version when npm cannot reach the registry", + outdated: { error: { code: "ECONNREFUSED" } }, + exitCode: 1, + status: "unknown", + latestVersion: null, + }, + ])( + "advertises $case for npm-global installs", + (fixture) => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-npm-latest"); + const npmBinDir = NodePath.join(tempDir, "npm-bin"); + const npmPath = NodePath.join(npmBinDir, "npm"); + writeExecutable(npmPath); + const link = linkIntoPackage(tempDir, "package-tool", [ + "lib", + "node_modules", + "@example", + "package-tool", + ]); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + packageToolUpdate, + { binaryPath: link, env: { PATH: "" } }, + ).pipe( + Effect.provideService(HostProcess.Environment, { PATH: npmBinDir }), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + stdoutSpawner( + (command, args) => { + expect(command).toBe(npmPath); + expect(args.slice(1, 4)).toEqual(["-g", "--prefix", tempDir]); + return JSON.stringify( + args[0] === "outdated" + ? fixture.outdated + : { + dependencies: { "@example/package-tool": { version: "1.0.0" } }, + }, + ); + }, + (args) => (args[0] === "outdated" ? fixture.exitCode : (fixture.listExitCode ?? 0)), + ), + ), + ); + + const provider = yield* enrichProviderSnapshotWithVersionAdvisory( + installedPackageToolProvider, + capabilities, + ).pipe( + Effect.provideServiceEffect( + ProviderLatestVersions.ProviderLatestVersions, + ProviderLatestVersions.make([["@example/package-tool", "9.9.9"]]), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("the npm version is already cached")), + ), + ); + expect(provider.versionAdvisory).toMatchObject({ + status: fixture.status, + currentVersion: "1.0.0", + latestVersion: fixture.latestVersion, + updateCommand: expect.stringMatching(/@example\/package-tool@latest$/), + }); + }), + { skip: !symlinksSupported || windowsHost }, + ); + it.effect.skipIf(windowsHost)( "switches to bun updates when the resolved binary lives in bun's global bin", () => diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 2e3aad166c58e..15175592e86ff 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -369,24 +369,22 @@ const runProbe = Effect.fn("runProbe")(function* ( ); }); -const decodePnpmOutdated = Schema.decodeUnknownOption( +const decodeOutdated = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Record(Schema.String, Schema.Struct({ latest: Schema.optional(Schema.String) })), ), ); -const decodePnpmList = Schema.decodeUnknownOption( - Schema.fromJsonString( - Schema.Array( - Schema.Struct({ - dependencies: Schema.optional( - Schema.Record(Schema.String, Schema.Struct({ version: Schema.optional(Schema.String) })), - ), - }), - ), +const GlobalPackageList = Schema.Struct({ + dependencies: Schema.optional( + Schema.Record(Schema.String, Schema.Struct({ version: Schema.optional(Schema.String) })), ), +}); +const decodePnpmList = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Array(GlobalPackageList)), ); +const decodeNpmList = Schema.decodeUnknownOption(Schema.fromJsonString(GlobalPackageList)); -// Unlike npm's latest tag, pnpm outdated respects minimumReleaseAge and its excludes. +// Unlike the registry's latest tag, pnpm outdated respects minimumReleaseAge and its excludes. const resolvePnpmGlobalLatestVersion = Effect.fn("resolvePnpmGlobalLatestVersion")(function* ( packageName: string, ) { @@ -401,9 +399,7 @@ const resolvePnpmGlobalLatestVersion = Effect.fn("resolvePnpmGlobalLatestVersion // pnpm outdated exits 1 when updates are available. [0, 1], ); - const outdatedEntries = outdated - ? Option.getOrUndefined(decodePnpmOutdated(outdated)) - : undefined; + const outdatedEntries = outdated ? Option.getOrUndefined(decodeOutdated(outdated)) : undefined; if (!outdatedEntries) return null; const latest = nonEmptyString(outdatedEntries[packageName]?.latest); if (latest) return latest; @@ -417,6 +413,38 @@ const resolvePnpmGlobalLatestVersion = Effect.fn("resolvePnpmGlobalLatestVersion return nonEmptyString(listedEntries?.[0]?.dependencies?.[packageName]?.version); }); +// Unlike `npm view`, `outdated` applies min-release-age, before and their excludes. +const resolveNpmGlobalLatestVersion = Effect.fn("resolveNpmGlobalLatestVersion")(function* ( + packageName: string, + npmPrefix: string, +) { + // Match the updater's npm and server environment, rather than the instance's. + const npmPath = yield* resolveCommandPath("npm"); + if (!npmPath) return null; + const outdated = yield* runProbe( + npmPath, + ["outdated", "-g", "--prefix", npmPrefix, "--json", packageName], + {}, + // npm outdated exits 1 when updates are available. + [0, 1], + ); + const outdatedEntries = outdated ? Option.getOrUndefined(decodeOutdated(outdated)) : undefined; + // npm prints failures such as an unreachable registry as `{"error": ...}`, also exiting 1. + if (!outdatedEntries || "error" in outdatedEntries) return null; + const latest = nonEmptyString(outdatedEntries[packageName]?.latest); + if (latest) return latest; + // An empty result means the installed version is still the eligible release. + const listed = yield* runProbe( + npmPath, + ["ls", "-g", "--prefix", npmPrefix, "--depth", "0", "--json", packageName], + {}, + // npm ls still prints the tree but exits 1 when other globals have problems. + [0, 1], + ); + const listedEntries = listed ? Option.getOrUndefined(decodeNpmList(listed)) : undefined; + return nonEmptyString(listedEntries?.dependencies?.[packageName]?.version); +}); + export const resolvePackageManagedProviderMaintenance = Effect.fn( "resolvePackageManagedProviderMaintenance", )(function* ( @@ -533,6 +561,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( // npm proof outranks a containing Homebrew keg; Homebrew Node globals still belong to npm. const npmPrefix = yield* resolveNpmGlobalPrefix(context, packageName); if (npmPrefix) { + const services = yield* Effect.context(); // npm 12 requires script approval to install native binaries; older npm only warns about the flag. return makeProviderMaintenanceCapabilities({ provider: definition.provider, @@ -547,6 +576,12 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( `${packageName}@latest`, ], updateLockKey: `npm-global:${normalizeCommandPath(npmPrefix)}`, + // Inside min-release-age `@latest` installs an older release than the + // registry's tag, so only npm's own answer is worth advertising. + latestVersion: null, + lookupLatestVersion: resolveNpmGlobalLatestVersion(packageName, npmPrefix).pipe( + Effect.provideContext(services), + ), }); }