Skip to content

Commit be8209b

Browse files
authored
feat(cli): add uninstall-tool command (#5026)
* feat(cli): add `uninstall-tool` command * fix: add exit codes * feat: check if child tool requires this version * feat: add optional uninstall method for v2 tools
1 parent b864985 commit be8209b

24 files changed

+614
-5
lines changed

src/cli/command/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,8 @@ import './install-pip';
88
import './install-tool';
99
import './link-tool';
1010
import './prepare-tool';
11+
import './uninstall-gem';
12+
import './uninstall-npm';
13+
import './uninstall-pip';
14+
import './uninstall-tool';
1115
export { registerCommands } from './utils';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, test, vi } from 'vitest';
2+
import { testCli } from '../../../test/di';
3+
import { MissingVersion } from '../utils/codes';
4+
5+
const mocks = vi.hoisted(() => ({
6+
uninstallTool: vi.fn(),
7+
}));
8+
9+
vi.mock('../install-tool', () => mocks);
10+
11+
describe('cli/command/uninstall-gem', () => {
12+
test.each([
13+
{
14+
mode: 'uninstall-gem' as const,
15+
args: [],
16+
},
17+
{
18+
mode: 'containerbase-cli' as const,
19+
args: ['uninstall', 'gem'],
20+
},
21+
])('$mode $args', async ({ mode, args }) => {
22+
const cli = testCli(mode);
23+
expect(await cli.run([...(args ?? []), 'rake'])).toBe(MissingVersion);
24+
25+
expect(await cli.run([...(args ?? []), 'rake', '5.0.0'])).toBe(0);
26+
27+
expect(mocks.uninstallTool).toHaveBeenCalledExactlyOnceWith(
28+
'rake',
29+
'5.0.0',
30+
false,
31+
'gem',
32+
);
33+
expect(await cli.run([...(args ?? []), 'rake', '-d', '5.0.0'])).toBe(0);
34+
35+
mocks.uninstallTool.mockRejectedValueOnce(new Error('test'));
36+
expect(await cli.run([...(args ?? []), 'rake', '5.0.0'])).toBe(1);
37+
});
38+
});

src/cli/command/uninstall-gem.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Command } from 'clipanion';
2+
import { UninstallToolCommand } from './uninstall-tool';
3+
import { command } from './utils';
4+
5+
@command('containerbase-cli')
6+
export class UninstallGemCommand extends UninstallToolCommand {
7+
static override paths = [['uninstall', 'gem']];
8+
static override usage = Command.Usage({
9+
description: 'Uninstalls a gem package from the container.',
10+
examples: [
11+
['Uninstalls rake 13.0.6', '$0 install gem rake 13.0.6'],
12+
// ['Installs latest rake version', '$0 install gem rake'], // not yet supported
13+
],
14+
});
15+
16+
protected override type = 'gem' as const;
17+
}
18+
19+
@command('uninstall-gem')
20+
export class UninstallGemShortCommand extends UninstallGemCommand {
21+
static override paths = [Command.Default];
22+
static override usage = Command.Usage({
23+
description: 'Uninstalls a gem package from the container.',
24+
examples: [
25+
['Uninstalls rake v13.0.6', '$0 rake 13.0.6'],
26+
// ['Installs latest rake version', '$0 rake'], // not yet supported
27+
],
28+
});
29+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, test, vi } from 'vitest';
2+
import { testCli } from '../../../test/di';
3+
import { MissingVersion } from '../utils/codes';
4+
5+
const mocks = vi.hoisted(() => ({
6+
uninstallTool: vi.fn(),
7+
}));
8+
9+
vi.mock('../install-tool', () => mocks);
10+
11+
describe('cli/command/uninstall-npm', () => {
12+
test.each([
13+
{
14+
mode: 'uninstall-npm' as const,
15+
args: [],
16+
},
17+
{
18+
mode: 'containerbase-cli' as const,
19+
args: ['uninstall', 'npm'],
20+
},
21+
])('$mode $args', async ({ mode, args }) => {
22+
const cli = testCli(mode);
23+
expect(await cli.run([...(args ?? []), 'del-cli'])).toBe(MissingVersion);
24+
25+
expect(await cli.run([...(args ?? []), 'del-cli', '5.0.0'])).toBe(0);
26+
27+
expect(mocks.uninstallTool).toHaveBeenCalledExactlyOnceWith(
28+
'del-cli',
29+
'5.0.0',
30+
false,
31+
'npm',
32+
);
33+
expect(await cli.run([...(args ?? []), 'del-cli', '-d', '5.0.0'])).toBe(0);
34+
35+
mocks.uninstallTool.mockRejectedValueOnce(new Error('test'));
36+
expect(await cli.run([...(args ?? []), 'del-cli', '5.0.0'])).toBe(1);
37+
});
38+
});

src/cli/command/uninstall-npm.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Command } from 'clipanion';
2+
import { UninstallToolCommand } from './uninstall-tool';
3+
import { command } from './utils';
4+
5+
@command('containerbase-cli')
6+
export class UninstallNpmCommand extends UninstallToolCommand {
7+
static override paths = [['uninstall', 'npm']];
8+
static override usage = Command.Usage({
9+
description: 'Uninstalls a npm package from the container.',
10+
examples: [
11+
['Uninstalls del-cli 5.0.0', '$0 install npm del-cli 5.0.0'],
12+
// ['Installs latest del-cli version', '$0 install npm del-cli'],
13+
],
14+
});
15+
16+
protected override type = 'npm' as const;
17+
}
18+
19+
@command('uninstall-npm')
20+
export class UninstallNpmShortCommand extends UninstallNpmCommand {
21+
static override paths = [Command.Default];
22+
static override usage = Command.Usage({
23+
description: 'Uninstalls a npm package from the container.',
24+
examples: [
25+
['Uninstalls del-cli v5.0.0', '$0 del-cli 5.0.0'],
26+
// ['Installs latest del-cli version', '$0 del-cli'],
27+
],
28+
});
29+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, test, vi } from 'vitest';
2+
import { testCli } from '../../../test/di';
3+
import { MissingVersion } from '../utils/codes';
4+
5+
const mocks = vi.hoisted(() => ({
6+
uninstallTool: vi.fn(),
7+
}));
8+
9+
vi.mock('../install-tool', () => mocks);
10+
11+
describe('cli/command/uninstall-pip', () => {
12+
test.each([
13+
{
14+
mode: 'uninstall-pip' as const,
15+
args: [],
16+
},
17+
{
18+
mode: 'containerbase-cli' as const,
19+
args: ['uninstall', 'pip'],
20+
},
21+
])('$mode $args', async ({ mode, args }) => {
22+
const cli = testCli(mode);
23+
expect(await cli.run([...(args ?? []), 'poetry'])).toBe(MissingVersion);
24+
25+
expect(await cli.run([...(args ?? []), 'poetry', '5.0.0'])).toBe(0);
26+
27+
expect(mocks.uninstallTool).toHaveBeenCalledExactlyOnceWith(
28+
'poetry',
29+
'5.0.0',
30+
false,
31+
'pip',
32+
);
33+
expect(await cli.run([...(args ?? []), 'poetry', '-d', '5.0.0'])).toBe(0);
34+
35+
mocks.uninstallTool.mockRejectedValueOnce(new Error('test'));
36+
expect(await cli.run([...(args ?? []), 'poetry', '5.0.0'])).toBe(1);
37+
});
38+
});

src/cli/command/uninstall-pip.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Command } from 'clipanion';
2+
import { UninstallToolCommand } from './uninstall-tool';
3+
import { command } from './utils';
4+
5+
@command('containerbase-cli')
6+
export class UninstallPipCommand extends UninstallToolCommand {
7+
static override paths = [['uninstall', 'pip']];
8+
static override usage = Command.Usage({
9+
description: 'Uninstalls a pip package from the container.',
10+
examples: [
11+
['Installs checkov 2.4.7', '$0 uninstall pip checkov 2.4.7'],
12+
// TODO: uninstall all versions
13+
// ['Uninstalls all but latest installed checkov version', '$0 uninstall pip checkov'],
14+
],
15+
});
16+
17+
protected override type = 'pip' as const;
18+
}
19+
20+
@command('uninstall-pip')
21+
export class UninstallPipShortCommand extends UninstallPipCommand {
22+
static override paths = [Command.Default];
23+
static override usage = Command.Usage({
24+
description: 'Uninstalls a pip package from the container.',
25+
examples: [
26+
['Uninstalls checkov v5.0.0', '$0 checkov 2.4.7'],
27+
// TODO: uninstall all versions
28+
// ['Uninstalls all but latest installed checkov version', '$0 checkov'],
29+
],
30+
});
31+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { env } from 'node:process';
2+
import { beforeEach, describe, expect, test, vi } from 'vitest';
3+
import { testCli } from '../../../test/di';
4+
import { logger } from '../utils';
5+
import { MissingVersion } from '../utils/codes';
6+
7+
const mocks = vi.hoisted(() => ({
8+
uninstallTool: vi.fn(),
9+
}));
10+
11+
vi.mock('../install-tool', () => mocks);
12+
13+
describe('cli/command/uninstall-tool', () => {
14+
beforeEach(() => {
15+
env.IGNORED_TOOLS = 'pnpm,php';
16+
});
17+
18+
test.each([
19+
{
20+
mode: 'uninstall-tool' as const,
21+
args: [],
22+
},
23+
{
24+
mode: 'containerbase-cli' as const,
25+
args: ['uninstall', 'tool'],
26+
},
27+
])('$mode $args', async ({ mode, args }) => {
28+
const cli = testCli(mode);
29+
expect(await cli.run([...(args ?? []), 'node'])).toBe(MissingVersion);
30+
31+
expect(await cli.run([...(args ?? []), 'node', '16.13.0'])).toBe(0);
32+
expect(mocks.uninstallTool).toHaveBeenCalledTimes(1);
33+
expect(mocks.uninstallTool).toHaveBeenCalledWith(
34+
'node',
35+
'16.13.0',
36+
false,
37+
undefined,
38+
);
39+
expect(await cli.run([...(args ?? []), 'node', '16.13.0', '-d'])).toBe(0);
40+
41+
mocks.uninstallTool.mockRejectedValueOnce(new Error('test'));
42+
expect(await cli.run([...(args ?? []), 'node', '16.13.0'])).toBe(1);
43+
44+
expect(await cli.run([...(args ?? []), 'php'])).toBe(0);
45+
expect(logger.info).toHaveBeenCalledWith({ tool: 'php' }, 'tool ignored');
46+
});
47+
});

src/cli/command/uninstall-tool.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { isNonEmptyStringAndNotWhitespace } from '@sindresorhus/is';
2+
import { Command, Option } from 'clipanion';
3+
import prettyMilliseconds from 'pretty-ms';
4+
import { type InstallToolType, uninstallTool } from '../install-tool';
5+
import { ResolverMap } from '../tools';
6+
import { logger } from '../utils';
7+
import { MissingVersion } from '../utils/codes';
8+
import { command, isToolIgnored } from './utils';
9+
10+
@command('containerbase-cli')
11+
export class UninstallToolCommand extends Command {
12+
static override paths = [['uninstall', 'tool'], ['ut']];
13+
14+
static override usage = Command.Usage({
15+
description: 'Uninstalls a tool from the container.',
16+
examples: [
17+
['Uninstalls node 14.17.0', '$0 uninstall tool node 14.17.0'],
18+
// ['Uninstalls latest pnpm version', '$0 uninstall tool pnpm'],
19+
],
20+
});
21+
22+
name = Option.String();
23+
24+
dryRun = Option.Boolean('-d,--dry-run', false);
25+
26+
version = Option.String({ required: false });
27+
28+
protected type: InstallToolType | undefined;
29+
30+
override async execute(): Promise<number | void> {
31+
const start = Date.now();
32+
33+
if (await isToolIgnored(this.name)) {
34+
logger.info({ tool: this.name }, 'tool ignored');
35+
return 0;
36+
}
37+
38+
let version = this.version;
39+
40+
const type = ResolverMap[this.name] ?? this.type;
41+
42+
// TODO: support uninstall all versions
43+
if (!isNonEmptyStringAndNotWhitespace(version)) {
44+
logger.error(`No version found for ${this.name}`);
45+
return MissingVersion;
46+
}
47+
48+
version = version.replace(/^v/, ''); // trim optional 'v' prefix
49+
50+
let error = false;
51+
logger.info(`Uninstalling ${type ?? 'tool'} ${this.name}@${version}...`);
52+
try {
53+
return await uninstallTool(this.name, version, this.dryRun, type);
54+
} catch (err) {
55+
error = true;
56+
logger.debug(err);
57+
if (err instanceof Error) {
58+
logger.error(err.message);
59+
}
60+
return 1;
61+
/* v8 ignore next -- coverage bug */
62+
} finally {
63+
if (error) {
64+
logger.fatal(
65+
`Uninstall ${this.type ?? 'tool'} ${this.name} failed in ${prettyMilliseconds(Date.now() - start)}.`,
66+
);
67+
} else {
68+
logger.info(
69+
`Uninstall ${this.type ?? 'tool'} ${this.name} succeeded in ${prettyMilliseconds(Date.now() - start)}.`,
70+
);
71+
}
72+
}
73+
}
74+
}
75+
76+
@command('uninstall-tool')
77+
export class UninstallToolShortCommand extends UninstallToolCommand {
78+
static override paths = [Command.Default];
79+
static override usage = Command.Usage({
80+
description: 'Uninstalls a tool from the container.',
81+
examples: [
82+
['Uninstalls node v14.17.0', '$0 node 14.17.0'],
83+
// ['Uninstalls all pnpm versions', '$0 pnpm'],
84+
],
85+
});
86+
}

src/cli/install-tool/base-install.service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from 'node:fs/promises';
12
import { inject, injectable } from 'inversify';
23
import {
34
CompressionService,
@@ -85,6 +86,13 @@ export abstract class BaseInstallService {
8586
return this.name;
8687
}
8788

89+
async uninstall(version: string): Promise<void> {
90+
await fs.rm(this.pathSvc.versionedToolPath(this.name, version), {
91+
recursive: true,
92+
force: true,
93+
});
94+
}
95+
8896
validate(version: string): Promise<boolean> {
8997
return Promise.resolve(isValid(version));
9098
}

0 commit comments

Comments
 (0)