From 063dd003639d0a537f02a1784f75c5b177c8ae6e Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 8 Dec 2025 20:03:21 -0700 Subject: [PATCH 1/3] feat(roo): add versioned settings support with minPluginVersion gating Add ability to gate model settings behind minimum plugin versions. Settings from the Roo API can now be either direct values or wrapped with { value: T, minPluginVersion: string } to conditionally apply based on the extension version. - Add versionedSettings.ts with semver comparison utilities - Add resolveVersionedSettings() to process versioned values - Update getRooModels() to resolve versioned settings - Add comprehensive tests for all utility functions --- .../__tests__/versionedSettings.spec.ts | 244 ++++++++++++++++++ src/api/providers/fetchers/roo.ts | 16 +- .../providers/fetchers/versionedSettings.ts | 126 +++++++++ 3 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 src/api/providers/fetchers/__tests__/versionedSettings.spec.ts create mode 100644 src/api/providers/fetchers/versionedSettings.ts diff --git a/src/api/providers/fetchers/__tests__/versionedSettings.spec.ts b/src/api/providers/fetchers/__tests__/versionedSettings.spec.ts new file mode 100644 index 00000000000..327dee213ea --- /dev/null +++ b/src/api/providers/fetchers/__tests__/versionedSettings.spec.ts @@ -0,0 +1,244 @@ +import { + compareSemver, + meetsMinimumVersion, + resolveVersionedSettings, + isVersionedValue, + type VersionedValue, +} from "../versionedSettings" + +describe("versionedSettings", () => { + describe("isVersionedValue", () => { + it("should return true for valid versioned value objects", () => { + const versionedValue: VersionedValue = { + value: ["search_replace"], + minPluginVersion: "3.36.4", + } + expect(isVersionedValue(versionedValue)).toBe(true) + }) + + it("should return true for versioned value with any value type", () => { + expect(isVersionedValue({ value: true, minPluginVersion: "1.0.0" })).toBe(true) + expect(isVersionedValue({ value: 42, minPluginVersion: "1.0.0" })).toBe(true) + expect(isVersionedValue({ value: "string", minPluginVersion: "1.0.0" })).toBe(true) + expect(isVersionedValue({ value: null, minPluginVersion: "1.0.0" })).toBe(true) + expect(isVersionedValue({ value: { nested: "object" }, minPluginVersion: "1.0.0" })).toBe(true) + }) + + it("should return false for non-versioned values", () => { + expect(isVersionedValue(null)).toBe(false) + expect(isVersionedValue(undefined)).toBe(false) + expect(isVersionedValue("string")).toBe(false) + expect(isVersionedValue(123)).toBe(false) + expect(isVersionedValue(["array"])).toBe(false) + expect(isVersionedValue({ value: "only value" })).toBe(false) + expect(isVersionedValue({ minPluginVersion: "1.0.0" })).toBe(false) + expect(isVersionedValue({ value: "test", minPluginVersion: 123 })).toBe(false) // version must be string + }) + }) + + describe("compareSemver", () => { + it("should return 0 for equal versions", () => { + expect(compareSemver("1.0.0", "1.0.0")).toBe(0) + expect(compareSemver("3.36.4", "3.36.4")).toBe(0) + expect(compareSemver("0.0.1", "0.0.1")).toBe(0) + }) + + it("should return positive when first version is greater", () => { + expect(compareSemver("2.0.0", "1.0.0")).toBeGreaterThan(0) + expect(compareSemver("1.1.0", "1.0.0")).toBeGreaterThan(0) + expect(compareSemver("1.0.1", "1.0.0")).toBeGreaterThan(0) + expect(compareSemver("3.36.5", "3.36.4")).toBeGreaterThan(0) + expect(compareSemver("3.37.0", "3.36.4")).toBeGreaterThan(0) + expect(compareSemver("4.0.0", "3.36.4")).toBeGreaterThan(0) + }) + + it("should return negative when first version is smaller", () => { + expect(compareSemver("1.0.0", "2.0.0")).toBeLessThan(0) + expect(compareSemver("1.0.0", "1.1.0")).toBeLessThan(0) + expect(compareSemver("1.0.0", "1.0.1")).toBeLessThan(0) + expect(compareSemver("3.36.3", "3.36.4")).toBeLessThan(0) + expect(compareSemver("3.35.0", "3.36.4")).toBeLessThan(0) + expect(compareSemver("2.0.0", "3.36.4")).toBeLessThan(0) + }) + + it("should handle versions with different segment counts", () => { + expect(compareSemver("1.0", "1.0.0")).toBe(0) + expect(compareSemver("1", "1.0.0")).toBe(0) + expect(compareSemver("1.0.0.0", "1.0.0")).toBe(0) + expect(compareSemver("1.0.1", "1.0")).toBeGreaterThan(0) + expect(compareSemver("1.0", "1.0.1")).toBeLessThan(0) + }) + + it("should handle pre-release versions by ignoring pre-release suffix", () => { + expect(compareSemver("3.36.4-beta.1", "3.36.4")).toBe(0) + expect(compareSemver("3.36.4-rc.2", "3.36.4")).toBe(0) + expect(compareSemver("3.36.5-alpha", "3.36.4")).toBeGreaterThan(0) + expect(compareSemver("3.36.3-beta", "3.36.4")).toBeLessThan(0) + }) + + it("should handle edge cases", () => { + expect(compareSemver("0.0.0", "0.0.0")).toBe(0) + expect(compareSemver("10.20.30", "10.20.30")).toBe(0) + expect(compareSemver("10.0.0", "9.99.99")).toBeGreaterThan(0) + }) + }) + + describe("meetsMinimumVersion", () => { + it("should return true when current version equals minimum", () => { + expect(meetsMinimumVersion("3.36.4", "3.36.4")).toBe(true) + }) + + it("should return true when current version exceeds minimum", () => { + expect(meetsMinimumVersion("3.36.4", "3.36.5")).toBe(true) + expect(meetsMinimumVersion("3.36.4", "3.37.0")).toBe(true) + expect(meetsMinimumVersion("3.36.4", "4.0.0")).toBe(true) + }) + + it("should return false when current version is below minimum", () => { + expect(meetsMinimumVersion("3.36.4", "3.36.3")).toBe(false) + expect(meetsMinimumVersion("3.36.4", "3.35.0")).toBe(false) + expect(meetsMinimumVersion("3.36.4", "2.0.0")).toBe(false) + }) + }) + + describe("resolveVersionedSettings", () => { + const currentVersion = "3.36.4" + + it("should pass through non-versioned settings unchanged", () => { + const settings = { + includedTools: ["search_replace"], + excludedTools: ["apply_diff"], + supportsReasoningEffort: false, + } + + const resolved = resolveVersionedSettings(settings, currentVersion) + + expect(resolved).toEqual(settings) + }) + + it("should include versioned settings when version requirement is met", () => { + const settings = { + includedTools: { + value: ["search_replace"], + minPluginVersion: "3.36.4", + }, + excludedTools: { + value: ["apply_diff"], + minPluginVersion: "3.36.0", + }, + } + + const resolved = resolveVersionedSettings(settings, currentVersion) + + expect(resolved).toEqual({ + includedTools: ["search_replace"], + excludedTools: ["apply_diff"], + }) + }) + + it("should exclude versioned settings when version requirement is not met", () => { + const settings = { + includedTools: { + value: ["search_replace"], + minPluginVersion: "3.36.5", // Higher than current + }, + excludedTools: { + value: ["apply_diff"], + minPluginVersion: "4.0.0", // Higher than current + }, + } + + const resolved = resolveVersionedSettings(settings, currentVersion) + + expect(resolved).toEqual({}) + }) + + it("should handle mixed versioned and non-versioned settings", () => { + const settings = { + supportsReasoningEffort: false, // Non-versioned, should be included + includedTools: { + value: ["search_replace"], + minPluginVersion: "3.36.4", // Met, should be included + }, + excludedTools: { + value: ["apply_diff"], + minPluginVersion: "4.0.0", // Not met, should be excluded + }, + description: "A test model", // Non-versioned, should be included + } + + const resolved = resolveVersionedSettings(settings, currentVersion) + + expect(resolved).toEqual({ + supportsReasoningEffort: false, + includedTools: ["search_replace"], + description: "A test model", + }) + }) + + it("should handle empty settings object", () => { + const resolved = resolveVersionedSettings({}, currentVersion) + expect(resolved).toEqual({}) + }) + + it("should handle versioned boolean values", () => { + const settings = { + supportsNativeTools: { + value: true, + minPluginVersion: "3.36.0", + }, + } + + const resolved = resolveVersionedSettings(settings, currentVersion) + + expect(resolved).toEqual({ + supportsNativeTools: true, + }) + }) + + it("should handle versioned null values", () => { + const settings = { + defaultTemperature: { + value: null, + minPluginVersion: "3.36.0", + }, + } + + const resolved = resolveVersionedSettings(settings, currentVersion) + + expect(resolved).toEqual({ + defaultTemperature: null, + }) + }) + + it("should handle versioned nested objects", () => { + const settings = { + complexSetting: { + value: { nested: { deeply: true } }, + minPluginVersion: "3.36.0", + }, + } + + const resolved = resolveVersionedSettings(settings, currentVersion) + + expect(resolved).toEqual({ + complexSetting: { nested: { deeply: true } }, + }) + }) + + it("should correctly resolve settings with exact version match", () => { + const settings = { + feature: { + value: "enabled", + minPluginVersion: "3.36.4", // Exact match + }, + } + + const resolved = resolveVersionedSettings(settings, "3.36.4") + + expect(resolved).toEqual({ + feature: "enabled", + }) + }) + }) +}) diff --git a/src/api/providers/fetchers/roo.ts b/src/api/providers/fetchers/roo.ts index 735d4bed922..69c552111c1 100644 --- a/src/api/providers/fetchers/roo.ts +++ b/src/api/providers/fetchers/roo.ts @@ -4,6 +4,7 @@ import type { ModelRecord } from "../../../shared/api" import { parseApiPrice } from "../../../shared/cost" import { DEFAULT_HEADERS } from "../constants" +import { resolveVersionedSettings } from "./versionedSettings" /** * Fetches available models from the Roo Code Cloud provider @@ -128,9 +129,18 @@ export async function getRooModels(baseUrl: string, apiKey?: string): Promise | undefined - - models[modelId] = apiSettings ? { ...baseModelInfo, ...apiSettings } : baseModelInfo + // Settings can be versioned with minPluginVersion to gate features by plugin version: + // - Direct values: { includedTools: ['search_replace'] } + // - Versioned values: { includedTools: { value: ['search_replace'], minPluginVersion: '3.36.4' } } + const apiSettings = model.settings as Record | undefined + + if (apiSettings) { + // Resolve versioned settings based on current plugin version + const resolvedSettings = resolveVersionedSettings(apiSettings) as Partial + models[modelId] = { ...baseModelInfo, ...resolvedSettings } + } else { + models[modelId] = baseModelInfo + } } return models diff --git a/src/api/providers/fetchers/versionedSettings.ts b/src/api/providers/fetchers/versionedSettings.ts new file mode 100644 index 00000000000..5667bbb9621 --- /dev/null +++ b/src/api/providers/fetchers/versionedSettings.ts @@ -0,0 +1,126 @@ +import { z } from "zod" + +import { Package } from "../../../shared/package" + +/** + * Schema for a versioned setting value. + * Allows settings to be gated behind a minimum plugin version. + * + * Example: + * ``` + * { + * includedTools: { + * value: ['search_replace'], + * minPluginVersion: '3.36.4', + * } + * } + * ``` + */ +export const versionedValueSchema = (valueSchema: T) => + z.object({ + value: valueSchema, + minPluginVersion: z.string(), + }) + +/** + * Type for a versioned setting value. + */ +export type VersionedValue = { + value: T + minPluginVersion: string +} + +/** + * Type guard to check if a value is a versioned value object. + */ +export function isVersionedValue(value: unknown): value is VersionedValue { + return ( + typeof value === "object" && + value !== null && + "value" in value && + "minPluginVersion" in value && + typeof (value as VersionedValue).minPluginVersion === "string" + ) +} + +/** + * Compares two semantic version strings. + * + * @param version1 First version string (e.g., "3.36.4") + * @param version2 Second version string (e.g., "3.36.0") + * @returns negative if version1 < version2, 0 if equal, positive if version1 > version2 + */ +export function compareSemver(version1: string, version2: string): number { + // Parse version strings, handling potential pre-release tags + const parseVersion = (v: string): number[] => { + // Remove any pre-release suffix (e.g., "-beta.1", "-rc.2") + const baseVersion = v.split("-")[0] + return baseVersion.split(".").map((part) => { + const num = parseInt(part, 10) + return isNaN(num) ? 0 : num + }) + } + + const v1Parts = parseVersion(version1) + const v2Parts = parseVersion(version2) + + // Pad shorter array with zeros + const maxLength = Math.max(v1Parts.length, v2Parts.length) + while (v1Parts.length < maxLength) v1Parts.push(0) + while (v2Parts.length < maxLength) v2Parts.push(0) + + // Compare each component + for (let i = 0; i < maxLength; i++) { + const diff = v1Parts[i] - v2Parts[i] + if (diff !== 0) { + return diff + } + } + + return 0 +} + +/** + * Checks if the current plugin version meets or exceeds the required minimum version. + * + * @param minPluginVersion The minimum required version + * @param currentVersion The current plugin version (defaults to Package.version) + * @returns true if current version >= minPluginVersion + */ +export function meetsMinimumVersion(minPluginVersion: string, currentVersion: string = Package.version): boolean { + return compareSemver(currentVersion, minPluginVersion) >= 0 +} + +/** + * Resolves versioned settings by extracting values only when the current plugin + * version meets the minimum version requirement. + * + * Settings can be either: + * - Direct values: `{ includedTools: ['search_replace'] }` + * - Versioned values: `{ includedTools: { value: ['search_replace'], minPluginVersion: '3.36.4' } }` + * + * @param settings The settings object with potentially versioned values + * @param currentVersion The current plugin version (defaults to Package.version) + * @returns A new settings object with versioned values resolved + */ +export function resolveVersionedSettings>( + settings: T, + currentVersion: string = Package.version, +): Partial { + const resolved: Partial = {} + + for (const [key, value] of Object.entries(settings)) { + if (isVersionedValue(value)) { + // Only include the setting if the version requirement is met + if (meetsMinimumVersion(value.minPluginVersion, currentVersion)) { + resolved[key as keyof T] = value.value as T[keyof T] + } + // If version requirement is not met, the setting is omitted + } else { + // Non-versioned values are included directly + resolved[key as keyof T] = value as T[keyof T] + } + } + + return resolved +} From c28d8eaa4aef9ddf4d1702fc0470ece4c3828133 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 8 Dec 2025 20:46:44 -0700 Subject: [PATCH 2/3] fix(roo): use separate versionedSettings field for backward compatibility Address review feedback from @mrubens: Old clients expect plain values in the settings field (e.g., includedTools as array of strings). Changes: - Add new versionedSettings field to RooModelSchema for version-gated values - Keep settings field for plain values (backward compatible with old clients) - New clients read from both fields: settings first, then overlay versionedSettings - Update documentation to clarify the two-field approach - Add tests for versionedSettings overlay behavior --- packages/types/src/providers/roo.ts | 6 + .../providers/fetchers/__tests__/roo.spec.ts | 136 ++++++++++++++++++ src/api/providers/fetchers/roo.ts | 31 ++-- .../providers/fetchers/versionedSettings.ts | 18 ++- 4 files changed, 179 insertions(+), 12 deletions(-) diff --git a/packages/types/src/providers/roo.ts b/packages/types/src/providers/roo.ts index af88d145ee5..fcb5d5a76c0 100644 --- a/packages/types/src/providers/roo.ts +++ b/packages/types/src/providers/roo.ts @@ -41,7 +41,13 @@ export const RooModelSchema = z.object({ default_temperature: z.number().optional(), // Dynamic settings that map directly to ModelInfo properties // Allows the API to configure model-specific defaults like includedTools, excludedTools, reasoningEffort, etc. + // These are always direct values (e.g., includedTools: ['search_replace']) for backward compatibility with old clients. settings: z.record(z.string(), z.unknown()).optional(), + // Versioned settings that are gated behind minimum plugin versions. + // Each value is an object with { value: T, minPluginVersion: string }. + // New clients check this field first and resolve based on current plugin version. + // Old clients ignore this field and use plain values from `settings`. + versionedSettings: z.record(z.string(), z.unknown()).optional(), }) export const RooModelsResponseSchema = z.object({ diff --git a/src/api/providers/fetchers/__tests__/roo.spec.ts b/src/api/providers/fetchers/__tests__/roo.spec.ts index 7cc479f9ba4..2cb6d335c5e 100644 --- a/src/api/providers/fetchers/__tests__/roo.spec.ts +++ b/src/api/providers/fetchers/__tests__/roo.spec.ts @@ -801,4 +801,140 @@ describe("getRooModels", () => { expect(model.anotherSetting).toBe(42) expect(model.nestedConfig).toEqual({ key: "value" }) }) + + it("should apply versioned settings on top of plain settings", async () => { + const mockResponse = { + object: "list", + data: [ + { + id: "test/versioned-model", + object: "model", + created: 1234567890, + owned_by: "test", + name: "Model with Versioned Settings", + description: "Model with versioned settings", + context_window: 128000, + max_tokens: 8192, + type: "language", + tags: ["tool-use"], + pricing: { + input: "0.0001", + output: "0.0002", + }, + // Plain settings for backward compatibility with old clients + settings: { + includedTools: ["apply_patch"], + excludedTools: ["write_to_file"], + }, + // Versioned settings for new clients (low version requirement, always met) + versionedSettings: { + includedTools: { + value: ["apply_patch", "search_replace"], + minPluginVersion: "1.0.0", // Very low version - always met + }, + excludedTools: { + value: ["apply_diff", "write_to_file"], + minPluginVersion: "1.0.0", // Very low version - always met + }, + }, + }, + ], + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }) + + const models = await getRooModels(baseUrl, apiKey) + + // Versioned settings should override plain settings + expect(models["test/versioned-model"].includedTools).toEqual(["apply_patch", "search_replace"]) + expect(models["test/versioned-model"].excludedTools).toEqual(["apply_diff", "write_to_file"]) + }) + + it("should use plain settings when versioned settings version requirement is not met", async () => { + const mockResponse = { + object: "list", + data: [ + { + id: "test/old-version-model", + object: "model", + created: 1234567890, + owned_by: "test", + name: "Model for Old Version", + description: "Model with versioned settings for newer version", + context_window: 128000, + max_tokens: 8192, + type: "language", + tags: ["tool-use"], + pricing: { + input: "0.0001", + output: "0.0002", + }, + settings: { + includedTools: ["apply_patch"], + }, + versionedSettings: { + // Very high version requirement - never met + includedTools: { + value: ["apply_patch", "search_replace"], + minPluginVersion: "99.0.0", + }, + }, + }, + ], + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }) + + const models = await getRooModels(baseUrl, apiKey) + + // Should use plain settings since versioned requirement is not met + expect(models["test/old-version-model"].includedTools).toEqual(["apply_patch"]) + }) + + it("should handle model with only versionedSettings and no plain settings", async () => { + const mockResponse = { + object: "list", + data: [ + { + id: "test/versioned-only-model", + object: "model", + created: 1234567890, + owned_by: "test", + name: "Model with Only Versioned Settings", + description: "Model with only versioned settings", + context_window: 128000, + max_tokens: 8192, + type: "language", + tags: [], + pricing: { + input: "0.0001", + output: "0.0002", + }, + // No plain settings, only versionedSettings + versionedSettings: { + customFeature: { + value: true, + minPluginVersion: "1.0.0", // Low version, should always be met + }, + }, + }, + ], + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }) + + const models = await getRooModels(baseUrl, apiKey) + const model = models["test/versioned-only-model"] as any + + expect(model.customFeature).toBe(true) + }) }) diff --git a/src/api/providers/fetchers/roo.ts b/src/api/providers/fetchers/roo.ts index 69c552111c1..f8377550bfb 100644 --- a/src/api/providers/fetchers/roo.ts +++ b/src/api/providers/fetchers/roo.ts @@ -129,18 +129,33 @@ export async function getRooModels(baseUrl: string, apiKey?: string): Promise | undefined + const apiVersionedSettings = model.versionedSettings as Record | undefined + // Start with base model info + let modelInfo: ModelInfo = { ...baseModelInfo } + + // Apply plain settings (backward compatible with old clients) if (apiSettings) { - // Resolve versioned settings based on current plugin version - const resolvedSettings = resolveVersionedSettings(apiSettings) as Partial - models[modelId] = { ...baseModelInfo, ...resolvedSettings } - } else { - models[modelId] = baseModelInfo + modelInfo = { ...modelInfo, ...(apiSettings as Partial) } + } + + // Apply versioned settings (new clients only - resolved based on plugin version) + if (apiVersionedSettings) { + const resolvedVersionedSettings = resolveVersionedSettings( + apiVersionedSettings, + ) as Partial + modelInfo = { ...modelInfo, ...resolvedVersionedSettings } } + + models[modelId] = modelInfo } return models diff --git a/src/api/providers/fetchers/versionedSettings.ts b/src/api/providers/fetchers/versionedSettings.ts index 5667bbb9621..62148538b45 100644 --- a/src/api/providers/fetchers/versionedSettings.ts +++ b/src/api/providers/fetchers/versionedSettings.ts @@ -6,12 +6,22 @@ import { Package } from "../../../shared/package" * Schema for a versioned setting value. * Allows settings to be gated behind a minimum plugin version. * - * Example: + * These values should be placed in the `versionedSettings` field of the API response, + * separate from the plain `settings` field. This ensures backward compatibility: + * - Old clients read from `settings` (plain values only) + * - New clients read from both `settings` and `versionedSettings` + * + * Example API response: * ``` * { - * includedTools: { - * value: ['search_replace'], - * minPluginVersion: '3.36.4', + * settings: { + * includedTools: ['search_replace'] // Plain value for old clients + * }, + * versionedSettings: { + * includedTools: { + * value: ['search_replace', 'apply_diff'], // Enhanced value for new clients + * minPluginVersion: '3.36.4', + * } * } * } * ``` From 3783869b05158a40ce0e4e154a41ab404356989d Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 9 Dec 2025 09:43:25 -0700 Subject: [PATCH 3/3] refactor(roo): restructure versionedSettings to use version as key Based on mrubens' review feedback: - Restructure versionedSettings to use version number as the key instead of having minPluginVersion on each setting value - Use semver-compare library instead of custom compareSemver function - Add findHighestMatchingVersion() to find the highest version <= current extension version - Make versionedSettings independent - use the highest versioned setting that's <= current version, OR fall back to plain settings if none match New structure: versionedSettings: { '3.36.4': { includedTools: ['search_replace'], excludedTools: ['apply_diff'], }, '3.35.0': { ... }, } The resolver finds the highest version key <= current plugin version and uses those settings entirely. If no version matches, falls back to plain settings. --- packages/types/src/providers/roo.ts | 9 +- pnpm-lock.yaml | 18 +- .../providers/fetchers/__tests__/roo.spec.ts | 81 ++++-- .../__tests__/versionedSettings.spec.ts | 233 ++++++++++-------- src/api/providers/fetchers/roo.ts | 34 +-- .../providers/fetchers/versionedSettings.ts | 153 +++++------- src/package.json | 2 + 7 files changed, 294 insertions(+), 236 deletions(-) diff --git a/packages/types/src/providers/roo.ts b/packages/types/src/providers/roo.ts index fcb5d5a76c0..1306d244a5b 100644 --- a/packages/types/src/providers/roo.ts +++ b/packages/types/src/providers/roo.ts @@ -43,11 +43,12 @@ export const RooModelSchema = z.object({ // Allows the API to configure model-specific defaults like includedTools, excludedTools, reasoningEffort, etc. // These are always direct values (e.g., includedTools: ['search_replace']) for backward compatibility with old clients. settings: z.record(z.string(), z.unknown()).optional(), - // Versioned settings that are gated behind minimum plugin versions. - // Each value is an object with { value: T, minPluginVersion: string }. - // New clients check this field first and resolve based on current plugin version. + // Versioned settings keyed by version number (e.g., '3.36.4'). + // Each version key maps to a settings object that is used when plugin version >= that version. + // New clients find the highest version key <= current version and use those settings. // Old clients ignore this field and use plain values from `settings`. - versionedSettings: z.record(z.string(), z.unknown()).optional(), + // Example: { '3.36.4': { includedTools: ['search_replace'] }, '3.35.0': { ... } } + versionedSettings: z.record(z.string(), z.record(z.string(), z.unknown())).optional(), }) export const RooModelsResponseSchema = z.object({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff3e592377e..8542674e140 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -795,6 +795,9 @@ importers: say: specifier: ^0.16.0 version: 0.16.0 + semver-compare: + specifier: ^1.0.0 + version: 1.0.0 serialize-error: specifier: ^12.0.0 version: 12.0.0 @@ -898,6 +901,9 @@ importers: '@types/ps-tree': specifier: ^1.1.6 version: 1.1.6 + '@types/semver-compare': + specifier: ^1.0.3 + version: 1.0.3 '@types/shell-quote': specifier: ^1.7.5 version: 1.7.5 @@ -4157,6 +4163,9 @@ packages: '@types/retry@0.12.5': resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@types/semver-compare@1.0.3': + resolution: {integrity: sha512-mVZkB2QjXmZhh+MrtwMlJ8BqUnmbiSkpd88uOWskfwB8yitBT0tBRAKt+41VRgZD9zr9Sc+Xs02qGgvzd1Rq/Q==} + '@types/shell-quote@1.7.5': resolution: {integrity: sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==} @@ -8906,6 +8915,9 @@ packages: seed-random@2.2.0: resolution: {integrity: sha512-34EQV6AAHQGhoc0tn/96a9Fsi6v2xdqe/dMUwljGRaFOzR3EgRmECvD0O8vi8X+/uQ50LGHfkNu/Eue5TPKZkQ==} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -13895,6 +13907,8 @@ snapshots: '@types/retry@0.12.5': {} + '@types/semver-compare@1.0.3': {} + '@types/shell-quote@1.7.5': {} '@types/stack-utils@2.0.3': {} @@ -14114,7 +14128,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -19476,6 +19490,8 @@ snapshots: seed-random@2.2.0: {} + semver-compare@1.0.0: {} + semver@5.7.2: {} semver@6.3.1: {} diff --git a/src/api/providers/fetchers/__tests__/roo.spec.ts b/src/api/providers/fetchers/__tests__/roo.spec.ts index 2cb6d335c5e..0ee7d8e3ed8 100644 --- a/src/api/providers/fetchers/__tests__/roo.spec.ts +++ b/src/api/providers/fetchers/__tests__/roo.spec.ts @@ -802,7 +802,7 @@ describe("getRooModels", () => { expect(model.nestedConfig).toEqual({ key: "value" }) }) - it("should apply versioned settings on top of plain settings", async () => { + it("should apply versioned settings when version matches", async () => { const mockResponse = { object: "list", data: [ @@ -826,15 +826,11 @@ describe("getRooModels", () => { includedTools: ["apply_patch"], excludedTools: ["write_to_file"], }, - // Versioned settings for new clients (low version requirement, always met) + // Versioned settings keyed by version number (low version - always met) versionedSettings: { - includedTools: { - value: ["apply_patch", "search_replace"], - minPluginVersion: "1.0.0", // Very low version - always met - }, - excludedTools: { - value: ["apply_diff", "write_to_file"], - minPluginVersion: "1.0.0", // Very low version - always met + "1.0.0": { + includedTools: ["apply_patch", "search_replace"], + excludedTools: ["apply_diff", "write_to_file"], }, }, }, @@ -848,12 +844,12 @@ describe("getRooModels", () => { const models = await getRooModels(baseUrl, apiKey) - // Versioned settings should override plain settings + // Versioned settings should be used instead of plain settings expect(models["test/versioned-model"].includedTools).toEqual(["apply_patch", "search_replace"]) expect(models["test/versioned-model"].excludedTools).toEqual(["apply_diff", "write_to_file"]) }) - it("should use plain settings when versioned settings version requirement is not met", async () => { + it("should use plain settings when no versioned settings version matches", async () => { const mockResponse = { object: "list", data: [ @@ -875,11 +871,10 @@ describe("getRooModels", () => { settings: { includedTools: ["apply_patch"], }, + // Versioned settings keyed by very high version - never met versionedSettings: { - // Very high version requirement - never met - includedTools: { - value: ["apply_patch", "search_replace"], - minPluginVersion: "99.0.0", + "99.0.0": { + includedTools: ["apply_patch", "search_replace"], }, }, }, @@ -893,7 +888,7 @@ describe("getRooModels", () => { const models = await getRooModels(baseUrl, apiKey) - // Should use plain settings since versioned requirement is not met + // Should use plain settings since no versioned settings match current version expect(models["test/old-version-model"].includedTools).toEqual(["apply_patch"]) }) @@ -916,11 +911,10 @@ describe("getRooModels", () => { input: "0.0001", output: "0.0002", }, - // No plain settings, only versionedSettings + // No plain settings, only versionedSettings keyed by version versionedSettings: { - customFeature: { - value: true, - minPluginVersion: "1.0.0", // Low version, should always be met + "1.0.0": { + customFeature: true, }, }, }, @@ -933,8 +927,53 @@ describe("getRooModels", () => { }) const models = await getRooModels(baseUrl, apiKey) - const model = models["test/versioned-only-model"] as any + const model = models["test/versioned-only-model"] as Record expect(model.customFeature).toBe(true) }) + + it("should select highest matching version from versionedSettings", async () => { + const mockResponse = { + object: "list", + data: [ + { + id: "test/multi-version-model", + object: "model", + created: 1234567890, + owned_by: "test", + name: "Model with Multiple Versions", + description: "Model with multiple version settings", + context_window: 128000, + max_tokens: 8192, + type: "language", + tags: [], + pricing: { + input: "0.0001", + output: "0.0002", + }, + settings: { + feature: "default", + }, + // Multiple version keys - should use highest one <= current version + versionedSettings: { + "99.0.0": { feature: "future" }, + "3.0.0": { feature: "current" }, + "2.0.0": { feature: "old" }, + "1.0.0": { feature: "very_old" }, + }, + }, + ], + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }) + + const models = await getRooModels(baseUrl, apiKey) + const model = models["test/multi-version-model"] as Record + + // Should use 3.0.0 version settings (highest that's <= current plugin version) + expect(model.feature).toBe("current") + }) }) diff --git a/src/api/providers/fetchers/__tests__/versionedSettings.spec.ts b/src/api/providers/fetchers/__tests__/versionedSettings.spec.ts index 327dee213ea..029f92b3017 100644 --- a/src/api/providers/fetchers/__tests__/versionedSettings.spec.ts +++ b/src/api/providers/fetchers/__tests__/versionedSettings.spec.ts @@ -1,41 +1,12 @@ import { compareSemver, meetsMinimumVersion, + findHighestMatchingVersion, resolveVersionedSettings, - isVersionedValue, - type VersionedValue, + type VersionedSettings, } from "../versionedSettings" describe("versionedSettings", () => { - describe("isVersionedValue", () => { - it("should return true for valid versioned value objects", () => { - const versionedValue: VersionedValue = { - value: ["search_replace"], - minPluginVersion: "3.36.4", - } - expect(isVersionedValue(versionedValue)).toBe(true) - }) - - it("should return true for versioned value with any value type", () => { - expect(isVersionedValue({ value: true, minPluginVersion: "1.0.0" })).toBe(true) - expect(isVersionedValue({ value: 42, minPluginVersion: "1.0.0" })).toBe(true) - expect(isVersionedValue({ value: "string", minPluginVersion: "1.0.0" })).toBe(true) - expect(isVersionedValue({ value: null, minPluginVersion: "1.0.0" })).toBe(true) - expect(isVersionedValue({ value: { nested: "object" }, minPluginVersion: "1.0.0" })).toBe(true) - }) - - it("should return false for non-versioned values", () => { - expect(isVersionedValue(null)).toBe(false) - expect(isVersionedValue(undefined)).toBe(false) - expect(isVersionedValue("string")).toBe(false) - expect(isVersionedValue(123)).toBe(false) - expect(isVersionedValue(["array"])).toBe(false) - expect(isVersionedValue({ value: "only value" })).toBe(false) - expect(isVersionedValue({ minPluginVersion: "1.0.0" })).toBe(false) - expect(isVersionedValue({ value: "test", minPluginVersion: 123 })).toBe(false) // version must be string - }) - }) - describe("compareSemver", () => { it("should return 0 for equal versions", () => { expect(compareSemver("1.0.0", "1.0.0")).toBe(0) @@ -61,14 +32,6 @@ describe("versionedSettings", () => { expect(compareSemver("2.0.0", "3.36.4")).toBeLessThan(0) }) - it("should handle versions with different segment counts", () => { - expect(compareSemver("1.0", "1.0.0")).toBe(0) - expect(compareSemver("1", "1.0.0")).toBe(0) - expect(compareSemver("1.0.0.0", "1.0.0")).toBe(0) - expect(compareSemver("1.0.1", "1.0")).toBeGreaterThan(0) - expect(compareSemver("1.0", "1.0.1")).toBeLessThan(0) - }) - it("should handle pre-release versions by ignoring pre-release suffix", () => { expect(compareSemver("3.36.4-beta.1", "3.36.4")).toBe(0) expect(compareSemver("3.36.4-rc.2", "3.36.4")).toBe(0) @@ -101,34 +64,69 @@ describe("versionedSettings", () => { }) }) - describe("resolveVersionedSettings", () => { - const currentVersion = "3.36.4" + describe("findHighestMatchingVersion", () => { + it("should return undefined when no versions match", () => { + const versionedSettings: VersionedSettings = { + "4.0.0": { includedTools: ["apply_diff"] }, + "5.0.0": { includedTools: ["apply_diff", "search_replace"] }, + } - it("should pass through non-versioned settings unchanged", () => { - const settings = { - includedTools: ["search_replace"], - excludedTools: ["apply_diff"], - supportsReasoningEffort: false, + const result = findHighestMatchingVersion(versionedSettings, "3.36.4") + expect(result).toBeUndefined() + }) + + it("should return the exact version when it matches", () => { + const versionedSettings: VersionedSettings = { + "3.36.4": { includedTools: ["apply_diff"] }, + "3.35.0": { includedTools: ["search_replace"] }, } - const resolved = resolveVersionedSettings(settings, currentVersion) + const result = findHighestMatchingVersion(versionedSettings, "3.36.4") + expect(result).toBe("3.36.4") + }) + + it("should return the highest version that is <= current version", () => { + const versionedSettings: VersionedSettings = { + "3.37.0": { includedTools: ["future_tool"] }, + "3.36.4": { includedTools: ["apply_diff"] }, + "3.35.0": { includedTools: ["search_replace"] }, + "3.34.0": { includedTools: ["basic_tool"] }, + } - expect(resolved).toEqual(settings) + // Current version is 3.36.5, should match 3.36.4 (highest <= 3.36.5) + const result = findHighestMatchingVersion(versionedSettings, "3.36.5") + expect(result).toBe("3.36.4") }) - it("should include versioned settings when version requirement is met", () => { - const settings = { - includedTools: { - value: ["search_replace"], - minPluginVersion: "3.36.4", - }, - excludedTools: { - value: ["apply_diff"], - minPluginVersion: "3.36.0", + it("should handle single version", () => { + const versionedSettings: VersionedSettings = { + "3.35.0": { includedTools: ["search_replace"] }, + } + + expect(findHighestMatchingVersion(versionedSettings, "3.36.4")).toBe("3.35.0") + expect(findHighestMatchingVersion(versionedSettings, "3.34.0")).toBeUndefined() + }) + + it("should handle empty versionedSettings", () => { + const versionedSettings: VersionedSettings = {} + + const result = findHighestMatchingVersion(versionedSettings, "3.36.4") + expect(result).toBeUndefined() + }) + }) + + describe("resolveVersionedSettings", () => { + const currentVersion = "3.36.4" + + it("should return settings for exact version match", () => { + const versionedSettings: VersionedSettings = { + "3.36.4": { + includedTools: ["search_replace"], + excludedTools: ["apply_diff"], }, } - const resolved = resolveVersionedSettings(settings, currentVersion) + const resolved = resolveVersionedSettings(versionedSettings, currentVersion) expect(resolved).toEqual({ includedTools: ["search_replace"], @@ -136,60 +134,56 @@ describe("versionedSettings", () => { }) }) - it("should exclude versioned settings when version requirement is not met", () => { - const settings = { - includedTools: { - value: ["search_replace"], - minPluginVersion: "3.36.5", // Higher than current + it("should return settings for highest matching version", () => { + const versionedSettings: VersionedSettings = { + "4.0.0": { + includedTools: ["future_tool"], + }, + "3.36.0": { + includedTools: ["search_replace"], + excludedTools: ["apply_diff"], }, - excludedTools: { - value: ["apply_diff"], - minPluginVersion: "4.0.0", // Higher than current + "3.35.0": { + includedTools: ["old_tool"], }, } - const resolved = resolveVersionedSettings(settings, currentVersion) + const resolved = resolveVersionedSettings(versionedSettings, currentVersion) - expect(resolved).toEqual({}) + expect(resolved).toEqual({ + includedTools: ["search_replace"], + excludedTools: ["apply_diff"], + }) }) - it("should handle mixed versioned and non-versioned settings", () => { - const settings = { - supportsReasoningEffort: false, // Non-versioned, should be included - includedTools: { - value: ["search_replace"], - minPluginVersion: "3.36.4", // Met, should be included + it("should return empty object when no versions match", () => { + const versionedSettings: VersionedSettings = { + "4.0.0": { + includedTools: ["future_tool"], }, - excludedTools: { - value: ["apply_diff"], - minPluginVersion: "4.0.0", // Not met, should be excluded + "3.37.0": { + includedTools: ["newer_tool"], }, - description: "A test model", // Non-versioned, should be included } - const resolved = resolveVersionedSettings(settings, currentVersion) + const resolved = resolveVersionedSettings(versionedSettings, currentVersion) - expect(resolved).toEqual({ - supportsReasoningEffort: false, - includedTools: ["search_replace"], - description: "A test model", - }) + expect(resolved).toEqual({}) }) - it("should handle empty settings object", () => { + it("should handle empty versionedSettings", () => { const resolved = resolveVersionedSettings({}, currentVersion) expect(resolved).toEqual({}) }) it("should handle versioned boolean values", () => { - const settings = { - supportsNativeTools: { - value: true, - minPluginVersion: "3.36.0", + const versionedSettings: VersionedSettings = { + "3.36.0": { + supportsNativeTools: true, }, } - const resolved = resolveVersionedSettings(settings, currentVersion) + const resolved = resolveVersionedSettings(versionedSettings, currentVersion) expect(resolved).toEqual({ supportsNativeTools: true, @@ -197,14 +191,13 @@ describe("versionedSettings", () => { }) it("should handle versioned null values", () => { - const settings = { - defaultTemperature: { - value: null, - minPluginVersion: "3.36.0", + const versionedSettings: VersionedSettings = { + "3.36.0": { + defaultTemperature: null, }, } - const resolved = resolveVersionedSettings(settings, currentVersion) + const resolved = resolveVersionedSettings(versionedSettings, currentVersion) expect(resolved).toEqual({ defaultTemperature: null, @@ -212,33 +205,59 @@ describe("versionedSettings", () => { }) it("should handle versioned nested objects", () => { - const settings = { - complexSetting: { - value: { nested: { deeply: true } }, - minPluginVersion: "3.36.0", + const versionedSettings: VersionedSettings = { + "3.36.0": { + complexSetting: { nested: { deeply: true } }, }, } - const resolved = resolveVersionedSettings(settings, currentVersion) + const resolved = resolveVersionedSettings(versionedSettings, currentVersion) expect(resolved).toEqual({ complexSetting: { nested: { deeply: true } }, }) }) - it("should correctly resolve settings with exact version match", () => { - const settings = { - feature: { - value: "enabled", - minPluginVersion: "3.36.4", // Exact match + it("should use all settings from the matching version", () => { + const versionedSettings: VersionedSettings = { + "3.36.4": { + includedTools: ["search_replace", "apply_diff"], + excludedTools: ["write_to_file"], + supportsReasoningEffort: true, + description: "Updated model", + }, + "3.35.0": { + includedTools: ["search_replace"], + description: "Old model", }, } - const resolved = resolveVersionedSettings(settings, "3.36.4") + const resolved = resolveVersionedSettings(versionedSettings, "3.36.4") expect(resolved).toEqual({ - feature: "enabled", + includedTools: ["search_replace", "apply_diff"], + excludedTools: ["write_to_file"], + supportsReasoningEffort: true, + description: "Updated model", }) }) + + it("should handle multiple versions and select correct one", () => { + const versionedSettings: VersionedSettings = { + "3.38.0": { feature: "very_new" }, + "3.37.0": { feature: "new" }, + "3.36.0": { feature: "current" }, + "3.35.0": { feature: "old" }, + "3.34.0": { feature: "very_old" }, + } + + // Test different current versions + expect(resolveVersionedSettings(versionedSettings, "3.40.0")).toEqual({ feature: "very_new" }) + expect(resolveVersionedSettings(versionedSettings, "3.37.5")).toEqual({ feature: "new" }) + expect(resolveVersionedSettings(versionedSettings, "3.36.5")).toEqual({ feature: "current" }) + expect(resolveVersionedSettings(versionedSettings, "3.35.5")).toEqual({ feature: "old" }) + expect(resolveVersionedSettings(versionedSettings, "3.34.5")).toEqual({ feature: "very_old" }) + expect(resolveVersionedSettings(versionedSettings, "3.33.0")).toEqual({}) + }) }) }) diff --git a/src/api/providers/fetchers/roo.ts b/src/api/providers/fetchers/roo.ts index f8377550bfb..65a2db77c39 100644 --- a/src/api/providers/fetchers/roo.ts +++ b/src/api/providers/fetchers/roo.ts @@ -4,7 +4,7 @@ import type { ModelRecord } from "../../../shared/api" import { parseApiPrice } from "../../../shared/cost" import { DEFAULT_HEADERS } from "../constants" -import { resolveVersionedSettings } from "./versionedSettings" +import { resolveVersionedSettings, type VersionedSettings } from "./versionedSettings" /** * Fetches available models from the Roo Code Cloud provider @@ -132,27 +132,31 @@ export async function getRooModels(baseUrl: string, apiKey?: string): Promise | undefined - const apiVersionedSettings = model.versionedSettings as Record | undefined + const apiVersionedSettings = model.versionedSettings as VersionedSettings | undefined // Start with base model info let modelInfo: ModelInfo = { ...baseModelInfo } - // Apply plain settings (backward compatible with old clients) - if (apiSettings) { - modelInfo = { ...modelInfo, ...(apiSettings as Partial) } - } - - // Apply versioned settings (new clients only - resolved based on plugin version) + // Try to resolve versioned settings first (finds highest version <= current plugin version) + // If versioned settings match, use them exclusively (they contain all necessary settings) + // Otherwise fall back to plain settings for backward compatibility if (apiVersionedSettings) { - const resolvedVersionedSettings = resolveVersionedSettings( - apiVersionedSettings, - ) as Partial - modelInfo = { ...modelInfo, ...resolvedVersionedSettings } + const resolvedVersionedSettings = resolveVersionedSettings>(apiVersionedSettings) + if (Object.keys(resolvedVersionedSettings).length > 0) { + // Versioned settings found - use them exclusively + modelInfo = { ...modelInfo, ...resolvedVersionedSettings } + } else if (apiSettings) { + // No matching versioned settings - fall back to plain settings + modelInfo = { ...modelInfo, ...(apiSettings as Partial) } + } + } else if (apiSettings) { + // No versioned settings at all - use plain settings + modelInfo = { ...modelInfo, ...(apiSettings as Partial) } } models[modelId] = modelInfo diff --git a/src/api/providers/fetchers/versionedSettings.ts b/src/api/providers/fetchers/versionedSettings.ts index 62148538b45..50ec0b89732 100644 --- a/src/api/providers/fetchers/versionedSettings.ts +++ b/src/api/providers/fetchers/versionedSettings.ts @@ -1,15 +1,11 @@ -import { z } from "zod" +import cmp from "semver-compare" import { Package } from "../../../shared/package" /** - * Schema for a versioned setting value. - * Allows settings to be gated behind a minimum plugin version. - * - * These values should be placed in the `versionedSettings` field of the API response, - * separate from the plain `settings` field. This ensures backward compatibility: - * - Old clients read from `settings` (plain values only) - * - New clients read from both `settings` and `versionedSettings` + * Type for versioned settings where the version is the key. + * Each version key maps to a settings object that should be used + * when the current plugin version is >= that version. * * Example API response: * ``` @@ -18,76 +14,34 @@ import { Package } from "../../../shared/package" * includedTools: ['search_replace'] // Plain value for old clients * }, * versionedSettings: { - * includedTools: { - * value: ['search_replace', 'apply_diff'], // Enhanced value for new clients - * minPluginVersion: '3.36.4', - * } + * '3.36.4': { + * includedTools: ['search_replace', 'apply_diff'], // Enhanced value for 3.36.4+ + * excludedTools: ['write_to_file'], + * }, + * '3.35.0': { + * includedTools: ['search_replace'], // Value for 3.35.0 - 3.36.3 + * }, * } * } * ``` + * + * The resolver will find the highest version key that is <= the current plugin version + * and use those settings. If no version matches, falls back to plain `settings`. */ -export const versionedValueSchema = (valueSchema: T) => - z.object({ - value: valueSchema, - minPluginVersion: z.string(), - }) - -/** - * Type for a versioned setting value. - */ -export type VersionedValue = { - value: T - minPluginVersion: string -} - -/** - * Type guard to check if a value is a versioned value object. - */ -export function isVersionedValue(value: unknown): value is VersionedValue { - return ( - typeof value === "object" && - value !== null && - "value" in value && - "minPluginVersion" in value && - typeof (value as VersionedValue).minPluginVersion === "string" - ) -} +export type VersionedSettings = Record> /** - * Compares two semantic version strings. + * Compares two semantic version strings using semver-compare. * * @param version1 First version string (e.g., "3.36.4") * @param version2 Second version string (e.g., "3.36.0") - * @returns negative if version1 < version2, 0 if equal, positive if version1 > version2 + * @returns -1 if version1 < version2, 0 if equal, 1 if version1 > version2 */ export function compareSemver(version1: string, version2: string): number { - // Parse version strings, handling potential pre-release tags - const parseVersion = (v: string): number[] => { - // Remove any pre-release suffix (e.g., "-beta.1", "-rc.2") - const baseVersion = v.split("-")[0] - return baseVersion.split(".").map((part) => { - const num = parseInt(part, 10) - return isNaN(num) ? 0 : num - }) - } - - const v1Parts = parseVersion(version1) - const v2Parts = parseVersion(version2) - - // Pad shorter array with zeros - const maxLength = Math.max(v1Parts.length, v2Parts.length) - while (v1Parts.length < maxLength) v1Parts.push(0) - while (v2Parts.length < maxLength) v2Parts.push(0) - - // Compare each component - for (let i = 0; i < maxLength; i++) { - const diff = v1Parts[i] - v2Parts[i] - if (diff !== 0) { - return diff - } - } - - return 0 + // Handle pre-release versions by stripping the suffix + // semver-compare doesn't handle pre-release properly + const stripPrerelease = (v: string): string => v.split("-")[0] + return cmp(stripPrerelease(version1), stripPrerelease(version2)) } /** @@ -102,35 +56,58 @@ export function meetsMinimumVersion(minPluginVersion: string, currentVersion: st } /** - * Resolves versioned settings by extracting values only when the current plugin - * version meets the minimum version requirement. + * Finds the highest version from versionedSettings that is <= the current plugin version. + * + * @param versionedSettings The versioned settings object with version keys + * @param currentVersion The current plugin version (defaults to Package.version) + * @returns The highest matching version key, or undefined if none match + */ +export function findHighestMatchingVersion( + versionedSettings: VersionedSettings, + currentVersion: string = Package.version, +): string | undefined { + const versions = Object.keys(versionedSettings) + + // Filter to versions that are <= currentVersion + const matchingVersions = versions.filter((version) => meetsMinimumVersion(version, currentVersion)) + + if (matchingVersions.length === 0) { + return undefined + } + + // Sort in descending order and return the highest + matchingVersions.sort((a, b) => compareSemver(b, a)) + return matchingVersions[0] +} + +/** + * Resolves versioned settings by finding the highest version that is <= the current + * plugin version and returning those settings. + * + * The versionedSettings structure uses version numbers as keys: + * ``` + * versionedSettings: { + * '3.36.4': { includedTools: ['search_replace'], excludedTools: ['apply_diff'] }, + * '3.35.0': { includedTools: ['search_replace'] }, + * } + * ``` * - * Settings can be either: - * - Direct values: `{ includedTools: ['search_replace'] }` - * - Versioned values: `{ includedTools: { value: ['search_replace'], minPluginVersion: '3.36.4' } }` + * This function finds the highest version key that is <= currentVersion and returns + * the corresponding settings object. If no version matches, returns an empty object. * - * @param settings The settings object with potentially versioned values + * @param versionedSettings The versioned settings object with version keys * @param currentVersion The current plugin version (defaults to Package.version) - * @returns A new settings object with versioned values resolved + * @returns The settings object for the highest matching version, or empty object if none match */ export function resolveVersionedSettings>( - settings: T, + versionedSettings: VersionedSettings, currentVersion: string = Package.version, ): Partial { - const resolved: Partial = {} + const matchingVersion = findHighestMatchingVersion(versionedSettings, currentVersion) - for (const [key, value] of Object.entries(settings)) { - if (isVersionedValue(value)) { - // Only include the setting if the version requirement is met - if (meetsMinimumVersion(value.minPluginVersion, currentVersion)) { - resolved[key as keyof T] = value.value as T[keyof T] - } - // If version requirement is not met, the setting is omitted - } else { - // Non-versioned values are included directly - resolved[key as keyof T] = value as T[keyof T] - } + if (!matchingVersion) { + return {} } - return resolved + return versionedSettings[matchingVersion] as Partial } diff --git a/src/package.json b/src/package.json index c1e38199aaf..e296d91f833 100644 --- a/src/package.json +++ b/src/package.json @@ -488,6 +488,7 @@ "safe-stable-stringify": "^2.5.0", "sanitize-filename": "^1.6.3", "say": "^0.16.0", + "semver-compare": "^1.0.0", "serialize-error": "^12.0.0", "shell-quote": "^1.8.2", "simple-git": "^3.27.0", @@ -524,6 +525,7 @@ "@types/node-ipc": "^9.2.3", "@types/proper-lockfile": "^4.1.4", "@types/ps-tree": "^1.1.6", + "@types/semver-compare": "^1.0.3", "@types/shell-quote": "^1.7.5", "@types/stream-json": "^1.7.8", "@types/string-similarity": "^4.0.2",