Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ Some tools accept partial versions and install a matching release:
A major or major.minor version resolves to the newest matching release even when pip would read it as `X.Y.0`, eg. `poetry 1.8` installs the newest `1.8.x`, not `1.8.0`; pin the full version for an exact one.
Without a version, or with `latest`, the latest release is installed.
For `poetry` the latest release is the newest one supported by `poetry-plugin-pypi-mirror`, a partial version ignores that limit.
- `android-sdk-cmdline-tools`: A major (`14`) or major.minor (`14.1`) version installs the newest matching stable release, compared numerically.
A version which exists as a release, like `13`, is kept; the repository lists versions without zero segments, so `13.0` installs the release listed as `13`.
The lookup needs the android repository, which the install needs too, so it fails when the repository can't be loaded.
Without a version, or with `latest`, the `latest` package is installed.
- `cocoapods`, `bundler` and any package installed with [`install-gem`](#install-gem-command): A major (`1`) or major.minor (`1.16`) version installs the newest matching release, prereleases are skipped.
A version which exists as a release, like `5.2`, is kept.
Other major or major.minor versions resolve to the newest matching release even when RubyGems would read them as `X.Y.0`, eg. `bundler 2.0` installs the newest `2.0.x`, not `2.0.0`; pin the full version for an exact one.
The lookup goes to rubygems.org, through the CDN and URL replacements like other lookups, and a configured gem registry is only used for the install, so a gem from a private registry needs a full version, and a partial version fails when rubygems.org can't be reached.
Without a version, or with `latest`, the latest release is installed.
- `nuget`: A major (`6`) or major.minor (`6.11`) version installs the newest matching released and blessed release.
A version which exists in the nuget feed is kept, and so is the version when `tools.json` can't be loaded.
Without a version, or with `latest`, the newest released and blessed release is installed.
Expand Down Expand Up @@ -155,6 +164,14 @@ Note: requires ruby to be installed.
```bash
install-gem rake
```
- Installs the newest rake 13 release
```bash
install-gem rake 13
```

#### Tool-specific versions <a name="Versions-install-gem"></a>

A major or major.minor version installs the newest matching release, see the [install-tool versions](#Versions-install-tool).

<br>

Expand Down
7 changes: 7 additions & 0 deletions src/cli/command/install-gem.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Cli } from 'clipanion';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { partialVersionHelp } from '../install-tool/tool-version-resolver.ts';
import { MissingVersion } from '../utils/codes.ts';
import { registerCommands } from './index.ts';
import { InstallGemCommand, InstallGemShortCommand } from './install-gem.ts';

const mocks = vi.hoisted(() => ({
installTool: vi.fn(),
Expand All @@ -17,6 +19,11 @@ describe('cli/command/install-gem', () => {
vi.stubEnv('RAKE_VERSION', undefined);
});

test('describes partial versions', () => {
expect(InstallGemCommand.usage?.details).toBe(partialVersionHelp);
expect(InstallGemShortCommand.usage?.details).toBe(partialVersionHelp);
});

test('install-gem', async () => {
const cli = new Cli({ binaryName: 'install-gem' });
registerCommands(cli, 'install-gem');
Expand Down
5 changes: 5 additions & 0 deletions src/cli/command/install-gem.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from 'clipanion';
import { partialVersionHelp } from '../install-tool/tool-version-resolver.ts';
import { InstallToolCommand } from './install-tool.ts';
import { command } from './utils.ts';

Expand All @@ -14,7 +15,9 @@ export class InstallGemCommand extends InstallToolCommand {
'RAKE_VERSION=13.0.6 $0 install gem rake',
],
// ['Installs latest rake version', '$0 install gem rake'], // not yet supported
['Installs the newest rake 13 release', '$0 install gem rake 13'],
],
details: partialVersionHelp,
});

protected override type = 'gem' as const;
Expand All @@ -33,6 +36,8 @@ export class InstallGemShortCommand extends InstallGemCommand {
'RAKE_VERSION=13.0.6 $0 rake',
],
// ['Installs latest rake version', '$0 rake'], // not yet supported
['Installs the newest rake 13 release', '$0 rake 13'],
],
details: partialVersionHelp,
});
}
26 changes: 26 additions & 0 deletions src/cli/install-tool/tool-version-resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, test } from 'vitest';
import { newestVersion } from './tool-version-resolver.ts';

describe('cli/install-tool/tool-version-resolver', () => {
describe('newestVersion', () => {
test('returns undefined without versions', () => {
expect(newestVersion([])).toBeUndefined();
});

test.each([
{ versions: ['6.1.7', '6.1.7.9', '6.1.7.10'], expected: '6.1.7.10' },
{ versions: ['6.1.7.10', '6.1.7.9', '6.1.7'], expected: '6.1.7.10' },
{ versions: ['6.1.7.9', '6.1.7.10', '6.1.7'], expected: '6.1.7.10' },
{ versions: ['2.9.1', '2.10.0', '2.5.11'], expected: '2.10.0' },
{ versions: ['4.8', '4.9', '4.8.1'], expected: '4.9' },
{ versions: ['1.0.0.pre2', '1.0.0.pre10'], expected: '1.0.0.pre10' },
])('picks $expected from $versions', ({ versions, expected }) => {
expect(newestVersion(versions)).toBe(expected);
});

test('treats a missing segment as 0 and keeps the first of equal versions', () => {
expect(newestVersion(['6.1', '6.1.0'])).toBe('6.1');
expect(newestVersion(['6.1.0', '6.1'])).toBe('6.1.0');
});
});
});
42 changes: 42 additions & 0 deletions src/cli/install-tool/tool-version-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,48 @@ export function isPartialVersion(version: string): boolean {
return /^\d+(\.\d+)?$/.test(version);
}

/**
* Compares two versions segment by segment, a missing segment counts as `0`,
* so `4.9` equals `4.9.0`. Segments are compared numerically, a non-numeric
* one is compared as text, with the digits in it compared as numbers.
* @returns a negative number if `a` is older, a positive one if it is newer,
* else `0`
*/
function compareSegments(a: string, b: string): number {
const left = a.split('.');
const right = b.split('.');
const length = Math.max(left.length, right.length);
for (let i = 0; i < length; i++) {
const diff = (left[i] ?? '0').localeCompare(right[i] ?? '0', 'en', {
numeric: true,
});
if (diff !== 0) {
return diff;
}
}
return 0;
}

/**
* The newest of the versions, independent of their order. Dot-separated
* segments are compared one by one, so `6.1.7.10` is newer than `6.1.7.9`,
* which is newer than `6.1.7`, and entries like `4.9` count as `4.9.0`. Of
* equal versions the first one wins.
*
* Expects release versions only: a prerelease like `1.0.0.rc1` would rank
* above its release `1.0.0`, so callers filter prereleases out first.
* @returns the newest version as listed, or `undefined` without any
*/
export function newestVersion(versions: string[]): string | undefined {
let result: string | undefined;
for (const version of versions) {
if (!result || compareSegments(version, result) > 0) {
result = version;
}
}
return result;
}

@injectable()
export abstract class ToolVersionResolver {
abstract readonly tool: string;
Expand Down
2 changes: 1 addition & 1 deletion src/cli/install-tool/version-resolvers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe('cli/install-tool/version-resolvers', () => {
expect(getVersionHelp()).toBe(codeBlock`
Some tools accept partial versions:

- \`checkov\`, \`conan\`, \`copier\`, \`hashin\`, \`kas\`, \`node\`, \`nuget\`, \`pdm\`, \`pip-tools\`, \`pipenv\`, \`poetry\`, \`uv\`: A major or major.minor version installs the newest matching release.
- \`android-sdk-cmdline-tools\`, \`bundler\`, \`checkov\`, \`cocoapods\`, \`conan\`, \`copier\`, \`hashin\`, \`kas\`, \`node\`, \`nuget\`, \`pdm\`, \`pip-tools\`, \`pipenv\`, \`poetry\`, \`uv\`: A major or major.minor version installs the newest matching release.
- \`corepack\`, \`npm\`, \`pnpm\`, \`yarn\`: A major or major.minor version installs the matching \`latest\` release, else the newest matching one.
- \`java\`, \`java-jdk\`, \`java-jre\`: A major, major.minor or major.minor.patch version, like \`21\` or \`11.0\`, installs the newest matching release.
`);
Expand Down
20 changes: 3 additions & 17 deletions src/cli/tools/dotnet/nuget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,10 @@ import { BaseInstallService } from '../../install-tool/base-install.service.ts';
import {
ToolVersionResolver,
isPartialVersion,
newestVersion,
partialVersionHelp,
} from '../../install-tool/tool-version-resolver.ts';
import { logger, semverCoerce, semverGte } from '../../utils/index.ts';

/**
* The newest of the versions, compared coerced, so entries like `4.9` count
* as `4.9.0`.
* @returns the newest version as listed, or `undefined` without any
*/
function newest(versions: string[]): string | undefined {
let result: string | undefined;
for (const version of versions) {
if (!result || semverGte(semverCoerce(version)!, semverCoerce(result)!)) {
result = version;
}
}
return result;
}
import { logger } from '../../utils/index.ts';

@injectable()
@injectFromHierarchy()
Expand Down Expand Up @@ -123,7 +109,7 @@ export class NugetVersionResolver extends ToolVersionResolver {
if (meta.some((v) => v.version === version)) {
return version;
}
const release = newest(
const release = newestVersion(
meta
.filter(
(v) =>
Expand Down
115 changes: 115 additions & 0 deletions src/cli/tools/java/android-partial.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { AndroidSdkCmdlineToolsVersionResolver } from './android.ts';
import { scope } from '~test/http-mock.ts';
import { toolContext } from '~test/tool.ts';

/**
* `android.ts` caches the sdk repository in a module level variable, so a
* single spec file can only ever see one version of it. This one holds a
* repository with several versions to resolve partial versions from.
*/

/** A repository package, the revision is written without trailing zeros. */
function remotePackage(
path: string,
major: number,
minor: number | undefined,
channel = 'channel-0',
): string {
const minorTag = minor === undefined ? '' : `<minor>${minor}</minor>`;
return `
<remotePackage path="${path}">
<type-details/>
<revision><major>${major}</major>${minorTag}</revision>
<display-name>Android SDK Command-line Tools</display-name>
<uses-license ref="android-sdk-license"/>
<channelRef ref="${channel}"/>
<archives>
<archive>
<complete>
<size>1</size>
<checksum type="sha1">deadbeef</checksum>
<url>commandlinetools-linux-${major}.zip</url>
</complete>
<host-os>linux</host-os>
</archive>
</archives>
</remotePackage>`;
}

const repository = `<?xml version="1.0" ?>
<sdk:sdk-repository xmlns:sdk="http://schemas.android.com/sdk/android/repo/repository2/03">
<license id="android-sdk-license" type="text">Terms and Conditions</license>
<channel id="channel-0">stable</channel>
<channel id="channel-1">beta</channel>
${remotePackage('cmdline-tools;11.0', 11, undefined)}
${remotePackage('cmdline-tools;12.0', 12, undefined)}
${remotePackage('cmdline-tools;12.1', 12, 1)}
${remotePackage('cmdline-tools;13.0', 13, undefined)}
${remotePackage('cmdline-tools;14.1', 14, 1)}
${remotePackage('cmdline-tools;14.3', 14, 3)}
${remotePackage('cmdline-tools;15.1', 15, 1, 'channel-1')}
${remotePackage('cmdline-tools;16.0', 16, undefined)}
${remotePackage('cmdline-tools;16.10', 16, 10)}
${remotePackage('cmdline-tools;16.2', 16, 2)}
${remotePackage('cmdline-tools;17.0', 17, undefined, 'channel-1')}
${remotePackage('cmdline-tools;160.0', 160, undefined)}
${remotePackage('cmdline-tools;latest', 16, 10)}
${remotePackage('platforms;android-35', 18, undefined)}
</sdk:sdk-repository>`;

describe('cli/tools/java/android-partial', () => {
beforeEach(() => {
// the repository is fetched once per run and cached in the module, so the
// first test serves it and the rest reuse the cache
scope('https://dl.google.com')
.get('/android/repository/repository2-3.xml')
.optionally()
.reply(200, repository);
});

test.each([
// existing releases are kept, in the form the repository lists them
{ version: '16', expected: '16' },
{ version: '16.2', expected: '16.2' },
{ version: '12', expected: '12' },
{ version: '11', expected: '11' },
// a listed `13` is `13.0`
{ version: '13', expected: '13' },
{ version: '13.0', expected: '13' },
{ version: '16.0', expected: '16' },
{ version: '160', expected: '160' },
// an existing beta release is kept too
{ version: '17', expected: '17' },
// no `14` release, so the newest stable 14.x
{ version: '14', expected: '14.3' },
])('resolves $version to $expected', async ({ version, expected }) => {
const { svc } = await toolContext(AndroidSdkCmdlineToolsVersionResolver);

expect(await svc.resolve(version)).toBe(expected);
});

test.each([
// only a beta package
'15',
// no such package, even though a platform with that revision exists
'18',
'16.3',
'13.1',
// 14.1 and 14.3 are no 14.0 release
'14.0',
'1',
])('throws for %s without a matching stable package', async (version) => {
const { svc } = await toolContext(AndroidSdkCmdlineToolsVersionResolver);

await expect(svc.resolve(version)).rejects.toThrow(
`No android-sdk-cmdline-tools release found for version ${version}`,
);
});

test('keeps a full version', async () => {
const { svc } = await toolContext(AndroidSdkCmdlineToolsVersionResolver);

expect(await svc.resolve('16.10.1')).toBe('16.10.1');
});
});
Loading
Loading