From 35c17f1731dbfee6c366ee3d18bd5a2072009471 Mon Sep 17 00:00:00 2001 From: Craig Davison <5341072+davisonio@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:05:59 +0800 Subject: [PATCH 1/3] fix(server): npm provider updates only offer versions npm will install --- .../src/provider/Drivers/CodexDriver.test.ts | 23 +++-- .../src/server/maintenanceResolver.test.ts | 92 ++++++++++++++++++- .../src/server/maintenanceResolver.ts | 64 ++++++++++--- 3 files changed, 158 insertions(+), 21 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index db8f42faea7f0..c967eb0ff28ce 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -516,6 +516,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", @@ -525,7 +526,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); @@ -540,12 +541,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( @@ -575,7 +584,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 9afedc66c5a73..73927af77730e 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -197,7 +197,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({ @@ -289,6 +289,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", @@ -712,6 +714,94 @@ 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"); + writeExecutable(NodePath.join(npmBinDir, "npm")); + 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) => + args[args.indexOf("--prefix") + 1] !== tempDir + ? "{}" + : args[0] === "outdated" + ? JSON.stringify(fixture.outdated) + : JSON.stringify({ + 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 94889b4a0f13f..c7b4b266db099 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -376,24 +376,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, ) { @@ -408,9 +406,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; @@ -424,6 +420,39 @@ const resolvePnpmGlobalLatestVersion = Effect.fn("resolvePnpmGlobalLatestVersion return nonEmptyString(listedEntries?.[0]?.dependencies?.[packageName]?.version); }); +// `npm view` ignores min-release-age and before, but `outdated` applies them and +// their excludes the way `install @latest` does. +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* ( @@ -546,6 +575,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( // `Cellar/node//lib/node_modules/`, and that is npm's install, not brew's. const npmPrefix = yield* resolveNpmGlobalPrefix(context, packageName); if (npmPrefix) { + const services = yield* Effect.context(); // npm 12 blocks install scripts by default (empty allow-scripts allowlist) // and still exits 0, so a package whose postinstall finishes the install // (claude copies its native binary over a placeholder stub) is left broken @@ -564,6 +594,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), + ), }); } From 6e1f9f2e458cda680858fca7cfed9dac3255f985 Mon Sep 17 00:00:00 2001 From: Craig Davison <5341072+davisonio@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:15:54 +0800 Subject: [PATCH 2/3] refactor(server): state only what npm outdated applies --- packages/provider-core/src/server/maintenanceResolver.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index c7b4b266db099..04b2b2eebd1b3 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -420,8 +420,7 @@ const resolvePnpmGlobalLatestVersion = Effect.fn("resolvePnpmGlobalLatestVersion return nonEmptyString(listedEntries?.[0]?.dependencies?.[packageName]?.version); }); -// `npm view` ignores min-release-age and before, but `outdated` applies them and -// their excludes the way `install @latest` does. +// Unlike `npm view`, `outdated` applies min-release-age, before and their excludes. const resolveNpmGlobalLatestVersion = Effect.fn("resolveNpmGlobalLatestVersion")(function* ( packageName: string, npmPrefix: string, From 4a516aa9f9702cd7737c30c6f0e60bd414c7d1ae Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 10:37:02 +0000 Subject: [PATCH 3/3] test(server): verify npm updater path and prefix --- .../src/server/maintenanceResolver.test.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index ba72c842f2fa9..44bbb13dcc6b3 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -745,7 +745,8 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-npm-latest"); const npmBinDir = NodePath.join(tempDir, "npm-bin"); - writeExecutable(NodePath.join(npmBinDir, "npm")); + const npmPath = NodePath.join(npmBinDir, "npm"); + writeExecutable(npmPath); const link = linkIntoPackage(tempDir, "package-tool", [ "lib", "node_modules", @@ -761,14 +762,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, stdoutSpawner( - (_command, args) => - args[args.indexOf("--prefix") + 1] !== tempDir - ? "{}" - : args[0] === "outdated" - ? JSON.stringify(fixture.outdated) - : JSON.stringify({ + (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)), ), ),