From 305a54bf37e8f0ae83a8e2829fc410994a7ecde3 Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Thu, 29 Oct 2026 10:54:17 +0100 Subject: [PATCH] feat(cli): resolve node, java and npm tools from the tool-versions data Co-Authored-By: Claude Opus 5.5 --- packages/base/src/tool-versions-schema.ts | 5 +- packages/base/src/tool-versions-types.ts | 3 +- src/cli/command/install-npm.spec.ts | 7 + src/cli/command/install-tool.spec.ts | 7 + src/cli/command/install-tool.ts | 10 +- src/cli/install-tool/index.spec.ts | 356 +++++++++- src/cli/install-tool/index.ts | 9 +- .../tool-version-resolver.service.spec.ts | 617 ++++++++++++++++++ .../tool-version-resolver.service.ts | 303 +++++++-- src/cli/install-tool/tool-version-resolver.ts | 36 + src/cli/services/tool-versions.service.ts | 7 +- src/cli/tools/java/resolver.ts | 9 +- src/cli/tools/node/resolver.spec.ts | 64 +- src/cli/tools/node/resolver.ts | 80 ++- src/cli/tools/python/pip.spec.ts | 15 + src/cli/tools/python/pip.ts | 14 +- src/cli/tools/python/poetry.spec.ts | 31 + src/cli/tools/python/poetry.ts | 71 +- src/cli/tools/ruby/utils.spec.ts | 14 + src/cli/tools/ruby/utils.ts | 16 +- 20 files changed, 1581 insertions(+), 93 deletions(-) diff --git a/packages/base/src/tool-versions-schema.ts b/packages/base/src/tool-versions-schema.ts index d494dd5ce6..2fe3ae534c 100644 --- a/packages/base/src/tool-versions-schema.ts +++ b/packages/base/src/tool-versions-schema.ts @@ -51,7 +51,10 @@ export const ToolVersion = z.object({ version: z.string().min(1), prerelease: z.literal(true).exactOptional(), lts: z.literal(true).exactOptional(), - /** the registry deprecates the version, only for npm tools */ + /** + * the registry deprecates the version: npm deprecated it, all its PyPI files + * are yanked, or the gem is gone from rubygems + */ deprecated: z.literal(true).exactOptional(), /** when the version was released, in UTC */ releaseTimestamp: z.iso.datetime().exactOptional(), diff --git a/packages/base/src/tool-versions-types.ts b/packages/base/src/tool-versions-types.ts index 89e8e79489..e3404725f3 100644 --- a/packages/base/src/tool-versions-types.ts +++ b/packages/base/src/tool-versions-types.ts @@ -50,7 +50,8 @@ export interface ToolVersion { prerelease?: true; lts?: true; /** - * The registry deprecates the version, only for npm tools. + * The registry deprecates the version: npm deprecated it, all its PyPI files + * are yanked, or the gem is gone from rubygems. */ deprecated?: true; /** diff --git a/src/cli/command/install-npm.spec.ts b/src/cli/command/install-npm.spec.ts index eacb2273f4..96247b29cc 100644 --- a/src/cli/command/install-npm.spec.ts +++ b/src/cli/command/install-npm.spec.ts @@ -35,6 +35,13 @@ describe('cli/command/install-npm', () => { vi.stubEnv('DEL_CLI_VERSION', '5.0.0'); expect(await cli.run(['del-cli'])).toBe(0); + // install-npm never reads the tool-versions data + expect(mocks.resolveVersion).toHaveBeenLastCalledWith( + 'del-cli', + '5.0.0', + 'npm', + false, + ); expect(mocks.installTool).toHaveBeenCalledTimes(2); expect(mocks.installTool).toHaveBeenCalledWith( 'del-cli', diff --git a/src/cli/command/install-tool.spec.ts b/src/cli/command/install-tool.spec.ts index 1fc767b285..6f8b5d3330 100644 --- a/src/cli/command/install-tool.spec.ts +++ b/src/cli/command/install-tool.spec.ts @@ -38,6 +38,13 @@ describe('cli/command/install-tool', () => { ); vi.stubEnv('NODE_VERSION', '16.13.0'); expect(await cli.run(['node'])).toBe(0); + // install-tool may read the tool-versions data + expect(mocks.resolveVersion).toHaveBeenLastCalledWith( + 'node', + '16.13.0', + undefined, + true, + ); expect(mocks.installTool).toHaveBeenCalledTimes(1); expect(mocks.installTool).toHaveBeenCalledWith( 'node', diff --git a/src/cli/command/install-tool.ts b/src/cli/command/install-tool.ts index 5f0ab8111f..d28ff7c44c 100644 --- a/src/cli/command/install-tool.ts +++ b/src/cli/command/install-tool.ts @@ -75,7 +75,15 @@ export class InstallToolCommand extends Command { logger.debug( `Try resolving version for ${this.name}@${version ?? 'latest'} ...`, ); - version = await resolveVersion(this.name, version, type); + // only `install-tool` reads the tool-versions data, `install-gem`, + // `install-npm` and `install-pip` set `this.type` and always ask their + // registry + version = await resolveVersion( + this.name, + version, + type, + this.type === undefined, + ); if (!isNonEmptyStringAndNotWhitespace(version)) { error = true; diff --git a/src/cli/install-tool/index.spec.ts b/src/cli/install-tool/index.spec.ts index 5f0474f332..9f3d60a9a2 100644 --- a/src/cli/install-tool/index.spec.ts +++ b/src/cli/install-tool/index.spec.ts @@ -1,8 +1,17 @@ import fs from 'node:fs/promises'; -import { beforeAll, describe, expect, test, vi } from 'vitest'; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from 'vitest'; import { IpcServer, PathService, + ToolVersionsService, VersionService, createContainer, } from '../services/index.ts'; @@ -25,6 +34,7 @@ import { uninstallTool, } from './index.ts'; import { V2ToolInstallService } from './install-legacy-tool.service.ts'; +import { scope } from '~test/http-mock.ts'; import { ensurePaths, rootPath } from '~test/path.ts'; vi.mock('del'); @@ -241,6 +251,350 @@ describe('cli/install-tool/index', () => { vi.spyOn(svc.prototype, 'resolve').mockResolvedValue('1.0.0'); expect(await resolveVersion('dummy', '1.0.0', type)).toBe('1.0.0'); }); + + test.each(['flux', 'pnpm', 'npm', 'yarn'])( + 'resolves install-npm %s with the registry, not the tool-versions data', + async (tool) => { + // the data has a file with the name of the package + await fs.mkdir(rootPath('usr/local/containerbase/tool-versions/v1'), { + recursive: true, + }); + await fs.writeFile( + rootPath(`usr/local/containerbase/tool-versions/v1/${tool}.json`), + JSON.stringify({ + tool, + updatedAt: '2026-10-16T00:00:00Z', + type: 'npm', + latest: '9.9.9', + versions: [{ version: '9.9.9' }], + }), + ); + const embedded = vi.spyOn(ToolVersionsService.prototype, 'getVersions'); + const data = vi.spyOn( + ToolVersionsService.prototype, + 'getResolveVersions', + ); + // the yarn resolver of install-tool is used for `yarn` too + vi.spyOn(NpmVersionResolver.prototype, 'resolve').mockResolvedValue( + '1.2.3', + ); + + // install-npm turns the data off + expect(await resolveVersion(tool, 'latest', 'npm', false)).toBe( + '1.2.3', + ); + + expect(embedded).not.toHaveBeenCalled(); + expect(data).not.toHaveBeenCalled(); + }, + ); + + test.each([ + { tool: 'php', version: '8.3', type: 'npm' as const }, + { tool: 'php', version: '8.3.10', type: 'npm' as const }, + { tool: 'mise', version: '2026.10', type: 'pip' as const }, + { tool: 'mise', version: '2026.10.21', type: 'pip' as const }, + ])( + 'keeps install-$type $tool $version without the tool-versions data', + async ({ tool, version, type }) => { + // the latest-only resolver of install-tool answers, it keeps any + // version but `latest` + const embedded = vi.spyOn(ToolVersionsService.prototype, 'getVersions'); + + expect(await resolveVersion(tool, version, type, false)).toBe(version); + + expect(embedded).not.toHaveBeenCalled(); + }, + ); + + describe('typed gem and pip tools', () => { + /** The embedded data, set before each test as the root dir comes from the test setup. */ + let dataDir!: string; + + /** + * Embeds the data of a tool. + * @param versions - the releases, newest first + * @param minCb - the minimum containerbase version ranges, if any + */ + async function embed( + tool: string, + versions: { version: string; deprecated?: true }[], + minCb?: { version: string; cb: string }[], + ): Promise { + await fs.writeFile( + `${dataDir}/v1/${tool}.json`, + JSON.stringify({ + tool, + updatedAt: '2026-10-16T00:00:00Z', + ...(minCb && { minCb }), + versions, + }), + ); + } + + /** + * Mocks the registry lookup of a gem or pip tool. + * @param partial - whether a partial version is looked up, else `latest` + * @param release - the release the registry has + */ + function registry( + tool: string, + type: 'gem' | 'pip', + partial: boolean, + release: string, + ): void { + if (type === 'pip') { + scope('https://pypi.org') + .get(`/pypi/${tool}/json`) + .reply(200, { + info: { version: release }, + releases: { + [release]: [{ packagetype: 'sdist', yanked: false }], + }, + }); + } else if (partial) { + scope('https://rubygems.org') + .get(`/api/v1/versions/${tool}.json`) + .reply(200, [{ number: release, prerelease: false }]); + } else { + scope('https://rubygems.org') + .get(`/api/v1/gems/${tool}.json`) + .reply(200, { version: release }); + } + } + + /** Releases with deprecated ones, like yanked or removed upstream. */ + const releases = [ + { version: '2.6.0', deprecated: true as const }, + { version: '2.5.1' }, + { version: '2.5.0' }, + { version: '2.4.1', deprecated: true as const }, + { version: '2.4.0' }, + ]; + + beforeEach(async () => { + dataDir = rootPath('usr/local/containerbase/tool-versions'); + await fs.rm(`${dataDir}/v1`, { recursive: true, force: true }); + await fs.mkdir(`${dataDir}/v1`, { recursive: true }); + // the embedded data is current, so the online data isn't read + await fs.writeFile( + `${dataDir}/release.json`, + JSON.stringify({ + version: '1.8.0', + buildTime: new Date(Date.now() - 60_000).toISOString(), + }), + ); + vi.stubGlobal('CONTAINERBASE_VERSION', '14.51.0'); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + await fs.rm(`${dataDir}/release.json`, { force: true }); + await fs.rm(`${dataDir}/v1`, { recursive: true, force: true }); + }); + + describe.each([ + { tool: 'bundler', type: 'gem' as const }, + { tool: 'uv', type: 'pip' as const }, + ])('$tool', ({ tool, type }) => { + test.each([ + // the newest release which isn't deprecated + { version: undefined, expected: '2.5.1' }, + { version: 'latest', expected: '2.5.1' }, + { version: '2.4', expected: '2.4.0' }, + // an existing release is kept, also a deprecated one + { version: '2.4.1', expected: '2.4.1' }, + ])( + 'resolves $version to $expected from the data', + async ({ version, expected }) => { + await embed(tool, releases); + + // no interceptor, a request would fail the test + expect(await resolveVersion(tool, version, type)).toBe(expected); + }, + ); + + test('asks the registry when only deprecated releases match', async () => { + await embed(tool, releases); + registry(tool, type, true, '2.6.2'); + + expect(await resolveVersion(tool, '2.6', type)).toBe('2.6.2'); + }); + + test('asks the registry without data', async () => { + registry(tool, type, false, '3.0.0'); + + expect(await resolveVersion(tool, 'latest', type)).toBe('3.0.0'); + }); + + test('warns and keeps an exact version which needs a newer containerbase', async () => { + await embed(tool, releases, [{ version: '2.5.0', cb: '15.0.0' }]); + + expect(await resolveVersion(tool, '2.5.0', type)).toBe('2.5.0'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + `${tool} 2.5.0 needs containerbase 15.0.0 or newer, running 14.51.0, 2.5.0 is used as is`, + ); + }); + + test('warns and asks the registry when every match needs a newer containerbase', async () => { + await embed(tool, releases, [{ version: '2.5.0', cb: '15.0.0' }]); + registry(tool, type, true, '2.5.1'); + + expect(await resolveVersion(tool, '2.5', type)).toBe('2.5.1'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + `${tool} 2.5.1 needs containerbase 15.0.0 or newer, running 14.51.0, the own lookup of the tool is used`, + ); + }); + + test('never reads the data for install-gem and install-pip', async () => { + await embed(tool, releases); + const embedded = vi.spyOn( + ToolVersionsService.prototype, + 'getVersions', + ); + const online = vi.spyOn( + ToolVersionsService.prototype, + 'getResolveVersions', + ); + registry(tool, type, false, '2.6.0'); + + expect(await resolveVersion(tool, 'latest', type, false)).toBe( + '2.6.0', + ); + + expect(embedded).not.toHaveBeenCalled(); + expect(online).not.toHaveBeenCalled(); + }); + }); + + /** LTS releases, newest first, like for node and java. */ + const ltsReleases = [ + { version: '25.0.0' }, + { version: '24.2.0', lts: true as const }, + { version: '22.1.0', lts: true as const }, + ]; + + test.each([ + { + tool: 'pnpm', + type: 'npm' as const, + extra: { type: 'npm', latest: '2.5.1' }, + versions: releases, + expected: '2.5.1', + }, + { + tool: 'bundler', + type: 'gem' as const, + extra: {}, + versions: releases, + expected: '2.5.1', + }, + { + tool: 'uv', + type: 'pip' as const, + extra: {}, + versions: releases, + expected: '2.5.1', + }, + { + tool: 'yarn', + type: undefined, + extra: { type: 'npm', latest: '2.5.1' }, + versions: releases, + expected: '2.5.1', + }, + { + tool: 'node', + type: undefined, + extra: { lts: true }, + versions: ltsReleases, + expected: '24.2.0', + }, + { + tool: 'java', + type: undefined, + extra: { lts: true }, + versions: ltsReleases, + expected: '24.2.0', + }, + ])( + 'uses the embedded data of $tool with a warning when the online data is not available', + async ({ tool, type, extra, versions, expected }) => { + // outdated embedded data, so the online data is looked up + await fs.rm(`${dataDir}/release.json`); + await fs.writeFile( + `${dataDir}/v1/${tool}.json`, + JSON.stringify({ + tool, + updatedAt: '2026-10-16T00:00:00Z', + ...extra, + versions, + }), + ); + scope('https://containerbase.github.io') + .get(`/tool-versions/v1/${tool}.json`) + .reply(404); + + // no own lookup interceptor, a request would fail the test + expect(await resolveVersion(tool, 'latest', type)).toBe(expected); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + { tool, version: 'latest', resolved: expected }, + 'online data not available (embedded, possibly outdated tool-versions data)', + ); + }, + ); + + describe('poetry', () => { + const mirror = { + info: { + version: '0.6.1', + requires_dist: ['poetry>=1.2.1,<2.0.0'], + }, + releases: {}, + }; + + test('limits latest from the data to the releases the mirror plugin supports', async () => { + await embed('poetry', [ + { version: '2.0.0' }, + { version: '1.8.4', deprecated: true }, + { version: '1.8.3' }, + ]); + scope('https://pypi.org') + .get('/pypi/poetry-plugin-pypi-mirror/json') + .reply(200, mirror); + + expect(await resolveVersion('poetry', 'latest', 'pip')).toBe('1.8.3'); + }); + + test('asks pypi when the data has no release the mirror plugin supports', async () => { + await embed('poetry', [{ version: '2.0.0' }]); + scope('https://pypi.org') + // the data filter and the pypi lookup share one plugin request + .get('/pypi/poetry-plugin-pypi-mirror/json') + .reply(200, mirror) + .get('/pypi/poetry/json') + .reply(200, { + info: { version: '2.0.0' }, + releases: { + '1.8.5': [{ packagetype: 'sdist', yanked: false }], + '2.0.0': [{ packagetype: 'sdist', yanked: false }], + }, + }); + + expect(await resolveVersion('poetry', 'latest', 'pip')).toBe('1.8.5'); + }); + + test('resolves a partial version from the data without the mirror plugin', async () => { + await embed('poetry', [{ version: '2.0.0' }, { version: '1.8.3' }]); + + // no interceptor, a request would fail the test + expect(await resolveVersion('poetry', '2', 'pip')).toBe('2.0.0'); + }); + }); + }); }); describe('linkTool', () => { diff --git a/src/cli/install-tool/index.ts b/src/cli/install-tool/index.ts index 69caf8a905..7c483c8826 100644 --- a/src/cli/install-tool/index.ts +++ b/src/cli/install-tool/index.ts @@ -453,12 +453,17 @@ export async function linkTool( /** * Resolves the version to install, eg. `latest` to a concrete version. For a - * `gem`, `npm` or `pip` type, a generic resolver is registered first. + * `gem`, `npm` or `pip` type, a generic resolver is registered after the tool + * resolvers, so a tool's own resolver wins. + * @param data - whether the resolvers may read the tool-versions data first: + * true for `install-tool`, false for `install-gem`, `install-npm` and + * `install-pip`, which always ask their registry */ export async function resolveVersion( tool: string, version: string | undefined, type?: InstallToolType, + data = true, ): Promise { const container = prepareResolveContainer(); @@ -468,7 +473,7 @@ export async function resolveVersion( .to(createGenericVersionResolver(type, tool)); } const svc = await container.getAsync(ToolVersionResolverService); - return svc.resolve(tool, version); + return svc.resolve(tool, version, data); } interface UninstallToolConfig { diff --git a/src/cli/install-tool/tool-version-resolver.service.spec.ts b/src/cli/install-tool/tool-version-resolver.service.spec.ts index 3dd5d9ff61..126cb54b7b 100644 --- a/src/cli/install-tool/tool-version-resolver.service.spec.ts +++ b/src/cli/install-tool/tool-version-resolver.service.spec.ts @@ -40,6 +40,54 @@ class LatestOnlyVersionResolver extends ToolVersionResolver { } } +@injectable() +@injectFromHierarchy() +class LtsDataVersionResolver extends ToolVersionResolver { + readonly tool = 'lts-data'; + override readonly dataField = 'lts'; + + /** Resolves any version to `9.9.9`, so its use is visible. */ + resolve(_version: string | undefined): Promise { + return Promise.resolve('9.9.9'); + } +} + +@injectable() +@injectFromHierarchy() +class NpmDataVersionResolver extends ToolVersionResolver { + readonly tool = 'npm-data'; + override readonly dataField = 'latest'; + + /** Resolves any version to `9.9.9`, so its use is visible. */ + resolve(_version: string | undefined): Promise { + return Promise.resolve('9.9.9'); + } +} + +@injectable() +@injectFromHierarchy() +class ExactDataVersionResolver extends ToolVersionResolver { + readonly tool = 'exact-data'; + override readonly dataField = 'lts'; + + /** Keeps the version, like node does for an exact one. */ + resolve(version: string | undefined): Promise { + return Promise.resolve(version); + } +} + +@injectable() +@injectFromHierarchy() +class BuildDataVersionResolver extends ToolVersionResolver { + readonly tool = 'build-data'; + override readonly dataField = 'lts'; + + /** Appends the build `+8`, like java does for an exact version. */ + resolve(version: string | undefined): Promise { + return Promise.resolve(`${version}+8`); + } +} + vi.mock('node:os', async (importOriginal) => ({ ...(await importOriginal()), arch: vi.fn(() => 'x64'), @@ -70,6 +118,7 @@ type Entry = | { version: string; lts?: true; + deprecated?: true; archs?: string[]; distros?: string[]; }; @@ -166,6 +215,10 @@ describe('cli/install-tool/tool-version-resolver.service', () => { child.bind(ToolVersionResolverService).toSelf(); child.bind(TOOL_VERSION_RESOLVER).to(DummyVersionResolver); child.bind(TOOL_VERSION_RESOLVER).to(LatestOnlyVersionResolver); + child.bind(TOOL_VERSION_RESOLVER).to(LtsDataVersionResolver); + child.bind(TOOL_VERSION_RESOLVER).to(NpmDataVersionResolver); + child.bind(TOOL_VERSION_RESOLVER).to(ExactDataVersionResolver); + child.bind(TOOL_VERSION_RESOLVER).to(BuildDataVersionResolver); svc = await child.getAsync(ToolVersionResolverService); }); @@ -173,6 +226,439 @@ describe('cli/install-tool/tool-version-resolver.service', () => { vi.unstubAllGlobals(); }); + describe('a resolver with an lts data field', () => { + /** Releases of a tool with LTS lines, newest first. */ + const lts: Entry[] = [ + '25.1.0', + '24.2.0-lts', + '24.1.0-lts', + '23.0.0', + '22.5.0-lts', + ]; + + beforeEach(async () => { + // the data is only read for a tool with an embedded file + await embed('lts-data', ['0.0.1']); + }); + + test.each([undefined, '', 'latest'])( + 'resolves %j to the newest lts release of the data', + async (version) => { + serve('lts-data', 200, { ...toolFile('lts-data', lts), lts: true }); + + expect(await svc.resolve('lts-data', version)).toBe('24.2.0'); + }, + ); + + test('resolves a partial version from the data', async () => { + serve('lts-data', 200, { ...toolFile('lts-data', lts), lts: true }); + + expect(await svc.resolve('lts-data', '23')).toBe('23.0.0'); + }); + + test('uses the resolver when the partial version has no match, without a warning', async () => { + serve('lts-data', 200, { ...toolFile('lts-data', lts), lts: true }); + + expect(await svc.resolve('lts-data', '26')).toBe('9.9.9'); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + test('uses the resolver when the online data has no lts field', async () => { + serve('lts-data', 200, lts); + + expect(await svc.resolve('lts-data', 'latest')).toBe('9.9.9'); + }); + + test('uses the embedded data with a warning when the online data is not available', async () => { + await writeFile( + `${dir}/lts-data.json`, + JSON.stringify({ ...toolFile('lts-data', lts), lts: true }), + ); + serve('lts-data', 404); + + expect(await svc.resolve('lts-data', 'latest')).toBe('24.2.0'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + { tool: 'lts-data', version: 'latest', resolved: '24.2.0' }, + 'online data not available (embedded, possibly outdated tool-versions data)', + ); + }); + + test('uses the resolver when the embedded data has no match and the online data is not available', async () => { + await writeFile( + `${dir}/lts-data.json`, + JSON.stringify({ ...toolFile('lts-data', lts), lts: true }), + ); + serve('lts-data', 404); + + expect(await svc.resolve('lts-data', '26')).toBe('9.9.9'); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + test('uses the embedded data when the online data is turned off', async () => { + vi.stubEnv('CONTAINERBASE_RESOLVER_OFFLINE', 'true'); + await writeFile( + `${dir}/lts-data.json`, + JSON.stringify({ ...toolFile('lts-data', lts), lts: true }), + ); + + expect(await svc.resolve('lts-data', 'latest')).toBe('24.2.0'); + }); + + test('uses the resolver when the embedded data has no lts field', async () => { + vi.stubEnv('CONTAINERBASE_RESOLVER_OFFLINE', 'true'); + await embed('lts-data', lts); + + expect(await svc.resolve('lts-data', '24')).toBe('9.9.9'); + }); + + test('skips the data of a tool without an embedded file', async () => { + await rm(`${dir}/lts-data.json`); + const online = vi.spyOn(HttpService.prototype, 'getJson'); + + expect(await svc.resolve('lts-data', undefined)).toBe('9.9.9'); + + expect(online).not.toHaveBeenCalled(); + }); + + test.each([undefined, '24'])( + 'skips the data for %j when it is turned off, like for install-npm', + async (version) => { + const data = vi.spyOn( + ToolVersionsService.prototype, + 'getResolveVersions', + ); + + expect(await svc.resolve('lts-data', version, false)).toBe('9.9.9'); + + expect(data).not.toHaveBeenCalled(); + }, + ); + + test.each([undefined, 'latest', '24'])( + 'warns and uses the resolver for %j when every match needs a newer containerbase', + async (version) => { + serve('lts-data', 200, { + ...toolFile('lts-data', lts), + lts: true, + minCb: [{ version: '22.5.0', cb: '15.0.0' }], + }); + + expect(await svc.resolve('lts-data', version)).toBe('9.9.9'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'lts-data 24.2.0 needs containerbase 15.0.0 or newer, running 14.51.0, the own lookup of the tool is used', + ); + }, + ); + + test.each([ + { + name: 'turned off online data', + setup: () => { + vi.stubEnv('CONTAINERBASE_RESOLVER_OFFLINE', 'true'); + return Promise.resolve(); + }, + // the embedded data may be months old then + note: ' (embedded, possibly outdated tool-versions data)', + }, + { + name: 'current embedded data', + setup: () => + writeFile( + `${dir}/../release.json`, + JSON.stringify({ + version: '1.8.0', + buildTime: new Date(Date.now() - 60_000).toISOString(), + }), + ), + note: '', + }, + ])( + 'notes the embedded data in the newer containerbase warning with $name', + async ({ setup, note }) => { + await setup(); + await writeFile( + `${dir}/lts-data.json`, + JSON.stringify({ + ...toolFile('lts-data', lts), + lts: true, + minCb: [{ version: '22.5.0', cb: '15.0.0' }], + }), + ); + + expect(await svc.resolve('lts-data', 'latest')).toBe('9.9.9'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + `lts-data 24.2.0 needs containerbase 15.0.0 or newer, running 14.51.0, the own lookup of the tool is used${note}`, + ); + }, + ); + + test('keeps a release the embedded data with the lts field lists', async () => { + await writeFile( + `${dir}/lts-data.json`, + JSON.stringify({ ...toolFile('lts-data', ['8.10']), lts: true }), + ); + + expect(await svc.resolve('lts-data', '8.10')).toBe('8.10'); + }); + + test('passes an exact version to the resolver', async () => { + expect(await svc.resolve('lts-data', '24.1.0')).toBe('9.9.9'); + }); + + test('warns and keeps an exact version the embedded data lists as needing a newer containerbase', async () => { + await embed( + 'exact-data', + ['26.13.0', '24.1.0'], + [{ version: '26.13.0', cb: '15.0.0' }], + ); + const online = vi.spyOn(HttpService.prototype, 'getJson'); + + expect(await svc.resolve('exact-data', '26.13.0')).toBe('26.13.0'); + + expect(online).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'exact-data 26.13.0 needs containerbase 15.0.0 or newer, running 14.51.0, 26.13.0 is used as is', + ); + }); + + test('warns about the version the resolver resolves an exact one to', async () => { + // the data only lists the builds, like for java + await embed( + 'build-data', + ['11.0.25+9', '11.0.24+8'], + [{ version: '11.0.24+8', cb: '15.0.0' }], + ); + + expect(await svc.resolve('build-data', '11.0.24')).toBe('11.0.24+8'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'build-data 11.0.24+8 needs containerbase 15.0.0 or newer, running 14.51.0, 11.0.24+8 is used as is', + ); + }); + + test('reads no data for an exact version when it is turned off, like for install-npm', async () => { + await embed( + 'exact-data', + ['26.13.0', '24.1.0'], + [{ version: '26.13.0', cb: '15.0.0' }], + ); + const embedded = vi.spyOn(ToolVersionsService.prototype, 'getVersions'); + + expect(await svc.resolve('exact-data', '26.13.0', false)).toBe('26.13.0'); + + expect(embedded).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + }); + + describe('npm tools', () => { + /** + * The data of an npm tool with the `latest` dist-tag. + * @param tool - the tool name + * @param list - the versions, newest first + * @param latest - the `latest` dist-tag + */ + function npmFile(tool: string, list: Entry[], latest: string): object { + return { ...toolFile(tool, list), type: 'npm', latest }; + } + + beforeEach(async () => { + // the data is only read for a tool with an embedded file + await embed('npm-data', ['0.0.1']); + }); + + test.each([undefined, 'latest'])( + 'resolves %j to the latest dist-tag, not the newest release', + async (version) => { + serve( + 'npm-data', + 200, + npmFile('npm-data', ['11.0.0', '10.9.0', '10.8.0'], '10.9.0'), + ); + + expect(await svc.resolve('npm-data', version)).toBe('10.9.0'); + }, + ); + + test.each([ + // the latest dist-tag wins over a newer release of the line + { version: '10', latest: '10.8.0', expected: '10.8.0' }, + // a deprecated latest dist-tag is skipped + { version: '10', latest: '10.9.0', expected: '10.8.0' }, + // a line without the latest dist-tag + { version: '9', latest: '10.8.0', expected: '9.2.0' }, + // only deprecated releases match, so the data has no match and the + // resolver answers + { version: '8', latest: '10.8.0', expected: '9.9.9' }, + ])( + 'resolves $version with the latest dist-tag $latest', + async ({ version, latest, expected }) => { + serve( + 'npm-data', + 200, + npmFile( + 'npm-data', + [ + { version: '10.9.0', deprecated: true }, + '10.8.0', + '9.3.0-rc1', + { version: '9.3.0', deprecated: true }, + '9.2.0', + { version: '8.1.0', deprecated: true }, + { version: '8.0.0', deprecated: true }, + ], + latest, + ), + ); + + expect(await svc.resolve('npm-data', version)).toBe(expected); + }, + ); + + test('uses the resolver when the data has no latest dist-tag', async () => { + serve('npm-data', 200, ['10.9.0']); + + expect(await svc.resolve('npm-data', '10')).toBe('9.9.9'); + }); + + test.each([ + { + name: 'needs a newer containerbase', + file: { + ...npmFile( + 'npm-data', + [ + '11.0.0', + '10.9.0', + { version: '10.8.0', deprecated: true }, + '10.7.0', + ], + '10.9.0', + ), + minCb: [{ version: '10.9.0', cb: '15.0.0' }], + }, + expected: '10.7.0', + }, + { + name: 'is not available for the host', + file: npmFile( + 'npm-data', + [{ version: '10.9.0', archs: ['arm64'] }, '10.8.0'], + '10.9.0', + ), + expected: '10.8.0', + }, + { + name: 'is deprecated', + file: npmFile( + 'npm-data', + [{ version: '10.9.0', deprecated: true }, '10.8.0'], + '10.9.0', + ), + expected: '10.8.0', + }, + { + name: 'is not installable and only deprecated releases are, so the resolver answers', + file: npmFile( + 'npm-data', + [ + { version: '10.9.0', archs: ['arm64'] }, + { version: '10.8.0', deprecated: true }, + ], + '10.9.0', + ), + expected: '9.9.9', + }, + ])( + 'resolves latest to the newest installable release when the dist-tag $name', + async ({ file, expected }) => { + serve('npm-data', 200, file); + + expect(await svc.resolve('npm-data', 'latest')).toBe(expected); + }, + ); + + test('resolves latest to a prerelease dist-tag', async () => { + serve( + 'npm-data', + 200, + npmFile('npm-data', ['11.0.0-rc1', '10.9.0'], '11.0.0-rc1'), + ); + + expect(await svc.resolve('npm-data', undefined)).toBe('11.0.0-rc1'); + }); + + test('warns and uses the resolver when the whole line needs a newer containerbase', async () => { + serve('npm-data', 200, { + ...npmFile('npm-data', ['10.9.0', '10.8.0', '9.0.0'], '9.0.0'), + minCb: [{ version: '10.8.0', cb: '15.0.0' }], + }); + + expect(await svc.resolve('npm-data', '10')).toBe('9.9.9'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'npm-data 10.9.0 needs containerbase 15.0.0 or newer, running 14.51.0, the own lookup of the tool is used', + ); + }); + + test('skips the data of a package without an embedded file', async () => { + await rm(`${dir}/npm-data.json`); + // no online mock: a request would fail the test + + expect(await svc.resolve('npm-data', '10')).toBe('9.9.9'); + }); + + test('resolves a tool without a resolver to the latest dist-tag', async () => { + serve('other', 200, npmFile('other', ['2.0.0', '1.9.0'], '1.9.0')); + + expect(await svc.resolve('other', 'latest')).toBe('1.9.0'); + }); + + test.each([ + { version: 'latest', expected: '1.9.0' }, + { version: '1', expected: '1.9.0' }, + ])( + 'resolves $version of a tool without a resolver to the deprecated latest dist-tag when every match is deprecated', + async ({ version, expected }) => { + serve( + 'other', + 200, + npmFile( + 'other', + [ + { version: '1.10.0', deprecated: true }, + { version: '1.9.0', deprecated: true }, + ], + '1.9.0', + ), + ); + + expect(await svc.resolve('other', version)).toBe(expected); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + { tool: 'other', version, resolved: expected }, + 'only deprecated releases match, the deprecated latest dist-tag is used', + ); + }, + ); + + test('keeps the no match of a resolver tool when only deprecated releases match', async () => { + serve( + 'npm-data', + 200, + npmFile('npm-data', [{ version: '1.9.0', deprecated: true }], '1.9.0'), + ); + + // the own lookup answers, not a deprecated release from the data + expect(await svc.resolve('npm-data', 'latest')).toBe('9.9.9'); + }); + }); + test('resolves with the matching resolver', async () => { expect(await svc.resolve('dummy', 'latest')).toBe('1.2.3'); expect(await svc.resolve('dummy', '1.0.0')).toBe('1.0.0'); @@ -218,6 +704,30 @@ describe('cli/install-tool/tool-version-resolver.service', () => { expect(await svc.resolve('latest-only', '1.2.3')).toBe('1.2.3'); }); + test.each(['2', '1.2.3'])( + 'lets a resolver which only resolves latest answer %s without the data', + async (version) => { + await embed('latest-only', ['2.1.0', '1.2.3']); + const data = vi.spyOn(ToolVersionsService.prototype, 'getVersions'); + + expect(await svc.resolve('latest-only', version, false)).toBe('9.9.9'); + + expect(data).not.toHaveBeenCalled(); + }, + ); + + test.each([undefined, '2', '1.2.3'])( + 'keeps %j of a tool without a resolver without the data', + async (version) => { + await embed('other', ['2.1.0', '1.2.3']); + const data = vi.spyOn(ToolVersionsService.prototype, 'getVersions'); + + expect(await svc.resolve('other', version, false)).toBe(version); + + expect(data).not.toHaveBeenCalled(); + }, + ); + test.each([undefined, 'latest'])( 'resolves %s of a tool without a resolver to the newest release', async (version) => { @@ -927,6 +1437,113 @@ describe('cli/install-tool/tool-version-resolver.service', () => { ); }); + test.each([ + { version: 'latest', expected: '2.0.0' }, + { version: '2', expected: '2.0.0' }, + ])( + 'skips the deprecated releases for $version of a tool without a resolver', + async ({ version, expected }) => { + serve('other', 200, [ + { version: '2.1.0', deprecated: true }, + '2.0.0', + '1.0.0', + ]); + + expect(await svc.resolve('other', version)).toBe(expected); + }, + ); + + test('keeps a partial version which is a deprecated release', async () => { + serve('other', 200, [{ version: '2.1', deprecated: true }, '2.0.0']); + + expect(await svc.resolve('other', '2.1')).toBe('2.1'); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + test.each([ + // a partial version: the newest deprecated release of the line + { version: '2.1', expected: '2.1.1' }, + // latest: the newest deprecated release + { version: 'latest', expected: '2.1.1' }, + ])( + 'resolves $version to the newest deprecated release with a warning when only those match', + async ({ version, expected }) => { + serve('other', 200, [ + '2.2.0-rc1', + { version: '2.1.1', deprecated: true }, + { version: '2.1.0', deprecated: true }, + ]); + + expect(await svc.resolve('other', version)).toBe(expected); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + { tool: 'other', version, resolved: expected }, + 'only deprecated releases match, the newest of them is used', + ); + }, + ); + + test('names the blocked release when a deprecated one is used instead', async () => { + serve( + 'other', + 200, + ['40.0.0', { version: '39.9.0', deprecated: true }], + [{ version: '40.0.0', cb: '15.0.0' }], + ); + + expect(await svc.resolve('other', 'latest')).toBe('39.9.0'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'other 40.0.0 needs containerbase 15.0.0 or newer, running 14.51.0, the deprecated 39.9.0 is used', + ); + }); + + test('notes the embedded data in the deprecated warning when the online data is turned off', async () => { + vi.stubEnv('CONTAINERBASE_RESOLVER_OFFLINE', 'true'); + await embed('other', [{ version: '2.1.0', deprecated: true }]); + + expect(await svc.resolve('other', 'latest')).toBe('2.1.0'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + { tool: 'other', version: 'latest', resolved: '2.1.0' }, + 'only deprecated releases match, the newest of them is used (embedded, possibly outdated tool-versions data)', + ); + }); + + test('names the newer containerbase when the only match is a deprecated release which needs it', async () => { + serve( + 'other', + 200, + [{ version: '2.0.0', deprecated: true }], + [{ version: '2.0.0', cb: '15.0.0' }], + ); + + expect(await svc.resolve('other', 'latest')).toBe('latest'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'other 2.0.0 needs containerbase 15.0.0 or newer, running 14.51.0, latest is used as is', + ); + }); + + test('names a blocked deprecated release when an older deprecated one is used instead', async () => { + serve( + 'other', + 200, + [ + { version: '2.1.0', deprecated: true }, + { version: '2.0.0', deprecated: true }, + ], + [{ version: '2.1.0', cb: '15.0.0' }], + ); + + expect(await svc.resolve('other', 'latest')).toBe('2.0.0'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'other 2.1.0 needs containerbase 15.0.0 or newer, running 14.51.0, the deprecated 2.0.0 is used', + ); + }); + test('warns about a newer containerbase when an invalid cb covers the release next to a valid one', async () => { serve( 'other', diff --git a/src/cli/install-tool/tool-version-resolver.service.ts b/src/cli/install-tool/tool-version-resolver.service.ts index 57a89b303d..b032e54c28 100644 --- a/src/cli/install-tool/tool-version-resolver.service.ts +++ b/src/cli/install-tool/tool-version-resolver.service.ts @@ -12,6 +12,8 @@ import type { ToolVersions } from '../../../packages/base/src/tool-versions-type import { EnvService, ToolVersionsService } from '../services/index.ts'; import { getDistro, logger } from '../utils/index.ts'; import { + type DataField, + type DataFilter, TOOL_VERSION_RESOLVER, type ToolVersionResolver, isPartialVersion, @@ -47,11 +49,13 @@ function hostDistros(data: ToolVersions, distro: string): string[] { } /** - * The release picked from the data, and the one picked without the `minCb` - * filter when that differs, as it needs a newer containerbase. + * The release picked from the data, whether it is a deprecated one, and the + * one picked without the `minCb` filter when that differs, as it needs a newer + * containerbase. */ interface Picked { version: string | undefined; + deprecated: boolean; blocked: string | undefined; } @@ -84,6 +88,28 @@ function newerCbWarning( return `${tool} ${version} needs ${newer}, running ${cb}, ${outcome}`; } +/** + * The warning for the release which the `minCb` filter skipped, see + * {@link newerCbWarning}. + * @param blocked - the skipped release, see {@link pick} + * @param outcome - what happens instead + */ +function blockedWarning( + tool: string, + data: ToolVersions, + blocked: string, + host: Host, + outcome: string, +): string { + return newerCbWarning( + tool, + blocked, + minCbByVersion(data).get(blocked), + host.cb, + outcome, + ); +} + /** * Picks a release from the data, prereleases and releases which are not * available for the host are skipped. A release is available when it has no @@ -95,16 +121,34 @@ function newerCbWarning( * release first, in the versioning of the tool. A missing version or `latest` * is the newest release, or the newest LTS release for a tool whose data has * LTS flags, even when none is available for the host, then there is no match. - * A partial version, a major (`20`) or major.minor (`3.12`) one, is the newest - * release of that line, unless it is a release itself, like `8.10`. - * @returns the picked release, `undefined` when none matches, and the release - * which the `minCb` filter skipped instead, if any + * For an npm tool, whose data has the `latest` dist-tag, it is that release, + * also a prerelease, like npm installs it. When the dist-tag is deprecated, + * isn't available for the host or installable by the running containerbase, + * it is the newest release which is, and isn't deprecated. A partial version, + * a major (`20`) or major.minor (`3.12`) one, is the newest release of that + * line, unless it is a release itself, like `8.10`. For an npm tool it is the + * `latest` dist-tag when it matches, else the newest release of the line. + * Every tool skips its deprecated releases for a missing version, `latest` + * and a partial one, like an npm deprecated release, a PyPI release whose + * files are all yanked or a gem gone from rubygems, so only deprecated matches + * are no match, unless `deprecatedFallback` picks them then; an existing + * release is kept, also a deprecated one. + * @param accept - limits the releases further, see + * {@link ToolVersionResolver.dataFilter} + * @param deprecatedFallback - whether only deprecated matches pick the newest + * of them, the deprecated `latest` dist-tag of an npm tool first, for a tool + * without its own resolver, which has no other lookup + * @returns the picked release, `undefined` when none matches, whether it is a + * deprecated one, and the release which the `minCb` filter skipped instead, + * if any, a deprecated one only when the fallback picks from all releases */ function pick( tool: string, data: ToolVersions, version: string | undefined, host: Host, + accept?: DataFilter, + deprecatedFallback = false, ): Picked { const { versions: installable, needed } = filterByMinCb(data, host.cb); const stable = data.versions.filter((v) => !v.prerelease); @@ -112,38 +156,92 @@ function pick( const forHost = (list: typeof data.versions): typeof data.versions => list.filter( (v) => - !v.prerelease && (!v.archs || v.archs.includes(host.arch)) && (!v.distros || v.distros.some((d) => distros.includes(d))), ); - /** Picks the requested release from the candidates, newest first. */ - const find = (releases: typeof data.versions): string | undefined => { + /** + * Picks the requested release from the candidates available for the host, + * newest first, which include the prereleases. + * @param deprecated - whether deprecated releases are picked too + */ + const find = ( + available: typeof data.versions, + deprecated = false, + ): string | undefined => { + const candidates = accept + ? available.filter((v) => accept(v.version)) + : available; + const releases = candidates.filter((v) => !v.prerelease); + // every tool skips its deprecated releases, like an npm deprecated one, a + // PyPI release whose files are all yanked or a gem gone from rubygems + const active = deprecated + ? releases + : releases.filter((v) => !v.deprecated); if (version === undefined || version === 'latest') { + if (data.latest !== undefined) { + // an npm tool: the `latest` dist-tag, also a prerelease, like npm + // installs it, unless it is deprecated or isn't installable, else the + // newest release which isn't deprecated + return ( + candidates.find( + (v) => v.version === data.latest && (deprecated || !v.deprecated), + ) ?? active[0] + )?.version; + } // the LTS flags are read before the host filter (arch, distro and // containerbase version), so a tool with LTS releases never falls back // to a release which isn't an LTS one return ( - stable.some((v) => v.lts) ? releases.find((v) => v.lts) : releases[0] + data.lts || stable.some((v) => v.lts) + ? active.find((v) => v.lts) + : active[0] )?.version; } + // an existing release is kept, also a deprecated one, like a pinned one if (releases.some((v) => v.version === version)) { return version; } - return releases.find((v) => matches(v.version, version))?.version; + const line = active.filter((v) => matches(v.version, version)); + // an npm tool, like npm: the `latest` dist-tag when it matches, else the + // newest release of the line + return (line.find((v) => v.version === data.latest) ?? line[0])?.version; }; - const picked = find(forHost(installable)); - const unfiltered = needed.length ? find(forHost(data.versions)) : picked; + /** + * Picks the installable release and the one the `minCb` filter skips + * instead, if any. + * @param deprecated - whether deprecated releases are picked too + */ + const run = ( + deprecated: boolean, + ): { version: string | undefined; blocked: string | undefined } => { + const version = find(forHost(installable), deprecated); + const unfiltered = needed.length + ? find(forHost(data.versions), deprecated) + : version; + return { + version, + blocked: unfiltered === version ? undefined : unfiltered, + }; + }; + + let picked = run(false); + let deprecated = false; + if (picked.version === undefined && deprecatedFallback) { + // nothing which isn't deprecated is installable, so pick again from all + // releases, also the deprecated ones + picked = run(true); + deprecated = picked.version !== undefined; + } // log only when the unmet ranges change the result of this request - if (unfiltered !== picked) { + if (picked.blocked !== undefined) { logger.debug( { tool, running: host.cb, needed }, 'skipping the versions which need a newer containerbase', ); - return { version: picked, blocked: unfiltered }; } - return { version: picked, blocked: undefined }; + return { ...picked, deprecated }; } /** @@ -157,8 +255,9 @@ const twoPartReleaseTools = new Set(['protoc', 'swift']); /** * Logs a warning about the tool-versions data, noting when it is the possibly * outdated embedded data. - * @param outdated - whether the data is the embedded fallback for online data - * which can't be loaded or is turned off, not current embedded data + * @param outdated - whether the data is the possibly outdated embedded data, + * the fallback for online data which can't be loaded or is turned off, not + * current embedded data * @param context - the tool and versions, for the log */ function warnData( @@ -185,8 +284,9 @@ function warnData( * {@link twoPartReleaseTools} whose line the data doesn't list at all is only * logged at debug level. * @param blocked - the release the `minCb` filter skipped, see {@link pick} - * @param embedded - whether the data is the possibly outdated embedded data, - * the fallback for online data which can't be loaded or used offline + * @param outdated - whether the data is the possibly outdated embedded data, + * the fallback for online data which can't be loaded or is turned off, not + * current embedded data */ function logNoMatch( tool: string, @@ -194,7 +294,7 @@ function logNoMatch( version: string | undefined, host: Host, blocked: string | undefined, - embedded: boolean, + outdated: boolean, ): void { const requested = version ?? 'latest'; if ( @@ -214,25 +314,18 @@ function logNoMatch( version === undefined ? 'no version is resolved' : `${version} is used as is`; - /** Logs the warning, naming the embedded data as its source. */ - const warn = (message: string): void => warnData(message, embedded); + /** Logs the warning, noting when the data may be outdated. */ + const warn = (message: string): void => warnData(message, outdated); if (blocked !== undefined) { - warn( - newerCbWarning( - tool, - blocked, - minCbByVersion(data).get(blocked), - host.cb, - outcome, - ), - ); + warn(blockedWarning(tool, data, blocked, host, outcome)); return; } const line = version === undefined || version === 'latest' ? data.versions : data.versions.filter((v) => matches(v.version, version)); + // a tool without its own resolver also picks a deprecated release, see pick if (line.some((v) => !v.prerelease)) { warn( `No ${tool} release matches ${requested} for ${host.arch} on ${host.distro}, ${outcome}`, @@ -265,18 +358,63 @@ export class ToolVersionResolverService { * is resolved from the tool-versions data, and keeps its version when the * data can't answer. A resolver which only resolves `latest` (see * {@link ToolVersionResolver.latestOnly}) gets a missing, empty or `latest` - * version, any other version is resolved from the data. + * version, any other version is resolved from the data. A resolver with a + * {@link ToolVersionResolver.dataField} resolves a missing, empty or + * `latest` version and a partial version from the data first, when the + * embedded data has a file for the tool, so an arbitrary package makes no + * request for data which doesn't exist, limited by its + * {@link ToolVersionResolver.dataFilter}. It only gets them when the data + * can't answer, and gets any other version. The version it resolves for an + * exact one, like the java build `11.0.24+8` for `11.0.24`, is checked + * against the `minCb` ranges of the embedded data, which only warns, see + * {@link checkExact}. + * @param data - whether the tool-versions data may be read at all, + * `install-gem`, `install-npm` and `install-pip` turn it off, so they always + * ask their registry: the resolver then answers any version, also a + * latest-only one, and a tool without a resolver keeps its version */ async resolve( tool: string, version: string | undefined, + data = true, ): Promise { const resolver = this.resolver.find((r) => r.tool === tool); const latest = !isNonEmptyStringAndNotWhitespace(version) || version === 'latest'; - if (resolver && (latest || !resolver.latestOnly)) { - return (await resolver.resolve(version)) ?? version; + if ( + data && + resolver?.dataField && + (latest || isPartialVersion(version)) && + (await this.toolVersions.getVersions(tool)) + ) { + const requested = latest ? undefined : version; + const resolved = await this.resolveFromData( + tool, + latest && version !== 'latest' ? undefined : version, + resolver.dataField, + await resolver.dataFilter(requested), + ); + if (resolved !== undefined) { + return resolved; + } + } + // without the data, the resolver answers any version, also a latest-only + // one, and a tool without a resolver keeps its version + if (resolver && (latest || !resolver.latestOnly || !data)) { + const resolved = (await resolver.resolve(version)) ?? version; + if (data && resolver.dataField && !latest && !isPartialVersion(version)) { + // the version the resolver answers is the one the data lists, eg. the + // java build `11.0.24+8` for `11.0.24`, it is never undefined here + // TODO: fail in the next major instead of only warning (#7962) + await this.checkExact(tool, resolved!); + } + return resolved; + } + if (!data) { + return version; } + // a latest-only resolver has no `dataField`, the two flags exclude each + // other, so its partial versions are resolved like for a tool without one return (await this.resolveFromData(tool, version)) ?? version; } @@ -289,19 +427,29 @@ export class ToolVersionResolverService { * {@link ToolVersionsService.getResolveVersions}: the online data, unless it * is turned off or the embedded data is current, else the embedded data, * which is only a possibly outdated fallback when the online data can't be - * loaded. A resolved version is logged at debug level, or with a warning - * when the embedded data replaces the online data, a version without a match - * is logged by {@link logNoMatch}. Only releases available for the host arch + * loaded, also for a tool with its own resolver. A resolved version is + * logged at debug level, or with a warning when the embedded data replaces + * the online data, a version without a match is logged by + * {@link logNoMatch}. Only releases available for the host arch * and distro, and installable by the running containerbase version * (`minCb`), are considered, also for the embedded release check. Only a * release version of containerbase filters by `minCb`, any other version, * like `0.0.0-PLACEHOLDER`, is a development build and doesn't. + * @param required - the field the data needs for the resolver of the tool, + * see {@link ToolVersionResolver.dataField}: data without it is skipped, as + * the resolver asks its own source; no match is only logged at debug level + * then, matching releases which all need a newer containerbase with a + * warning, and the resolver answers like before + * @param accept - the limits of the resolver of the tool on the releases, + * see {@link ToolVersionResolver.dataFilter} * @returns an exact version as given, else the resolved release, or * `undefined` when the data has no match or the tool has no data */ private async resolveFromData( tool: string, version: string | undefined, + required?: DataField, + accept?: DataFilter, ): Promise { const given = version !== undefined && version !== 'latest'; if (given && !isPartialVersion(version)) { @@ -309,6 +457,10 @@ export class ToolVersionResolverService { return version; } + /** Whether the data has the field the resolver of the tool needs. */ + const usable = (data: ToolVersions | undefined): data is ToolVersions => + !!data && (!required || data[required] !== undefined); + const host: Host = { arch: this.env.arch, distro: (await getDistro()).versionCode, @@ -316,7 +468,10 @@ export class ToolVersionResolverService { }; if (given) { const listed = await this.toolVersions.getVersions(tool); - if (listed && pick(tool, listed, version, host).version === version) { + if ( + usable(listed) && + pick(tool, listed, version, host, accept).version === version + ) { logger.debug( { tool, version, resolved: version }, 'version resolved from the embedded tool-versions data', @@ -330,20 +485,68 @@ export class ToolVersionResolverService { return undefined; } const { data, source } = found; - const { version: resolved, blocked } = pick(tool, data, version, host); - if (resolved === undefined) { - // the embedded data may be outdated when the online data was looked up - // or is turned off, not when it is current - logNoMatch( - tool, - data, - version, - host, - blocked, - source === 'fallback' || source === 'offline', + if (!usable(data)) { + logger.debug( + { tool, required }, + 'tool-versions data without the field, using the resolver', ); return undefined; } + // a tool without its own resolver has no other lookup, so it picks a + // deprecated release when nothing else matches + const { + version: resolved, + deprecated, + blocked, + } = pick(tool, data, version, host, accept, !required); + // the embedded data may be outdated when the online data was looked up or + // is turned off, not when it is current + const outdated = source === 'fallback' || source === 'offline'; + if (resolved === undefined) { + if (!required) { + logNoMatch(tool, data, version, host, blocked, outdated); + } else if (blocked === undefined) { + logger.debug( + { tool, version }, + 'no match in the tool-versions data, using the resolver', + ); + } else { + // TODO: fail in the next major instead of using the resolver (#7962) + warnData( + blockedWarning( + tool, + data, + blocked, + host, + 'the own lookup of the tool is used', + ), + outdated, + ); + } + return undefined; + } + // TODO: fail in the next major instead of using a deprecated release (#7962) + if (deprecated && blocked !== undefined) { + // a newer release needs a newer containerbase + warnData( + blockedWarning( + tool, + data, + blocked, + host, + `the deprecated ${resolved} is used`, + ), + outdated, + ); + } else if (deprecated) { + warnData( + resolved === data.latest + ? 'only deprecated releases match, the deprecated latest dist-tag is used' + : 'only deprecated releases match, the newest of them is used', + outdated, + { tool, version, resolved }, + ); + } if (source === 'fallback') { warnData('online data not available', true, { tool, diff --git a/src/cli/install-tool/tool-version-resolver.ts b/src/cli/install-tool/tool-version-resolver.ts index 9583e81de9..6d3a4520a2 100644 --- a/src/cli/install-tool/tool-version-resolver.ts +++ b/src/cli/install-tool/tool-version-resolver.ts @@ -5,6 +5,17 @@ import { EnvService, HttpService, lookupOptions } from '../services/index.ts'; export const TOOL_VERSION_RESOLVER = Symbol('TOOL_VERSION_RESOLVER'); +/** + * A field of the tool-versions data a resolver needs, see + * {@link ToolVersionResolver.dataField}: the tool-level `lts` or `latest` + * field, or `versions` for a tool which only needs its releases, which every + * tool file has. + */ +export type DataField = 'lts' | 'latest' | 'versions'; + +/** Checks a release of the tool-versions data, see {@link ToolVersionResolver.dataFilter}. */ +export type DataFilter = (version: string) => boolean; + /** The version note shared by the tools accepting major or major.minor versions. */ export const partialVersionHelp = 'A major or major.minor version installs the newest matching release.'; @@ -81,6 +92,31 @@ export abstract class ToolVersionResolver { // TODO: drop in the next major, when `latest` comes from the data too (#7962) readonly latestOnly: boolean = false; + /** + * The field of the tool-versions data the tool's rules for a missing + * version, `latest` and partial versions need, eg. `lts` for a tool whose + * `latest` is the newest LTS release, `latest` for an npm tool, `versions` + * for a gem or pip tool, whose `latest` is the newest release. When it is + * set, those versions are resolved from data with the field first, the + * online data or the embedded data, which replaces online data that can't + * be loaded with a warning, and the resolver only gets them when the data + * has no match. `undefined` resolves every version with the resolver. + */ + readonly dataField: DataField | undefined = undefined; + + /** + * Limits the releases of the tool-versions data the requested version may + * resolve to, for a tool with its own limits, like poetry, see + * {@link dataField}. Releases it rejects are skipped like releases which + * aren't available for the host. + * @param _version - the requested version, `undefined` for a missing one or + * `latest` + * @returns the check of a release, `undefined` without a limit + */ + dataFilter(_version: string | undefined): Promise { + return Promise.resolve(undefined); + } + /** * Resolves the requested version, eg. a missing version or `latest` to the * latest release. diff --git a/src/cli/services/tool-versions.service.ts b/src/cli/services/tool-versions.service.ts index a8f28a9676..403239730f 100644 --- a/src/cli/services/tool-versions.service.ts +++ b/src/cli/services/tool-versions.service.ts @@ -85,9 +85,10 @@ export class ToolVersionsService { * loaded, else the embedded data. A tool which only the online data has is * looked up online, unless it is turned off. Online data is final, the * embedded data is older and can't know more. The source tells whether the - * embedded data is current, see {@link ResolveVersions}. Only tools without - * their own resolver read it, so a tool without embedded data while the - * online data is turned off is logged as a warning, once per tool. + * embedded data is current, see {@link ResolveVersions}. A tool without + * embedded data while the online data is turned off is logged as a warning, + * once per tool; a tool with its own resolver only reads it when the image + * embeds data for it. * @returns `undefined` when the tool has no data */ async getResolveVersions(tool: string): Promise { diff --git a/src/cli/tools/java/resolver.ts b/src/cli/tools/java/resolver.ts index 63ddc08022..181e8c0d7a 100644 --- a/src/cli/tools/java/resolver.ts +++ b/src/cli/tools/java/resolver.ts @@ -30,9 +30,16 @@ export class JavaVersionResolver extends ToolVersionResolver { override readonly versionHelp = 'A major, major.minor or major.minor.patch version, like `21` or `11.0`, installs the newest matching release.'; + /** + * `latest` is the newest LTS release of the tool-versions data, a + * major.minor.patch version always uses the adoptium lookup. + */ + override readonly dataField = 'lts'; + /** * Resolves a missing version or `latest` to the newest adoptium lts and a - * partial version (`11`, `11.0` or `11.0.24`) to its newest ga release. + * partial version (`11`, `11.0` or `11.0.24`) to its newest ga release, + * when the tool-versions data can't answer, see {@link dataField}. * Any other version, like a full version with a build such as `17.0.12+7`, * is returned unchanged. * diff --git a/src/cli/tools/node/resolver.spec.ts b/src/cli/tools/node/resolver.spec.ts index 21af309eb8..966d9ee378 100644 --- a/src/cli/tools/node/resolver.spec.ts +++ b/src/cli/tools/node/resolver.spec.ts @@ -2,6 +2,7 @@ import { arch } from 'node:os'; import { injectFromHierarchy, injectable } from 'inversify'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { HttpService, lookupOptions } from '../../services/index.ts'; +import { logger } from '../../utils/index.ts'; import { NodeVersionResolver, NpmVersionResolver, @@ -212,7 +213,7 @@ describe('cli/tools/node/resolver', () => { expect(await svc.resolve('9')).toBe('9.1.0'); }); - test('falls back to the newest version if all matching versions are deprecated', async () => { + test('resolves to the newest deprecated release with a warning if all matching versions are deprecated', async () => { scope(registryUrl) .get('/pnpm') .reply(200, { @@ -226,6 +227,48 @@ describe('cli/tools/node/resolver', () => { const { svc } = await toolContext(PnpmVersionResolver); expect(await svc.resolve('9')).toBe('9.2.0'); + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + { tool: 'pnpm', version: '9', resolved: '9.2.0' }, + 'only deprecated releases match, the newest of them is used', + ); + }); + + test('resolves latest past a deprecated latest dist tag', async () => { + scope(registryUrl) + .get('/pnpm') + .reply(200, { + name: 'pnpm', + 'dist-tags': { latest: '9.3.0' }, + versions: { + '9.1.0': {}, + '9.2.0': {}, + '9.3.0': { deprecated: 'broken release' }, + '9.4.0-rc.1': {}, + }, + }); + const { svc } = await toolContext(PnpmVersionResolver); + + expect(await svc.resolve('latest')).toBe('9.2.0'); + }); + + test('keeps a deprecated latest dist tag with a warning when every release is deprecated', async () => { + scope(registryUrl) + .get('/pnpm') + .reply(200, { + name: 'pnpm', + 'dist-tags': { latest: '9.3.0' }, + versions: { + '9.2.0': { deprecated: 'broken release' }, + '9.3.0': { deprecated: 'broken release' }, + }, + }); + const { svc } = await toolContext(PnpmVersionResolver); + + expect(await svc.resolve('latest')).toBe('9.3.0'); + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + { tool: 'pnpm', latest: '9.3.0' }, + 'the latest dist tag is deprecated and no other release is, it is used anyway', + ); }); test('skips a deprecated latest dist tag', async () => { @@ -275,7 +318,8 @@ describe('cli/tools/node/resolver', () => { }); const { svc } = await toolContext(PnpmVersionResolver); - expect(await svc.resolve('latest')).toBe('9.4.0'); + // the deprecated latest dist tag is skipped + expect(await svc.resolve('latest')).toBe('9.3.0'); expect(await svc.resolve('9')).toBe('9.3.0'); }); }); @@ -289,6 +333,22 @@ describe('cli/tools/node/resolver', () => { expect(svc.tool).toBe('del-cli'); expect(await svc.resolve(undefined)).toBe('6.0.0'); }); + + test.each([ + // typed npm tools read the tool-versions data first + { tool: 'pnpm', dataField: 'latest' }, + { tool: 'npm', dataField: 'latest' }, + // any other package always uses the npm registry + { tool: 'del-cli', dataField: undefined }, + { tool: 'flux', dataField: undefined }, + ])( + 'reads the tool-versions data for $tool: $dataField', + ({ tool, dataField }) => { + const Resolver = createNpmVersionResolver(tool); + + expect(new Resolver().dataField).toBe(dataField); + }, + ); }); describe('YarnVersionResolver', () => { diff --git a/src/cli/tools/node/resolver.ts b/src/cli/tools/node/resolver.ts index 140148bc1c..6111716a65 100644 --- a/src/cli/tools/node/resolver.ts +++ b/src/cli/tools/node/resolver.ts @@ -5,7 +5,12 @@ import { isPartialVersion, partialVersionHelp, } from '../../install-tool/tool-version-resolver.ts'; -import { semverMaxSatisfying, semverSatisfies } from '../../utils/index.ts'; +import { + logger, + semverMaxSatisfying, + semverSatisfies, +} from '../../utils/index.ts'; +import { ResolverMap, getToolType } from '../index.ts'; import { yarnPackage } from './npm.ts'; import { type NodeVersionMeta, @@ -32,8 +37,12 @@ export class NodeVersionResolver extends ToolVersionResolver { override readonly versionHelp = partialVersionHelp; + /** `latest` is the newest LTS release of the tool-versions data. */ + override readonly dataField = 'lts'; + /** - * Resolves a version from nodejs.org. + * Resolves a version from nodejs.org, when the tool-versions data can't + * answer, see {@link dataField}. * * - A missing version or `latest` resolves to the newest lts release. * - A major (`20`) or major.minor (`20.11`) version resolves to the newest @@ -74,13 +83,17 @@ export abstract class NpmVersionResolver extends ToolVersionResolver { override readonly versionHelp = npmPartialVersionHelp; /** - * Resolves a version from the npm registry. + * Resolves a version from the npm registry, for a typed npm tool when the + * tool-versions data can't answer, see {@link dataField}. * - * - A missing version or `latest` resolves to the npm `latest` dist tag. + * - A missing version or `latest` resolves to the npm `latest` dist tag, + * unless it is deprecated, then to the newest release which is not + * deprecated. Without one, the deprecated dist tag is kept with a warning. * - A major (`9`) or major.minor (`9.15`) version resolves to the `latest` * dist tag if it matches and is not deprecated, otherwise to the newest - * matching release which is not deprecated, or the newest deprecated one - * if there is no other. Prereleases are skipped. + * matching release which is not deprecated. Prereleases are skipped. When + * only deprecated releases match, the newest of them is used with a + * warning, the next major fails then. * - Any other version, like a full `X.Y.Z`, is returned unchanged. * * @throws if a partial version matches no release. @@ -88,7 +101,25 @@ export abstract class NpmVersionResolver extends ToolVersionResolver { async resolve(version: string | undefined): Promise { if (!isNonEmptyStringAndNotWhitespace(version) || version === 'latest') { const meta = await this.getMeta(this.packageName(version)); - return meta['dist-tags'].latest; + const latest = meta['dist-tags'].latest; + if (!latest || !isDeprecated(meta.versions[latest])) { + return latest; + } + const active = semverMaxSatisfying( + Object.keys(meta.versions).filter( + (v) => !isDeprecated(meta.versions[v]), + ), + '*', + ); + if (!active) { + // TODO: fail in the next major (#7962) + logger.warn( + { tool: this.tool, latest }, + 'the latest dist tag is deprecated and no other release is, it is used anyway', + ); + return latest; + } + return active; } if (isPartialVersion(version)) { const meta = await this.getMeta(this.packageName(version)); @@ -103,12 +134,19 @@ export abstract class NpmVersionResolver extends ToolVersionResolver { return latest; } const all = Object.keys(meta.versions); - // like npm, fall back to deprecated versions if nothing else matches - const release = - semverMaxSatisfying( - all.filter((v) => !isDeprecated(meta.versions[v])), - version, - ) ?? semverMaxSatisfying(all, version); + const release = semverMaxSatisfying( + all.filter((v) => !isDeprecated(meta.versions[v])), + version, + ); + const deprecated = release ? null : semverMaxSatisfying(all, version); + if (deprecated) { + // TODO: fail in the next major (#7962) + logger.warn( + { tool: this.tool, version, resolved: deprecated }, + 'only deprecated releases match, the newest of them is used', + ); + return deprecated; + } if (!release) { // only mention prereleases when one would have matched const prerelease = all.some((v) => @@ -145,16 +183,24 @@ export abstract class NpmVersionResolver extends ToolVersionResolver { } /** - * Creates a version resolver for a tool which is a plain npm package. + * Creates a version resolver for a tool which is a plain npm package. Only a + * typed npm tool of the {@link ResolverMap}, like `npm` or `pnpm`, reads the + * tool-versions data first, also the embedded data when the online data can't + * be loaded, any other package installed with `install-npm` always uses the + * npm registry, as the data has no file for it. * @param tool - the tool and npm package name */ export function createNpmVersionResolver( tool: string, ): new () => NpmVersionResolver { + const typed = getToolType(ResolverMap, tool) === 'npm'; @injectable() @injectFromHierarchy() class GenericVersionResolver extends NpmVersionResolver { override readonly tool: string = tool; + + /** The `latest` dist-tag of the data, only for a typed npm tool. */ + override readonly dataField = typed ? ('latest' as const) : undefined; } return GenericVersionResolver; } @@ -164,6 +210,12 @@ export function createNpmVersionResolver( export class YarnVersionResolver extends NpmVersionResolver { readonly tool = 'yarn'; + /** + * `latest` is the `latest` dist-tag of the tool-versions data, which also + * flags the deprecated versions. + */ + override readonly dataField = 'latest'; + /** * The npm package of the requested yarn major version, see * {@link yarnPackage}. `latest` uses the `@yarnpkg/cli-dist` dist tags. diff --git a/src/cli/tools/python/pip.spec.ts b/src/cli/tools/python/pip.spec.ts index 95bd03e953..54578e6bc6 100644 --- a/src/cli/tools/python/pip.spec.ts +++ b/src/cli/tools/python/pip.spec.ts @@ -139,4 +139,19 @@ describe('cli/tools/python/pip', () => { expect(svc.versionHelp).toBe(partialVersionHelp); expect(await svc.resolve('latest')).toBe('3.2.1'); }); + + test.each([ + // a typed pip tool reads the tool-versions data first + { tool: 'checkov', dataField: 'versions' }, + // any other package always asks pypi + { tool: 'requests', dataField: undefined }, + ])( + 'createPipVersionResolver reads the data for $tool: $dataField', + async ({ tool, dataField }) => { + const { svc } = await toolContext(createPipVersionResolver(tool)); + + expect(svc.dataField).toBe(dataField); + expect(await svc.dataFilter(undefined)).toBeUndefined(); + }, + ); }); diff --git a/src/cli/tools/python/pip.ts b/src/cli/tools/python/pip.ts index b78af9591b..8e709cb46c 100644 --- a/src/cli/tools/python/pip.ts +++ b/src/cli/tools/python/pip.ts @@ -6,6 +6,7 @@ import { partialVersionHelp, } from '../../install-tool/tool-version-resolver.ts'; import { logger } from '../../utils/index.ts'; +import { ResolverMap, getToolType } from '../index.ts'; import { PypiJson } from './schema.ts'; @injectable() @@ -13,7 +14,8 @@ export abstract class PipVersionResolver extends ToolVersionResolver { override readonly versionHelp = partialVersionHelp; /** - * Resolves a version from pypi. + * Resolves a version from pypi, for a typed pip tool when the tool-versions + * data can't answer, see {@link dataField}. * * - A missing version or `latest` resolves to the latest release. * - A major (`21`) or major.minor (`21.3`) version which is no existing @@ -71,16 +73,24 @@ export abstract class PipVersionResolver extends ToolVersionResolver { } /** - * Creates a version resolver for a tool which is a plain pip package. + * Creates a version resolver for a tool which is a plain pip package. Only a + * typed pip tool of the {@link ResolverMap}, like `uv` or `pipenv`, reads the + * tool-versions data first, also the embedded data when the online data can't + * be loaded, any other package installed with `install-pip` + * always uses pypi, as the data has no file for it. * @param tool - the tool and pypi package name */ export function createPipVersionResolver( tool: string, ): new () => PipVersionResolver { + const typed = getToolType(ResolverMap, tool) === 'pip'; @injectable() @injectFromHierarchy() class GenericVersionResolver extends PipVersionResolver { override readonly tool: string = tool; + + /** The releases of the data, only for a typed pip tool. */ + override readonly dataField = typed ? ('versions' as const) : undefined; } return GenericVersionResolver; } diff --git a/src/cli/tools/python/poetry.spec.ts b/src/cli/tools/python/poetry.spec.ts index 7b239019be..d6dd9a446d 100644 --- a/src/cli/tools/python/poetry.spec.ts +++ b/src/cli/tools/python/poetry.spec.ts @@ -113,4 +113,35 @@ describe('cli/tools/python/poetry', () => { expect(await svc.resolve('1.8.3')).toBe('1.8.3'); }); + + test('limits latest from the data to the releases the mirror plugin supports', async () => { + scope(baseUrl) + .get('/pypi/poetry-plugin-pypi-mirror/json') + .reply(200, mirrorMeta); + const { svc } = await toolContext(PoetryVersionResolver); + + const accept = await svc.dataFilter(undefined); + + expect(svc.dataField).toBe('versions'); + expect(accept?.('1.8.3')).toBe(true); + expect(accept?.('2.0.0')).toBe(false); + }); + + test('does not limit a partial version from the data', async () => { + // no interceptor, a request would fail the test + const { svc } = await toolContext(PoetryVersionResolver); + + expect(await svc.dataFilter('1.8')).toBeUndefined(); + }); + + test('throws for latest from the data without a poetry constraint', async () => { + scope(baseUrl) + .get('/pypi/poetry-plugin-pypi-mirror/json') + .reply(200, { info: { version: '0.6.1' }, releases: {} }); + const { svc } = await toolContext(PoetryVersionResolver); + + await expect(svc.dataFilter(undefined)).rejects.toThrow( + 'poetry-plugin-pypi-mirror has missing poetry version', + ); + }); }); diff --git a/src/cli/tools/python/poetry.ts b/src/cli/tools/python/poetry.ts index dba2a658d5..8c92bca327 100644 --- a/src/cli/tools/python/poetry.ts +++ b/src/cli/tools/python/poetry.ts @@ -1,5 +1,6 @@ -import { maxSatisfying } from '@renovatebot/pep440'; +import { maxSatisfying, satisfies } from '@renovatebot/pep440'; import { injectFromHierarchy, injectable } from 'inversify'; +import type { DataFilter } from '../../install-tool/tool-version-resolver.ts'; import { logger } from '../../utils/index.ts'; import { PipVersionResolver } from './pip.ts'; @@ -8,10 +9,36 @@ import { PipVersionResolver } from './pip.ts'; export class PoetryVersionResolver extends PipVersionResolver { override tool = 'poetry'; + /** The releases of the data, like for the other typed pip tools. */ + override readonly dataField = 'versions'; + /** + * The lookup of the poetry range of `poetry-plugin-pypi-mirror`, made once + * per instance, so {@link dataFilter} and {@link resolve} share it. + */ + private range: Promise | undefined; + + /** + * Limits a missing version or `latest` from the tool-versions data to the + * poetry releases `poetry-plugin-pypi-mirror` supports, like + * {@link resolve} does, a partial version isn't limited. + * + * @throws when the plugin has no poetry requirement + */ + override async dataFilter( + version: string | undefined, + ): Promise { + if (version !== undefined) { + return undefined; + } + const range = await this.mirrorRange(); + return (v) => satisfies(v, range); + } + /** * Resolves a missing version or `latest` to the newest poetry release - * supported by `poetry-plugin-pypi-mirror`. A partial version resolves to - * the newest matching release, regardless of the plugin. + * supported by `poetry-plugin-pypi-mirror`, when the tool-versions data + * can't answer, see {@link dataField}. A partial version resolves to the + * newest matching release, regardless of the plugin. * * @throws when the plugin has no poetry requirement */ @@ -19,15 +46,7 @@ export class PoetryVersionResolver extends PipVersionResolver { version: string | undefined, ): Promise { if (version === undefined || version === 'latest') { - const mirrorMeta = await this.fetchMeta('poetry-plugin-pypi-mirror'); - logger.debug({ info: mirrorMeta.info }, 'poetry-plugin-pypi-mirror'); - - const poetryVersion = mirrorMeta.info.requires_dist?.poetry; - - if (!poetryVersion) { - throw new Error('poetry-plugin-pypi-mirror has missing poetry version'); - } - + const poetryVersion = await this.mirrorRange(); const meta = await this.fetchMeta(this.tool); const version = maxSatisfying( Object.entries(meta.releases) @@ -41,4 +60,32 @@ export class PoetryVersionResolver extends PipVersionResolver { } return super.resolve(version); } + + /** + * The poetry version range `poetry-plugin-pypi-mirror` supports, looked up + * once per instance, see {@link fetchMirrorRange}. + * + * @throws when the plugin has no poetry requirement + */ + private mirrorRange(): Promise { + return (this.range ??= this.fetchMirrorRange()); + } + + /** + * Fetches the poetry version range `poetry-plugin-pypi-mirror` supports from + * pypi. + * + * @throws when the plugin has no poetry requirement + */ + private async fetchMirrorRange(): Promise { + const mirrorMeta = await this.fetchMeta('poetry-plugin-pypi-mirror'); + logger.debug({ info: mirrorMeta.info }, 'poetry-plugin-pypi-mirror'); + + const poetryVersion = mirrorMeta.info.requires_dist?.poetry; + + if (!poetryVersion) { + throw new Error('poetry-plugin-pypi-mirror has missing poetry version'); + } + return poetryVersion; + } } diff --git a/src/cli/tools/ruby/utils.spec.ts b/src/cli/tools/ruby/utils.spec.ts index 15b84b3397..34c0a3313b 100644 --- a/src/cli/tools/ruby/utils.spec.ts +++ b/src/cli/tools/ruby/utils.spec.ts @@ -365,5 +365,19 @@ describe('cli/tools/ruby/utils', () => { expect(svc.tool).toBe('cocoapods'); expect(await svc.resolve('latest')).toBe('1.16.2'); }); + + test.each([ + // a typed gem tool reads the tool-versions data first + { tool: 'bundler', dataField: 'versions' }, + // any other gem always asks rubygems.org + { tool: 'rake', dataField: undefined }, + ])( + 'createGemVersionResolver reads the data for $tool: $dataField', + async ({ tool, dataField }) => { + const { svc } = await toolContext(createGemVersionResolver(tool)); + + expect(svc.dataField).toBe(dataField); + }, + ); }); }); diff --git a/src/cli/tools/ruby/utils.ts b/src/cli/tools/ruby/utils.ts index 2c94b0111a..472fee7be2 100644 --- a/src/cli/tools/ruby/utils.ts +++ b/src/cli/tools/ruby/utils.ts @@ -13,6 +13,7 @@ import { } from '../../install-tool/tool-version-resolver.ts'; import { VersionService } from '../../services/index.ts'; import { logger } from '../../utils/index.ts'; +import { ResolverMap, getToolType } from '../index.ts'; import { RubyGemJson, RubyGemVersionsJson } from './schema.ts'; const defaultRegistry = 'https://rubygems.org/'; @@ -194,8 +195,9 @@ export abstract class RubyGemVersionResolver extends ToolVersionResolver { override readonly versionHelp = partialVersionHelp; /** - * Resolves a version from rubygems.org, a configured gem registry is not - * used for the lookup. Like other lookups, the request goes through the + * Resolves a version from rubygems.org, for a typed gem tool when the + * tool-versions data can't answer, see {@link dataField}. A configured gem + * registry is not used for the lookup. Like other lookups, the request goes through the * configured CDN and URL replacements. * * - A missing version or `latest` resolves to the latest release. @@ -250,16 +252,24 @@ export abstract class RubyGemVersionResolver extends ToolVersionResolver { } /** - * Creates a version resolver for a tool which is a plain gem. + * Creates a version resolver for a tool which is a plain gem. Only a typed gem + * tool of the {@link ResolverMap}, like `bundler`, reads the tool-versions + * data first, also the embedded data when the online data can't be loaded, + * any other gem installed with `install-gem` always uses + * rubygems.org, as the data has no file for it. * @param tool - the tool and gem name */ export function createGemVersionResolver( tool: string, ): new () => RubyGemVersionResolver { + const typed = getToolType(ResolverMap, tool) === 'gem'; @injectable() @injectFromHierarchy() class GenericVersionResolver extends RubyGemVersionResolver { override readonly tool: string = tool; + + /** The releases of the data, only for a typed gem tool. */ + override readonly dataField = typed ? ('versions' as const) : undefined; } return GenericVersionResolver; }