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
23 changes: 17 additions & 6 deletions apps/server/src/provider/Drivers/CodexDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
Expand All @@ -531,12 +532,20 @@ it.layer(layerTest)("CodexDriver", (it) => {
].join(NodePath.delimiter);
const probes: Array<ReadonlyArray<string>> = [];
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(
Expand Down Expand Up @@ -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,
Expand Down
96 changes: 95 additions & 1 deletion packages/provider-core/src/server/maintenanceResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
() =>
Expand Down
63 changes: 49 additions & 14 deletions packages/provider-core/src/server/maintenanceResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand All @@ -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;
Expand All @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// 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* (
Expand Down Expand Up @@ -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<ProviderMaintenanceResolverServices>();
// npm 12 requires script approval to install native binaries; older npm only warns about the flag.
return makeProviderMaintenanceCapabilities({
provider: definition.provider,
Expand All @@ -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),
),
});
}

Expand Down
Loading