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
2 changes: 2 additions & 0 deletions src/cli/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { InstallGemCommand, InstallGemShortCommand } from './install-gem';
import { InstallNpmCommand, InstallNpmShortCommand } from './install-npm';
import { InstallPipCommand, InstallPipShortCommand } from './install-pip';
import { InstallToolCommand, InstallToolShortCommand } from './install-tool';
import { LinkToolCommand } from './link-tool';
import { PrepareToolCommand, PrepareToolShortCommand } from './prepare-tool';

export function prepareCommands(cli: Cli, mode: CliMode | null): void {
Expand Down Expand Up @@ -44,6 +45,7 @@ export function prepareCommands(cli: Cli, mode: CliMode | null): void {
cli.register(InstallNpmCommand);
cli.register(InstallPipCommand);
cli.register(InstallToolCommand);
cli.register(LinkToolCommand);
cli.register(PrepareToolCommand);
cli.register(InitToolCommand);
cli.register(CleanupPathCommand);
Expand Down
56 changes: 56 additions & 0 deletions src/cli/command/link-tool.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { env } from 'node:process';
import { Cli } from 'clipanion';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { logger } from '../utils';
import { prepareCommands } from '.';

const mocks = vi.hoisted(() => ({
linkTool: vi.fn(),
}));

vi.mock('../install-tool', () => mocks);

describe('cli/command/link-tool', () => {
const cli = new Cli({ binaryName: 'containerbase-cli' });
prepareCommands(cli, 'containerbase-cli');

beforeEach(() => {
delete env.TOOL_NAME;
delete env.TOOL_VERSION;
});

test('missing TOOL_NAME', async () => {
expect(await cli.run(['lt', 'node', 'bin'])).toBe(1);
expect(mocks.linkTool).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledExactlyOnceWith(
`Missing 'TOOL_NAME' environment variable`,
);
});
test('missing TOOL_VERSION', async () => {
env.TOOL_NAME = 'node';
expect(await cli.run(['lt', 'node', 'bin'])).toBe(1);
expect(mocks.linkTool).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledExactlyOnceWith(
`Missing 'TOOL_VERSION' environment variable`,
);
});

test('works', async () => {
env.TOOL_NAME = 'node';
env.TOOL_VERSION = '1.2.3';

expect(await cli.run(['lt', 'node', 'bin'])).toBe(0);
expect(mocks.linkTool).toHaveBeenCalledOnce();
expect(mocks.linkTool).toHaveBeenCalledWith('node', {
name: 'node',
srcDir: 'bin',
});
});

test('fails', async () => {
env.TOOL_NAME = 'node';
env.TOOL_VERSION = '1.2.3';
mocks.linkTool.mockRejectedValueOnce(new Error('test'));
expect(await cli.run(['lt', 'node', 'bin'])).toBe(1);
});
});
65 changes: 65 additions & 0 deletions src/cli/command/link-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Command, Option } from 'clipanion';
import prettyMilliseconds from 'pretty-ms';
import { linkTool } from '../install-tool';
import { logger } from '../utils';

export class LinkToolCommand extends Command {
static override paths = [['link', 'tool'], ['lt']];

static override usage = Command.Usage({
description:
'Links a tool into the global tool path. For internal use only.',
});

name = Option.String();
src = Option.String();

exports = Option.String({ required: false });
args = Option.String({ required: false });

body = Option.String({ required: false });

tool = Option.String('--tool-name', { env: 'TOOL_NAME' });
version = Option.String('--tool-version', { env: 'TOOL_VERSION' });

async execute(): Promise<number | void> {
if (!this.tool) {
logger.error(`Missing 'TOOL_NAME' environment variable`);
return 1;
}
if (!this.version) {
logger.error(`Missing 'TOOL_VERSION' environment variable`);
return 1;
}
const start = Date.now();
let error = false;
logger.debug(`Linking tool ${this.name} (${this.tool})...`);
try {
return await linkTool(this.tool, {
name: this.name,
srcDir: this.src,
exports: this.exports,
args: this.args,
body: this.body,
});
} catch (err) {
error = true;
logger.debug(err);
if (err instanceof Error) {
logger.fatal(err.message);
}
return 1;
/* v8 ignore next -- coverage bug */
} finally {
if (error) {
logger.fatal(
`Linking tools ${this.name} (${this.tool}) failed in ${prettyMilliseconds(Date.now() - start)}.`,
);
} else {
logger.debug(
`Linking tools ${this.name} (${this.tool}) succeded in ${prettyMilliseconds(Date.now() - start)}.`,
);
}
}
}
}
68 changes: 7 additions & 61 deletions src/cli/install-tool/base-install.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { codeBlock } from 'common-tags';
import { inject, injectable } from 'inversify';
import {
CompressionService,
Expand All @@ -9,21 +6,8 @@ import {
PathService,
} from '../services';
import { NoInitTools, NoPrepareTools } from '../tools';
import { isValid, tool2path } from '../utils';

export interface ShellWrapperConfig {
name?: string;
srcDir: string;
exports?: string;

args?: string;

/**
* Which extra tool envs to load.
* Eg. load php env before composer env.
*/
extraToolEnvs?: string[];
}
import { isValid } from '../utils';
import { LinkToolService, type ShellWrapperConfig } from './link-tool.service';

@injectable()
export abstract class BaseInstallService {
Expand All @@ -36,6 +20,9 @@ export abstract class BaseInstallService {
@inject(CompressionService)
protected readonly compress!: CompressionService;

@inject(LinkToolService)
private readonly _link!: LinkToolService;

/**
* Optional tool alias used to refer to this tool as parent.
*/
Expand Down Expand Up @@ -99,48 +86,7 @@ export abstract class BaseInstallService {
return Promise.resolve(isValid(version));
}

protected async shellwrapper({
args,
name,
srcDir,
exports,
extraToolEnvs,
}: ShellWrapperConfig): Promise<void> {
const tgt = join(this.pathSvc.binDir, name ?? this.name);

const envs = [...(extraToolEnvs ?? []), this.name].map(tool2path);
let content = codeBlock`
#!/bin/bash

if [[ -z "\${CONTAINERBASE_ENV+x}" ]]; then
. ${this.pathSvc.envFile}
fi

if [[ ! -f "${this.pathSvc.toolInitPath(this.name)}" ]]; then
# set logging to only warn and above to not interfere with tool output
CONTAINERBASE_LOG_LEVEL=warn containerbase-cli init tool "${this.name}"
fi

# load tool envs
for n in ${envs.join(' ')}; do
if [[ -f "${this.pathSvc.toolsPath}/\${n}/env.sh" ]]; then
. "${this.pathSvc.toolsPath}/\${n}/env.sh"
fi
done
unset n
`;

if (exports) {
content += `\nexport ${exports}`;
}

content += `\n${srcDir}/${name ?? this.name}`;
if (args) {
content += ` ${args}`;
}
content += ` "$@"\n`;

await writeFile(tgt, content, { encoding: 'utf8' });
await this.pathSvc.setOwner({ path: tgt });
protected shellwrapper(options: ShellWrapperConfig): Promise<void> {
return this._link.shellwrapper(this.name, options);
}
}
6 changes: 6 additions & 0 deletions src/cli/install-tool/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ describe('cli/install-tool/index', () => {
'',
);

await fs.writeFile(rootPath('usr/local/containerbase/tools/leg.sh'), '');

const verSvc = await createContainer().getAsync(VersionService);

await verSvc.setCurrent({
Expand All @@ -51,6 +53,10 @@ describe('cli/install-tool/index', () => {
test('works', async () => {
expect(await installTool('bun', '1.0.0')).toBeUndefined();
expect(await installTool('dummy', '1.0.0')).toBeUndefined();
expect(await installTool('dummy', '1.0.0')).toBeUndefined();
expect(await installTool('leg', '1.0.0', true)).toBeUndefined();
expect(await installTool('leg', '1.0.0')).toBeUndefined();
expect(await installTool('a', '1.0.0')).toBe(1);
});

test.each([
Expand Down
12 changes: 12 additions & 0 deletions src/cli/install-tool/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
V2ToolInstallService,
} from './install-legacy-tool.service';
import { INSTALL_TOOL_TOKEN, InstallToolService } from './install-tool.service';
import { LinkToolService, type ShellWrapperConfig } from './link-tool.service';
import { TOOL_VERSION_RESOLVER } from './tool-version-resolver';
import { ToolVersionResolverService } from './tool-version-resolver.service';

Expand All @@ -85,6 +86,7 @@ async function prepareInstallContainer(): Promise<Container> {
// core services
container.bind(InstallToolService).toSelf();
container.bind(V1ToolInstallService).toSelf();
container.bind(LinkToolService).toSelf();

// modern tool services
container.bind(INSTALL_TOOL_TOKEN).to(ComposerInstallService);
Expand Down Expand Up @@ -246,6 +248,16 @@ export async function installTool(
return svc.install(tool, version, dryRun);
}

export async function linkTool(
tool: string,
options: ShellWrapperConfig,
): Promise<number | void> {
const container = await prepareInstallContainer();

const svc = await container.getAsync(LinkToolService);
return svc.shellwrapper(tool, options);
}

export async function resolveVersion(
tool: string,
version: string | undefined,
Expand Down
2 changes: 2 additions & 0 deletions src/cli/install-tool/install-tool.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { VersionService, createContainer } from '../services';
import { BunInstallService } from '../tools/bun';
import { V1ToolInstallService } from './install-legacy-tool.service';
import { INSTALL_TOOL_TOKEN, InstallToolService } from './install-tool.service';
import { LinkToolService } from './link-tool.service';
import { ensurePaths } from '~test/path';

vi.mock('del');
Expand All @@ -17,6 +18,7 @@ describe('cli/install-tool/install-tool', () => {
parent.bind(InstallToolService).toSelf();
parent.bind(V1ToolInstallService).toSelf();
parent.bind(INSTALL_TOOL_TOKEN).to(BunInstallService);
parent.bind(LinkToolService).toSelf();

let child: Container;
let install: InstallToolService;
Expand Down
92 changes: 92 additions & 0 deletions src/cli/install-tool/link-tool.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import fs from 'node:fs/promises';
import { join } from 'node:path';
import { codeBlock } from 'common-tags';
import { inject, injectable } from 'inversify';
import { EnvService, PathService } from '../services';
import { pathExists, tool2path } from '../utils';

export interface ShellWrapperConfig {
name?: string | undefined;
srcDir: string;
exports?: string | undefined;

args?: string | undefined;

/**
* Which extra tool envs to load.
* Eg. load php env before composer env.
*/
extraToolEnvs?: string[] | undefined;

/**
* extra content to be added to the shell wrapper
*/
body?: string | undefined;
}

@injectable()
export class LinkToolService {
@inject(PathService)
protected readonly pathSvc!: PathService;
@inject(EnvService)
protected readonly envSvc!: EnvService;

async shellwrapper(
tool: string,
{ args, name, srcDir, exports, extraToolEnvs, body }: ShellWrapperConfig,
): Promise<void> {
const tgt = join(this.pathSvc.binDir, name ?? tool);
const src = (await pathExists(srcDir, 'file'))
? srcDir
: `${srcDir}/${name ?? tool}`;

const envs = [...(extraToolEnvs ?? []), tool].map(tool2path);
let content = codeBlock`
#!/bin/bash

if [[ -z "\${CONTAINERBASE_ENV+x}" ]]; then
. ${this.pathSvc.envFile}
fi

if [[ ! -f "${this.pathSvc.toolInitPath(tool)}" ]]; then
# set logging to only warn and above to not interfere with tool output
CONTAINERBASE_LOG_LEVEL=warn containerbase-cli init tool "${tool}"
fi
`;

if (envs) {
content +=
'\n' +
codeBlock`
# load tool envs
include () {
local file=${this.pathSvc.toolsPath}/$1/env.sh
[[ -f "$file" ]] && source "$file"
}
`;

for (const t of envs) {
content += `\ninclude ${t}`;
}

content += `\nunset include`;
}

if (exports) {
content += `\nexport ${exports}`;
}

if (body) {
content += `\n${body}`;
}

content += `\n${src}`;
if (args) {
content += ` ${args}`;
}
content += ` "$@"\n`;

await fs.writeFile(tgt, content, { encoding: 'utf8' });
await this.pathSvc.setOwner({ path: tgt });
}
}
Loading