diff --git a/src/cli/command/index.ts b/src/cli/command/index.ts index f3c56d0bb6..be6844050f 100644 --- a/src/cli/command/index.ts +++ b/src/cli/command/index.ts @@ -2,6 +2,12 @@ import type { Cli } from 'clipanion'; import type { CliMode } from '../utils'; import { logger } from '../utils/logger'; import { DownloadFileCommand } from './download-file'; +import { + InstallGemCommand, + InstallGemEnvCommand, + InstallGemShortCommand, + InstallGemShortEnvCommand, +} from './install-gem'; import { InstallNpmCommand, InstallNpmEnvCommand, @@ -36,9 +42,15 @@ export function prepareCommands(cli: Cli, mode: CliMode | null): void { cli.register(InstallNpmShortCommand); cli.register(InstallNpmShortEnvCommand); return; + } else if (mode === 'install-gem') { + cli.register(InstallGemShortCommand); + cli.register(InstallGemShortEnvCommand); + return; } cli.register(DownloadFileCommand); + cli.register(InstallGemCommand); + cli.register(InstallGemEnvCommand); cli.register(InstallNpmCommand); cli.register(InstallNpmEnvCommand); cli.register(InstallToolCommand); diff --git a/src/cli/command/install-gem.spec.ts b/src/cli/command/install-gem.spec.ts new file mode 100644 index 0000000000..469597fbee --- /dev/null +++ b/src/cli/command/install-gem.spec.ts @@ -0,0 +1,39 @@ +import { env } from 'node:process'; +import { Cli } from 'clipanion'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { prepareCommands } from '.'; + +const mocks = vi.hoisted(() => ({ + installTool: vi.fn(), + prepareTools: vi.fn(), +})); + +vi.mock('../install-tool', () => mocks); +vi.mock('../prepare-tool', () => mocks); + +describe('index', () => { + beforeEach(() => { + delete env.RAKE_VERSION; + }); + + test('install-gem', async () => { + const cli = new Cli({ binaryName: 'install-gem' }); + prepareCommands(cli, 'install-gem'); + + expect(await cli.run(['rake'])).toBe(1); + + env.RAKE_VERSION = '13.0.6'; + expect(await cli.run(['rake'])).toBe(0); + expect(mocks.installTool).toHaveBeenCalledOnce(); + expect(mocks.installTool).toHaveBeenCalledWith( + 'rake', + '13.0.6', + false, + 'gem', + ); + expect(await cli.run(['rake', '-d'])).toBe(0); + + mocks.installTool.mockRejectedValueOnce(new Error('test')); + expect(await cli.run(['rake'])).toBe(1); + }); +}); diff --git a/src/cli/command/install-gem.ts b/src/cli/command/install-gem.ts new file mode 100644 index 0000000000..c6fb806cad --- /dev/null +++ b/src/cli/command/install-gem.ts @@ -0,0 +1,95 @@ +import { Command, Option } from 'clipanion'; +import prettyMilliseconds from 'pretty-ms'; +import * as t from 'typanion'; +import { installTool } from '../install-tool'; +import { logger, validateVersion } from '../utils'; +import { getVersion } from './utils'; + +export class InstallGemEnvCommand extends Command { + static override paths = [['install', 'gem']]; + + static override usage = Command.Usage({ + description: 'Installs a gem package into the container.', + examples: [ + [ + 'Installs rake with version via environment variable', + 'RAKE_VERSION=13.0.6 $0 install gem rake', + ], + ], + }); + + name = Option.String(); + + dryRun = Option.Boolean('-d,--dry-run', false); + + override async execute(): Promise { + const version = getVersion(this.name); + + if (!version) { + logger.fatal(`No version found for ${this.name}`); + return 1; + } + + return await this.cli.run([ + ...this.path, + ...(this.dryRun ? ['-d'] : []), + this.name, + version, + ]); + } +} + +export class InstallGemCommand extends InstallGemEnvCommand { + static override usage = Command.Usage({ + description: 'Installs a gem package into the container.', + examples: [['Installs rake 13.0.6', '$0 install gem rake 13.0.6']], + }); + + version = Option.String({ + required: true, + validator: t.cascade(t.isString(), validateVersion()), + }); + + override async execute(): Promise { + const start = Date.now(); + let error = false; + + logger.info(`Installing gem package ${this.name} v${this.version}...`); + try { + return await installTool(this.name, this.version, this.dryRun, 'gem'); + } catch (err) { + logger.fatal(err); + error = true; + return 1; + } finally { + logger.info( + `Installed gem package ${this.name} ${ + error ? 'with errors ' : '' + }in ${prettyMilliseconds(Date.now() - start)}.`, + ); + } + } +} + +export class InstallGemShortEnvCommand extends InstallGemEnvCommand { + static override paths = [Command.Default]; + + static override usage = Command.Usage({ + description: 'Installs a gem package into the container.', + examples: [ + [ + 'Installs rake with version via environment variable', + 'RAKE_VERSION=13.0.6 $0 rake', + ], + ], + }); +} + +export class InstallGemShortCommand extends InstallGemCommand { + static override paths = [Command.Default]; + + static override usage = Command.Usage({ + description: 'Installs a gem package into the container.', + examples: [['Installs rake v13.0.6', '$0 rake 13.0.6']], + }); +} diff --git a/src/cli/command/install-npm.ts b/src/cli/command/install-npm.ts index fb8520d23d..afae17b8f0 100644 --- a/src/cli/command/install-npm.ts +++ b/src/cli/command/install-npm.ts @@ -12,8 +12,8 @@ export class InstallNpmEnvCommand extends Command { description: 'Installs a npm package into the container.', examples: [ [ - 'Installs corepack with version via environment variable', - 'COREPACK_VERSION=0.9.0 $0 install npm corepack', + 'Installs del-cli with version via environment variable', + 'DEL_CLI_VERSION=5.0.0 $0 install npm del-cli', ], ], }); @@ -42,7 +42,7 @@ export class InstallNpmEnvCommand extends Command { export class InstallNpmCommand extends InstallNpmEnvCommand { static override usage = Command.Usage({ description: 'Installs a npm package into the container.', - examples: [['Installs corepack 0.9.0', '$0 install npm corepack 0.9.0']], + examples: [['Installs del-cli 5.0.0', '$0 install npm del-cli 5.0.0']], }); version = Option.String({ @@ -78,8 +78,8 @@ export class InstallNpmShortEnvCommand extends InstallNpmEnvCommand { description: 'Installs a npm package into the container.', examples: [ [ - 'Installs corepack with version via environment variable', - 'NODE_VERSION=0.9.0 $0 corepack', + 'Installs del-cli with version via environment variable', + 'DEL_CLI_VERSION=5.0.0 $0 del-cli', ], ], }); @@ -90,6 +90,6 @@ export class InstallNpmShortCommand extends InstallNpmCommand { static override usage = Command.Usage({ description: 'Installs a npm package into the container.', - examples: [['Installs corepack v0.9.0', '$0 corepack 0.9.0']], + examples: [['Installs del-cli v5.0.0', '$0 del-cli 5.0.0']], }); } diff --git a/src/cli/install-tool/index.ts b/src/cli/install-tool/index.ts index 8fc1172ec2..3090c132d5 100644 --- a/src/cli/install-tool/index.ts +++ b/src/cli/install-tool/index.ts @@ -16,11 +16,16 @@ import { InstallYarnSlimService, } from '../tools/node/npm'; import { InstallNodeBaseService } from '../tools/node/utils'; +import { + InstallBundlerService, + InstallCocoapodsService, +} from '../tools/ruby/gem'; +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'; -export type InstallToolType = 'npm'; +export type InstallToolType = 'gem' | 'npm'; function prepareContainer(): Container { logger.trace('preparing container'); @@ -33,6 +38,8 @@ function prepareContainer(): Container { // tool services container.bind(INSTALL_TOOL_TOKEN).to(InstallBowerService); + container.bind(INSTALL_TOOL_TOKEN).to(InstallBundlerService); + container.bind(INSTALL_TOOL_TOKEN).to(InstallCocoapodsService); container.bind(INSTALL_TOOL_TOKEN).to(InstallCorepackService); container.bind(INSTALL_TOOL_TOKEN).to(InstallDartService); container.bind(INSTALL_TOOL_TOKEN).to(InstallDockerService); @@ -59,6 +66,27 @@ export function installTool( const container = prepareContainer(); if (type) { switch (type) { + case 'gem': { + @injectable() + class InstallGenericDemService 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(InstallGenericDemService); + break; + } case 'npm': { @injectable() class InstallGenericNpmService extends InstallNodeBaseService { diff --git a/src/cli/install-tool/install-tool-base.service.ts b/src/cli/install-tool/install-tool-base.service.ts index 27a8432b62..81318b30c7 100644 --- a/src/cli/install-tool/install-tool-base.service.ts +++ b/src/cli/install-tool/install-tool-base.service.ts @@ -60,10 +60,10 @@ export abstract class InstallToolBaseService { let content = `#!/bin/bash - if [[ -z "\${CONTAINERBASE_ENV+x}" ]]; then - . ${this.pathSvc.envFile} - fi - `; +if [[ -z "\${CONTAINERBASE_ENV+x}" ]]; then + . ${this.pathSvc.envFile} +fi +`; if (exports) { content += `export ${exports}\n`; diff --git a/src/cli/tools/index.ts b/src/cli/tools/index.ts index ab259551a9..bb4e269ce3 100644 --- a/src/cli/tools/index.ts +++ b/src/cli/tools/index.ts @@ -1,5 +1,7 @@ export const NoPrepareTools = [ 'bower', + 'bundler', + 'cocoapods', 'corepack', 'flux', 'lerna', diff --git a/src/cli/tools/ruby/gem.ts b/src/cli/tools/ruby/gem.ts new file mode 100644 index 0000000000..3818941ff3 --- /dev/null +++ b/src/cli/tools/ruby/gem.ts @@ -0,0 +1,17 @@ +import { execa } from 'execa'; +import { injectable } from 'inversify'; +import { InstallRubyBaseService } from './utils'; + +@injectable() +export class InstallBundlerService extends InstallRubyBaseService { + override readonly name: string = 'bundler'; +} + +@injectable() +export class InstallCocoapodsService extends InstallRubyBaseService { + override readonly name: string = 'cocoapods'; + + override async test(_version: string): Promise { + await execa('pod', ['--version', '--allow-root'], { stdio: 'inherit' }); + } +} diff --git a/src/cli/tools/ruby/utils.ts b/src/cli/tools/ruby/utils.ts new file mode 100644 index 0000000000..545150e341 --- /dev/null +++ b/src/cli/tools/ruby/utils.ts @@ -0,0 +1,140 @@ +import { chmod, mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { env as penv } from 'node:process'; +import { execa } from 'execa'; +import { inject, injectable } from 'inversify'; +import { InstallToolBaseService } from '../../install-tool/install-tool-base.service'; +import { EnvService, PathService, VersionService } from '../../services'; +import { logger, parse } from '../../utils'; + +const defaultRegistry = 'https://rubygems.org/'; + +@injectable() +export abstract class InstallRubyBaseService extends InstallToolBaseService { + constructor( + @inject(EnvService) envSvc: EnvService, + @inject(PathService) pathSvc: PathService, + @inject(VersionService) protected versionSvc: VersionService, + ) { + super(pathSvc, envSvc); + } + + override async install(version: string): Promise { + const env: NodeJS.ProcessEnv = {}; + + if (!penv.RUBYGEMS_HOST) { + const registry = this.envSvc.replaceUrl(defaultRegistry); + if (registry !== defaultRegistry) { + env.RUBYGEMS_HOST = registry; + } + } + + const gem = await this.getRubyGem(); + const ruby = await this.getRubyMinor(); + + // TODO: create recursive + if (!(await this.pathSvc.findToolPath(this.name))) { + await this.pathSvc.createToolPath(this.name); + } + + let prefix = await this.pathSvc.findVersionedToolPath(this.name, version); + if (!prefix) { + prefix = await this.pathSvc.createVersionedToolPath(this.name, version); + // fix perms for later user installs + await chmod(prefix, 0o775); + } + + prefix = join(prefix, ruby); + await mkdir(prefix); + + await execa( + gem, + [ + 'install', + this.name, + '--install-dir', + prefix, + '--bindir', + join(prefix, 'bin'), + '--version', + version, + ], + { stdio: ['inherit', 'inherit', 1], env, cwd: this.pathSvc.installDir }, + ); + } + + override async isInstalled(version: string): Promise { + const ruby = await this.getRubyMinor(); + return this.pathSvc.fileExists(this.getGemSpec(version, ruby)); + } + + override async link(version: string): Promise { + await this.postInstall(version); + } + + override async postInstall(version: string): Promise { + const ruby = await this.getRubyMinor(); + const vtPath = this.pathSvc.versionedToolPath(this.name, version); + const path = join(vtPath, ruby); + const src = join(path, 'bin'); + const exports = `GEM_PATH=$GEM_PATH:${path}`; + const gemSpec = await readFile(this.getGemSpec(version, ruby), { + encoding: 'utf8', + }); + const pkg = /\s+s\.executables\s+=\s+\[([^\]]+)\]\s+/.exec(gemSpec)?.[1]; + + if (!pkg) { + logger.warn( + { tool: this.name, version, gemSpec }, + "Missing 'executables' in gemspec", + ); + return; + } + + for (const [, name] of pkg.matchAll(/"([^"]+)"/g)) { + await this.shellwrapper({ srcDir: src, name: name!, exports }); + } + } + + override async test(_version: string): Promise { + await execa(this.name, ['--version'], { stdio: 'inherit' }); + } + + override async validate(version: string): Promise { + if (!(await super.validate(version))) { + return false; + } + + return (await this.versionSvc.find('ruby')) !== null; + } + + private async getRubyGem(): Promise { + const rubyVersion = await this.getRubyVersion(); + + return join(this.pathSvc.versionedToolPath('ruby', rubyVersion), 'bin/gem'); + } + + private async getRubyMinor(): Promise { + const rubyVersion = await this.getRubyVersion(); + const ver = parse(rubyVersion)!; + return `${ver.major}.${ver.minor}`; + } + + private async getRubyVersion(): Promise { + const rubyVersion = await this.versionSvc.find('ruby'); + + if (!rubyVersion) { + throw new Error('Ruby not installed'); + } + return rubyVersion; + } + + private getGemSpec(version: string, ruby: string): string { + return join( + this.pathSvc.versionedToolPath(this.name, version), + ruby, + 'specifications', + `${this.name}-${version}.gemspec`, + ); + } +} diff --git a/src/cli/utils/index.spec.ts b/src/cli/utils/index.spec.ts index fa3e6be7ea..a2922344d8 100644 --- a/src/cli/utils/index.spec.ts +++ b/src/cli/utils/index.spec.ts @@ -9,6 +9,8 @@ describe('index', () => { expect(cliMode()).toBeNull(); procMocks.argv0 = 'containerbase-cli'; expect((await import('.')).cliMode()).toBe('containerbase-cli'); + procMocks.argv0 = 'install-gem'; + expect((await import('.')).cliMode()).toBe('install-gem'); procMocks.argv0 = 'install-npm'; expect((await import('.')).cliMode()).toBe('install-npm'); procMocks.argv0 = 'install-tool'; diff --git a/src/cli/utils/index.ts b/src/cli/utils/index.ts index 509ac12d41..dedf30abdc 100644 --- a/src/cli/utils/index.ts +++ b/src/cli/utils/index.ts @@ -10,6 +10,9 @@ export function cliMode(): CliMode | null { if (argv0.endsWith('/containerbase-cli') || argv0 === 'containerbase-cli') { return 'containerbase-cli'; } + if (argv0.endsWith('/install-gem') || argv0 === 'install-gem') { + return 'install-gem'; + } if (argv0.endsWith('/install-npm') || argv0 === 'install-npm') { return 'install-npm'; } diff --git a/src/cli/utils/types.ts b/src/cli/utils/types.ts index ae22d6e071..0e9dbbfa07 100644 --- a/src/cli/utils/types.ts +++ b/src/cli/utils/types.ts @@ -6,6 +6,7 @@ export interface Distro { export type CliMode = | 'containerbase-cli' + | 'install-gem' | 'install-npm' | 'install-tool' | 'prepare-tool'; diff --git a/src/usr/local/bin/install-containerbase b/src/usr/local/bin/install-containerbase index 726d2bfd95..e0185d6bf9 100755 --- a/src/usr/local/bin/install-containerbase +++ b/src/usr/local/bin/install-containerbase @@ -88,6 +88,7 @@ function link_tool () { arch=arm64 fi ln -sf /usr/local/containerbase/bin/containerbase-cli-${arch} /usr/local/bin/containerbase-cli + ln -sf /usr/local/containerbase/bin/containerbase-cli-${arch} /usr/local/bin/install-gem ln -sf /usr/local/containerbase/bin/containerbase-cli-${arch} /usr/local/bin/install-npm ln -sf /usr/local/containerbase/bin/containerbase-cli-${arch} /usr/local/bin/install-tool ln -sf /usr/local/containerbase/bin/containerbase-cli-${arch} /usr/local/bin/prepare-tool diff --git a/src/usr/local/containerbase/tools/v2/bundler.sh b/src/usr/local/containerbase/tools/v2/bundler.sh deleted file mode 100644 index ead9331c18..0000000000 --- a/src/usr/local/containerbase/tools/v2/bundler.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -# shellcheck source=/dev/null -. "$(get_containerbase_path)/utils/ruby.sh" - -function link_tool () { - post_install - [[ -n $SKIP_VERSION ]] || bundler --version -} diff --git a/src/usr/local/containerbase/tools/v2/cocoapods.sh b/src/usr/local/containerbase/tools/v2/cocoapods.sh deleted file mode 100644 index e458b70a9d..0000000000 --- a/src/usr/local/containerbase/tools/v2/cocoapods.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -# shellcheck source=/dev/null -. "$(get_containerbase_path)/utils/ruby.sh" - -function link_tool () { - post_install - [[ -n $SKIP_VERSION ]] || pod --version --allow-root -} diff --git a/test/ruby/Dockerfile b/test/ruby/Dockerfile index a4b4de008a..72877443ba 100644 --- a/test/ruby/Dockerfile +++ b/test/ruby/Dockerfile @@ -97,8 +97,11 @@ RUN install-tool bundler RUN set -ex; \ + command -v bundler; \ + bundler --version; \ bundler --version | grep ${BUNDLER_VERSION}; \ [ "$(command -v bundler)" = "/usr/local/bin/bundler" ] && echo "works" || exit 1; \ + [ "$(command -v bundle)" = "/usr/local/bin/bundle" ] && echo "works" || exit 1; \ true RUN set -ex; [ $(stat --format '%u' "/usr/local/bin/bundler") -eq 1000 ] @@ -113,6 +116,7 @@ RUN install-tool bundler RUN set -ex; \ bundler --version | grep ${BUNDLER_VERSION}; \ [ "$(command -v bundler)" = "/usr/local/bin/bundler" ] && echo "works" || exit 1; \ + [ "$(command -v bundle)" = "/usr/local/bin/bundle" ] && echo "works" || exit 1; \ true RUN bundler env @@ -182,6 +186,19 @@ RUN set -ex; \ gem install cocoapods-acknowledgements; \ pod install; +#-------------------------------------- +# test: install-gem +#-------------------------------------- +FROM build3 as test-gem + +USER 1000 + +# renovate: datasource=rubygems versioning=ruby +RUN install-gem rake 13.0.6 + +RUN rake --help + + #-------------------------------------- # final #-------------------------------------- @@ -194,3 +211,4 @@ COPY --from=test-bundler-d /.dummy /.dummy COPY --from=test-cocoapods-a /.dummy /.dummy # COPY --from=test-cocoapods-b /.dummy /.dummy COPY --from=test-cocoapods-c /.dummy /.dummy +COPY --from=test-gem /.dummy /.dummy