diff --git a/docs/custom-registries.md b/docs/custom-registries.md index e71aece1bf..27ea4e4637 100644 --- a/docs/custom-registries.md +++ b/docs/custom-registries.md @@ -152,8 +152,11 @@ https://github.com/containerbase/node-prebuild/releases/download/18.12.0/node-18 https://nodejs.org/dist/v20.0.0/SHASUMS256.txt https://nodejs.org/dist/v20.0.0/node-v20.0.0-linux-x64.tar.xz https://nodejs.org/dist/v20.0.0/node-v20.0.0-linux-arm64.tar.xz +https://nodejs.org/dist/index.json ``` +The url `https://nodejs.org/dist/index.json` is used to find the latest version if no version was provided. + ### npm tools Npm tools are downloaded from: diff --git a/src/cli/command/install-gem.spec.ts b/src/cli/command/install-gem.spec.ts index 3047974a49..97c5e9da48 100644 --- a/src/cli/command/install-gem.spec.ts +++ b/src/cli/command/install-gem.spec.ts @@ -6,18 +6,19 @@ import { prepareCommands } from '.'; const mocks = vi.hoisted(() => ({ installTool: vi.fn(), + resolveVersion: vi.fn((_, v) => v), prepareTools: vi.fn(), })); vi.mock('../install-tool', () => mocks); vi.mock('../prepare-tool', () => mocks); -describe('index', () => { +describe('install-gem', () => { beforeEach(() => { delete env.RAKE_VERSION; }); - test('install-gem', async () => { + test('works', async () => { const cli = new Cli({ binaryName: 'install-gem' }); prepareCommands(cli, 'install-gem'); diff --git a/src/cli/command/install-npm.spec.ts b/src/cli/command/install-npm.spec.ts index 1b545dddbc..cf5555cdc9 100644 --- a/src/cli/command/install-npm.spec.ts +++ b/src/cli/command/install-npm.spec.ts @@ -6,26 +6,36 @@ import { prepareCommands } from '.'; const mocks = vi.hoisted(() => ({ installTool: vi.fn(), + resolveVersion: vi.fn((_, v) => v), prepareTools: vi.fn(), })); vi.mock('../install-tool', () => mocks); vi.mock('../prepare-tool', () => mocks); -describe('index', () => { +describe('install-npm', () => { beforeEach(() => { delete env.DEL_CLI_VERSION; }); - test('install-npm', async () => { + test('works', async () => { const cli = new Cli({ binaryName: 'install-npm' }); prepareCommands(cli, 'install-npm'); expect(await cli.run(['del-cli'])).toBe(MissingVersion); + mocks.resolveVersion.mockResolvedValueOnce('4.0.0'); + expect(await cli.run(['del-cli'])).toBe(0); + env.DEL_CLI_VERSION = '5.0.0'; expect(await cli.run(['del-cli'])).toBe(0); - expect(mocks.installTool).toHaveBeenCalledTimes(1); + expect(mocks.installTool).toHaveBeenCalledTimes(2); + expect(mocks.installTool).toHaveBeenCalledWith( + 'del-cli', + '4.0.0', + false, + 'npm', + ); expect(mocks.installTool).toHaveBeenCalledWith( 'del-cli', '5.0.0', diff --git a/src/cli/command/install-tool.spec.ts b/src/cli/command/install-tool.spec.ts index b49fe0fad4..d9030768a8 100644 --- a/src/cli/command/install-tool.spec.ts +++ b/src/cli/command/install-tool.spec.ts @@ -6,18 +6,19 @@ import { prepareCommands } from '.'; const mocks = vi.hoisted(() => ({ installTool: vi.fn(), + resolveVersion: vi.fn((_, v) => v), prepareTools: vi.fn(), })); vi.mock('../install-tool', () => mocks); vi.mock('../prepare-tool', () => mocks); -describe('index', () => { +describe('install-tool', () => { beforeEach(() => { delete env.NODE_VERSION; }); - test('install-tool', async () => { + test('works', async () => { const cli = new Cli({ binaryName: 'install-tool' }); prepareCommands(cli, 'install-tool'); diff --git a/src/cli/command/install-tool.ts b/src/cli/command/install-tool.ts index 73d96004dd..039baec9f8 100644 --- a/src/cli/command/install-tool.ts +++ b/src/cli/command/install-tool.ts @@ -2,7 +2,12 @@ import is from '@sindresorhus/is'; import { Command, Option } from 'clipanion'; import prettyMilliseconds from 'pretty-ms'; import * as t from 'typanion'; -import { type InstallToolType, installTool } from '../install-tool'; +import { + type InstallToolType, + installTool, + resolveVersion, +} from '../install-tool'; +import { ResolverMap } from '../tools'; import { logger, validateVersion } from '../utils'; import { MissingVersion } from '../utils/codes'; import { getVersion } from './utils'; @@ -36,10 +41,15 @@ export class InstallToolCommand extends Command { override async execute(): Promise { let version = this.version; + const type = ResolverMap[this.name] ?? this.type; + if (!is.nonEmptyStringAndNotWhitespace(version)) { version = getVersion(this.name); } + logger.debug(`Try resolving version for ${this.name}@${version} ...`); + version = await resolveVersion(this.name, version, type); + if (!is.nonEmptyStringAndNotWhitespace(version)) { logger.error(`No version found for ${this.name}`); return MissingVersion; @@ -47,9 +57,9 @@ export class InstallToolCommand extends Command { const start = Date.now(); let error = false; - logger.info(`Installing ${this.type ?? 'tool'} ${this.name}@${version}...`); + logger.info(`Installing ${type ?? 'tool'} ${this.name}@${version}...`); try { - return await installTool(this.name, version, this.dryRun, this.type); + return await installTool(this.name, version, this.dryRun, type); } catch (err) { logger.fatal(err); error = true; diff --git a/src/cli/install-tool/index.ts b/src/cli/install-tool/index.ts index d7034e6dbf..28e8f9f563 100644 --- a/src/cli/install-tool/index.ts +++ b/src/cli/install-tool/index.ts @@ -19,6 +19,10 @@ import { InstallYarnService, InstallYarnSlimService, } from '../tools/node/npm'; +import { + NodeVersionResolver, + NpmVersionResolver, +} from '../tools/node/resolver'; import { InstallNodeBaseService } from '../tools/node/utils'; import { InstallBundlerService, @@ -28,11 +32,13 @@ import { InstallRubyBaseService } from '../tools/ruby/utils'; import { logger } from '../utils'; import { InstallLegacyToolService } from './install-legacy-tool.service'; import { INSTALL_TOOL_TOKEN, InstallToolService } from './install-tool.service'; +import { TOOL_VERSION_RESOLVER } from './tool-version-resolver'; +import { ToolVersionResolverService } from './tool-version-resolver.service'; export type InstallToolType = 'gem' | 'npm'; -function prepareContainer(): Container { - logger.trace('preparing container'); +function prepareInstallContainer(): Container { + logger.trace('preparing install container'); const container = new Container(); container.parent = rootContainer; @@ -61,6 +67,21 @@ function prepareContainer(): Container { container.bind(INSTALL_TOOL_TOKEN).to(InstallYarnService); container.bind(INSTALL_TOOL_TOKEN).to(InstallYarnSlimService); + logger.trace('preparing install container done'); + return container; +} + +function prepareResolveContainer(): Container { + logger.trace('preparing resolve container'); + const container = new Container(); + container.parent = rootContainer; + + // core services + container.bind(ToolVersionResolverService).toSelf(); + + // tool version resolver + container.bind(TOOL_VERSION_RESOLVER).to(NodeVersionResolver); + logger.trace('preparing container done'); return container; } @@ -71,7 +92,7 @@ export function installTool( dryRun = false, type?: InstallToolType, ): Promise { - const container = prepareContainer(); + const container = prepareInstallContainer(); if (type) { switch (type) { case 'gem': { @@ -120,3 +141,46 @@ export function installTool( } return container.get(InstallToolService).execute(tool, version, dryRun); } + +export async function resolveVersion( + tool: string, + version: string | undefined, + type?: InstallToolType, +): Promise { + const container = prepareResolveContainer(); + + if (type) { + switch (type) { + // case 'gem': { + // @injectable() + // class InstallGenericGemService extends InstallRubyBaseService { + // override readonly name: string = tool; + + // override needsPrepare(): boolean { + // return false; + // } + + // override async test(version: string): Promise { + // try { + // // some npm packages may not have a `--version` flag + // await super.test(version); + // } catch (err) { + // logger.debug(err); + // } + // } + // } + // container.bind(INSTALL_TOOL_TOKEN).to(InstallGenericGemService); + // break; + // } + case 'npm': { + @injectable() + class GenericNpmVersionResolver extends NpmVersionResolver { + override readonly tool: string = tool; + } + container.bind(TOOL_VERSION_RESOLVER).to(GenericNpmVersionResolver); + break; + } + } + } + return await container.get(ToolVersionResolverService).resolve(tool, version); +} diff --git a/src/cli/install-tool/tool-version-resolver.service.ts b/src/cli/install-tool/tool-version-resolver.service.ts new file mode 100644 index 0000000000..699de7f9b4 --- /dev/null +++ b/src/cli/install-tool/tool-version-resolver.service.ts @@ -0,0 +1,20 @@ +import { injectable, multiInject } from 'inversify'; +import { + TOOL_VERSION_RESOLVER, + type ToolVersionResolver, +} from './tool-version-resolver'; + +@injectable() +export class ToolVersionResolverService { + constructor( + @multiInject(TOOL_VERSION_RESOLVER) private resolver: ToolVersionResolver[], + ) {} + + async resolve( + tool: string, + version: string | undefined, + ): Promise { + const resolver = this.resolver.find((r) => r.tool === tool); + return (await resolver?.resolve(version)) ?? version; + } +} diff --git a/src/cli/install-tool/tool-version-resolver.ts b/src/cli/install-tool/tool-version-resolver.ts new file mode 100644 index 0000000000..6f00610794 --- /dev/null +++ b/src/cli/install-tool/tool-version-resolver.ts @@ -0,0 +1,16 @@ +import { injectable } from 'inversify'; +import type { EnvService, HttpService } from '../services'; + +export const TOOL_VERSION_RESOLVER = Symbol('TOOL_VERSION_RESOLVER'); + +@injectable() +export abstract class ToolVersionResolver { + abstract readonly tool: string; + + constructor( + protected readonly http: HttpService, + protected readonly env: EnvService, + ) {} + + abstract resolve(version: string | undefined): Promise; +} diff --git a/src/cli/tools/index.ts b/src/cli/tools/index.ts index d824906346..36bca9e009 100644 --- a/src/cli/tools/index.ts +++ b/src/cli/tools/index.ts @@ -1,3 +1,5 @@ +import type { InstallToolType } from '../install-tool'; + export const NoPrepareTools = [ 'bazelisk', 'bower', @@ -15,3 +17,13 @@ export const NoPrepareTools = [ 'yarn', 'yarn-slim', ]; + +export const ResolverMap: Record = { + corepack: 'npm', + lerna: 'npm', + npm: 'npm', + pnpm: 'npm', + renovate: 'npm', + yarn: 'npm', + 'yarn-slim': 'npm', +}; diff --git a/src/cli/tools/node/index.ts b/src/cli/tools/node/index.ts index 87307188e7..37ae8d0937 100644 --- a/src/cli/tools/node/index.ts +++ b/src/cli/tools/node/index.ts @@ -42,11 +42,11 @@ export class InstallNodeService extends InstallNodeBaseService { constructor( @inject(EnvService) envSvc: EnvService, @inject(PathService) pathSvc: PathService, - @inject(HttpService) http: HttpService, + @inject(HttpService) private http: HttpService, @inject(CompressionService) private compress: CompressionService, @inject(VersionService) versionSvc: VersionService, ) { - super(envSvc, pathSvc, versionSvc, http); + super(envSvc, pathSvc, versionSvc); } override async install(version: string): Promise { diff --git a/src/cli/tools/node/npm.ts b/src/cli/tools/node/npm.ts index ddf813b198..beb3b80175 100644 --- a/src/cli/tools/node/npm.ts +++ b/src/cli/tools/node/npm.ts @@ -5,6 +5,7 @@ import { InstallNodeBaseService } from './utils'; @injectable() export class InstallBowerService extends InstallNodeBaseService { override readonly name: string = 'bower'; + protected override readonly deprecated: boolean = true; } @injectable() @@ -15,6 +16,7 @@ export class InstallCorepackService extends InstallNodeBaseService { @injectable() export class InstallLernaService extends InstallNodeBaseService { override readonly name: string = 'lerna'; + protected override readonly deprecated: boolean = true; } @injectable() diff --git a/src/cli/tools/node/resolver.ts b/src/cli/tools/node/resolver.ts new file mode 100644 index 0000000000..e4f6113ee0 --- /dev/null +++ b/src/cli/tools/node/resolver.ts @@ -0,0 +1,58 @@ +import is from '@sindresorhus/is'; +import { inject, injectable } from 'inversify'; +import { ToolVersionResolver } from '../../install-tool/tool-version-resolver'; +import { EnvService, HttpService } from '../../services'; +import type { NodeVersionMeta, NpmPackageMeta } from './types'; + +@injectable() +export class NodeVersionResolver extends ToolVersionResolver { + readonly tool = 'node'; + + constructor( + @inject(HttpService) http: HttpService, + @inject(EnvService) env: EnvService, + ) { + super(http, env); + } + + async resolve(version: string | undefined): Promise { + if (!is.nonEmptyStringAndNotWhitespace(version) || version === 'latest') { + const url = this.env.replaceUrl('https://nodejs.org/dist/index.json'); + const meta = await this.http.getJson(url, { + headers: { + accept: + 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*', + }, + }); + // we know that the latest version is the first entry, so search for first lts + return meta.find((v) => v.lts)?.version.replace(/^v/, ''); + } + return version; + } +} + +@injectable() +export abstract class NpmVersionResolver extends ToolVersionResolver { + constructor( + @inject(HttpService) http: HttpService, + @inject(EnvService) env: EnvService, + ) { + super(http, env); + } + + async resolve(version: string | undefined): Promise { + if (!is.nonEmptyStringAndNotWhitespace(version) || version === 'latest') { + const meta = await this.http.getJson( + `https://registry.npmjs.org/${this.tool}`, + { + headers: { + accept: + 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*', + }, + }, + ); + return meta['dist-tags'].latest; + } + return version; + } +} diff --git a/src/cli/tools/node/types.ts b/src/cli/tools/node/types.ts new file mode 100644 index 0000000000..5064e5e414 --- /dev/null +++ b/src/cli/tools/node/types.ts @@ -0,0 +1,9 @@ +export interface NodeVersionMeta { + version: string; + lts?: boolean; +} + +export interface NpmPackageMeta { + 'dist-tags': Record; + name: string; +} diff --git a/src/cli/tools/node/utils.ts b/src/cli/tools/node/utils.ts index 78d94c8643..2fb8a3549d 100644 --- a/src/cli/tools/node/utils.ts +++ b/src/cli/tools/node/utils.ts @@ -6,12 +6,7 @@ import { execa } from 'execa'; import { inject, injectable } from 'inversify'; import type { PackageJson } from 'type-fest'; import { InstallToolBaseService } from '../../install-tool/install-tool-base.service'; -import { - EnvService, - HttpService, - PathService, - VersionService, -} from '../../services'; +import { EnvService, PathService, VersionService } from '../../services'; import { fileExists, logger, parse } from '../../utils'; const defaultRegistry = 'https://registry.npmjs.org/'; @@ -22,16 +17,22 @@ export abstract class InstallNodeBaseService extends InstallToolBaseService { return this.name; } + protected readonly deprecated: boolean = false; + constructor( @inject(EnvService) envSvc: EnvService, @inject(PathService) pathSvc: PathService, @inject(VersionService) protected versionSvc: VersionService, - @inject(HttpService) protected http: HttpService, ) { super(pathSvc, envSvc); } override async install(version: string): Promise { + if (this.deprecated) { + logger.info( + `Installing install-tool ${this.name} is deprecated, used install-npm instead.`, + ); + } const npm = await this.getNodeNpm(); const tmp = await fs.mkdtemp( join(this.pathSvc.tmpDir, 'containerbase-npm-'), diff --git a/test/node/Dockerfile b/test/node/Dockerfile index 3e516689d1..8b636db325 100644 --- a/test/node/Dockerfile +++ b/test/node/Dockerfile @@ -466,7 +466,12 @@ RUN set -ex; \ #-------------------------------------- # test: renovate #-------------------------------------- -FROM build as testq +FROM base as testq + +# install latest version +RUN install-tool node +RUN install-tool yarn +RUN install-npm del-cli # renovate: datasource=npm depName=renovate ARG RENOVATE_VERSION=37.115.0