diff --git a/docs/tools.md b/docs/tools.md
index ce31a82b6d..efa7a0fb7c 100644
--- a/docs/tools.md
+++ b/docs/tools.md
@@ -19,6 +19,7 @@
- [Cleanup](#cleanup)
- [cleanup-cache](#cleanup-cache-command)
- [cleanup-downloads](#cleanup-downloads-command)
+ - [cleanup-tool](#cleanup-tool-command)
---
@@ -585,3 +586,42 @@ With `--dry-run` only the oldest file is logged, as nothing is freed to check th
```bash
containerbase-cli cleanup downloads --dry-run
```
+
+
+
+### `containerbase-cli cleanup tool`
+
+#### Description
+
+Removes all unlinked tools from `/opt/containerbase/tools`.
+
+Every installed tool version which is not the linked one of its tool is uninstalled, together with all tools installed for it, like [`uninstall-tool --recursive`](#uninstall-tool-command) does.
+A tool without a linked version, eg. a `java-jdk` while `java` is linked to a `java-jre`, has all its versions uninstalled.
+
+A version is kept, together with all tools installed for it, when:
+
+- a tool installed for it, also indirectly, is the linked version of its tool, eg. an older node version with the linked pnpm version installed for it
+- it or a tool installed for it can't be uninstalled, like legacy shell tools, `git`, or tools which need root when not running as root
+- a tool installed for it is kept for another version
+
+#### Usage
+
+`$ containerbase-cli cleanup tool [-d,--dry-run]`
+
+#### Examples
+
+- Removes all unlinked tool versions
+
+ ```bash
+ $ install-tool node 22.0.0
+ $ install-tool node 24.0.0
+ $ containerbase-cli cleanup tool
+ INFO (9): Cleanup tool ...
+ INFO (9): Uninstalling unlinked tool node@22.0.0...
+ INFO (9): Cleanup tool succeeded in 120ms.
+ ```
+
+- Logs the tool versions which would be removed
+ ```bash
+ containerbase-cli cleanup tool --dry-run
+ ```
diff --git a/src/cli/cleanup/cleanup-tool.service.spec.ts b/src/cli/cleanup/cleanup-tool.service.spec.ts
new file mode 100644
index 0000000000..ba76eadf48
--- /dev/null
+++ b/src/cli/cleanup/cleanup-tool.service.spec.ts
@@ -0,0 +1,297 @@
+import { injectFromHierarchy, injectable } from 'inversify';
+import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
+import { BaseInstallService } from '../install-tool/base-install.service.ts';
+import { V1ToolInstallService } from '../install-tool/install-legacy-tool.service.ts';
+import {
+ INSTALL_TOOL_TOKEN,
+ InstallToolService,
+} from '../install-tool/install-tool.service.ts';
+import {
+ DataService,
+ EnvService,
+ VersionService,
+ createContainer,
+} from '../services/index.ts';
+import type { ToolVersion } from '../services/version.service.ts';
+import { logger } from '../utils/index.ts';
+import { CleanupToolService } from './cleanup-tool.service.ts';
+import { ensurePaths } from '~test/path.ts';
+
+@injectable()
+@injectFromHierarchy()
+abstract class TestInstallService extends BaseInstallService {
+ /** Does nothing, for tests. */
+ override install(_version: string): Promise {
+ return Promise.resolve();
+ }
+
+ /** Does nothing, for tests. */
+ override link(_version: string): Promise {
+ return Promise.resolve();
+ }
+}
+
+/** a test tool */
+@injectable()
+@injectFromHierarchy()
+class ParentInstallService extends TestInstallService {
+ override readonly name = 'parent';
+}
+
+/** another test tool */
+@injectable()
+@injectFromHierarchy()
+class ChildInstallService extends TestInstallService {
+ override readonly name = 'child';
+}
+
+/** a third test tool */
+@injectable()
+@injectFromHierarchy()
+class PlainInstallService extends TestInstallService {
+ override readonly name = 'plain';
+}
+
+/** a tool linked under another name, like `java-jdk` as `java` */
+@injectable()
+@injectFromHierarchy()
+class AliasedInstallService extends TestInstallService {
+ override readonly name = 'aliased';
+
+ /** Linked as `alias`. */
+ override get alias(): string {
+ return 'alias';
+ }
+}
+
+/** a tool which can only be uninstalled as root, like `git` */
+@injectable()
+@injectFromHierarchy()
+class RootOnlyInstallService extends TestInstallService {
+ override readonly name = 'root-only';
+
+ override readonly needsRoot = true;
+}
+
+/** a tool which is installed system wide, like `git` */
+@injectable()
+@injectFromHierarchy()
+class NoUninstallInstallService extends TestInstallService {
+ override readonly name = 'no-uninstall';
+
+ override readonly canUninstall = false;
+}
+
+describe('cli/cleanup/cleanup-tool.service', () => {
+ let svc: CleanupToolService;
+ let ver: VersionService;
+
+ beforeAll(async () => {
+ await ensurePaths(['opt/containerbase/data', 'var/lib/containerbase']);
+ });
+
+ beforeEach(async () => {
+ const container = createContainer();
+ container.bind(CleanupToolService).toSelf();
+ container.bind(InstallToolService).toSelf();
+ container.bind(V1ToolInstallService).toSelf();
+ container.bind(INSTALL_TOOL_TOKEN).to(ParentInstallService);
+ container.bind(INSTALL_TOOL_TOKEN).to(ChildInstallService);
+ container.bind(INSTALL_TOOL_TOKEN).to(PlainInstallService);
+ container.bind(INSTALL_TOOL_TOKEN).to(AliasedInstallService);
+ container.bind(INSTALL_TOOL_TOKEN).to(RootOnlyInstallService);
+ container.bind(INSTALL_TOOL_TOKEN).to(NoUninstallInstallService);
+ svc = await container.getAsync(CleanupToolService);
+ ver = await container.getAsync(VersionService);
+ // every test starts without installed or linked tools
+ await ver.removeInstalled({});
+ const state = await (await container.getAsync(DataService)).load('state');
+ await state.removeAsync({}, { multi: true });
+ });
+
+ /** Records an installed version. */
+ async function add(
+ name: string,
+ version: string,
+ parent?: ToolVersion['parent'],
+ ): Promise {
+ await ver.addInstalled({ name, version, ...(parent && { parent }) });
+ }
+
+ /** Makes a version the linked one, under the alias when given. */
+ async function link(
+ name: string,
+ version: string,
+ alias = name,
+ ): Promise {
+ await ver.setCurrent({ name: alias, tool: { name, version } });
+ }
+
+ /** The installed versions of a tool. */
+ async function installed(name: string): Promise {
+ return (await ver.findInstalled(name)).map((v) => v.version).sort();
+ }
+
+ test('removes unlinked versions with their childs', async () => {
+ await add('parent', '1.0.0');
+ await link('parent', '1.0.0');
+ await add('parent', '2.0.0');
+ await add('plain', '1.0.0', { name: 'parent', version: '2.0.0' });
+ await add('child', '1.0.0', { name: 'parent', version: '2.0.0' });
+ // a child installed for two versions, which are both removed
+ await add('plain', '1.0.0', { name: 'child', version: '1.0.0' });
+
+ expect(await svc.cleanupTools()).toBeUndefined();
+
+ expect(await installed('parent')).toEqual(['1.0.0']);
+ expect(await installed('plain')).toEqual([]);
+ expect(await installed('child')).toEqual([]);
+ expect(logger.info).toHaveBeenCalledWith(
+ 'Uninstalling unlinked tool parent@2.0.0...',
+ );
+ // already removed as a child of `parent@2.0.0`
+ expect(logger.info).not.toHaveBeenCalledWith(
+ 'Uninstalling unlinked tool plain@1.0.0...',
+ );
+ });
+
+ test('keeps versions with a linked child', async () => {
+ await add('parent', '1.0.0');
+ await link('parent', '1.0.0');
+ await add('parent', '0.9.0');
+ await add('plain', '2.0.0', { name: 'parent', version: '1.0.0' });
+ await link('plain', '2.0.0');
+ await add('plain', '1.0.0', { name: 'parent', version: '0.9.0' });
+ await add('plain', '1.5.0', { name: 'parent', version: '0.9.0' });
+ // the linked grand child keeps both versions above it
+ await add('child', '3.0.0', { name: 'plain', version: '1.0.0' });
+ await link('child', '3.0.0');
+
+ expect(await svc.cleanupTools()).toBeUndefined();
+
+ expect(await installed('parent')).toEqual(['0.9.0', '1.0.0']);
+ // the unlinked child of a kept version is kept as well
+ expect(await installed('plain')).toEqual(['1.0.0', '1.5.0', '2.0.0']);
+ expect(await installed('child')).toEqual(['3.0.0']);
+ expect(logger.info).toHaveBeenCalledWith(
+ { tool: 'parent', version: '0.9.0' },
+ 'keeping unlinked tool version, a child tool version is linked',
+ );
+ expect(logger.info).toHaveBeenCalledWith(
+ { tool: 'plain', version: '1.0.0' },
+ 'keeping unlinked tool version, a child tool version is linked',
+ );
+ });
+
+ test('keeps versions with a kept child', async () => {
+ await add('parent', '1.0.0');
+ await link('parent', '1.0.0');
+ await add('parent', '0.8.0');
+ await add('root-only', '1.0.0', { name: 'parent', version: '0.8.0' });
+ await add('plain', '3.0.0', { name: 'parent', version: '0.8.0' });
+ await add('child', '9.0.0');
+ await link('child', '9.0.0');
+ await add('child', '0.5.0');
+ // uninstalling `child@0.5.0` would remove it for `parent@0.8.0` too
+ await add('plain', '3.0.0', { name: 'child', version: '0.5.0' });
+
+ expect(await svc.cleanupTools()).toBeUndefined();
+
+ expect(await installed('parent')).toEqual(['0.8.0', '1.0.0']);
+ expect(await installed('plain')).toEqual(['3.0.0', '3.0.0']);
+ expect(await installed('child')).toEqual(['0.5.0', '9.0.0']);
+ expect(logger.info).toHaveBeenCalledWith(
+ { tool: 'parent', version: '0.8.0' },
+ 'keeping unlinked tool version, it or a child tool cannot be uninstalled',
+ );
+ expect(logger.info).toHaveBeenCalledWith(
+ { tool: 'child', version: '0.5.0' },
+ 'keeping unlinked tool version, a child tool version is kept',
+ );
+ });
+
+ test('looks the linked version up by the tool name', async () => {
+ await add('aliased', '1.0.0');
+ await add('aliased', '2.0.0');
+ await link('aliased', '2.0.0', 'alias');
+
+ expect(await svc.cleanupTools()).toBeUndefined();
+
+ expect(await installed('aliased')).toEqual(['2.0.0']);
+ });
+
+ test('skips tools which cannot be uninstalled', async () => {
+ await add('legacy', '1.0.0');
+ await add('no-uninstall', '1.0.0');
+ await add('root-only', '2.0.0');
+
+ expect(await svc.cleanupTools()).toBeUndefined();
+
+ expect(await installed('legacy')).toEqual(['1.0.0']);
+ expect(await installed('no-uninstall')).toEqual(['1.0.0']);
+ expect(await installed('root-only')).toEqual(['2.0.0']);
+ expect(logger.fatal).not.toHaveBeenCalled();
+
+ // root can uninstall root only tools
+ vi.spyOn(EnvService.prototype, 'isRoot', 'get').mockReturnValue(true);
+ expect(await svc.cleanupTools()).toBeUndefined();
+ expect(await installed('root-only')).toEqual([]);
+ });
+
+ test('dry run', async () => {
+ await add('parent', '1.0.0');
+ await link('parent', '1.0.0');
+ await add('parent', '2.0.0');
+
+ expect(await svc.cleanupTools(true)).toBeUndefined();
+
+ expect(await installed('parent')).toEqual(['1.0.0', '2.0.0']);
+ expect(logger.info).toHaveBeenCalledWith(
+ 'Dry run: uninstall tool parent ...',
+ );
+ });
+
+ test('continues after a failed uninstall and returns the first exit code', async () => {
+ await add('parent', '1.0.0');
+ await link('parent', '1.0.0');
+ await add('parent', '2.0.0');
+ await add('parent', '3.0.0');
+ await add('plain', '1.0.0');
+ vi.spyOn(InstallToolService.prototype, 'uninstall')
+ .mockResolvedValueOnce(2)
+ .mockResolvedValueOnce(3);
+
+ expect(await svc.cleanupTools()).toBe(2);
+
+ expect(await installed('parent')).toEqual(['1.0.0', '2.0.0', '3.0.0']);
+ expect(await installed('plain')).toEqual([]);
+ expect(logger.warn).toHaveBeenCalledExactlyOnceWith(
+ { tools: ['parent@2.0.0', 'parent@3.0.0'] },
+ 'unlinked tool versions left installed',
+ );
+ });
+
+ test('continues after an uninstall error and returns 1', async () => {
+ await add('parent', '1.0.0');
+ await link('parent', '1.0.0');
+ await add('parent', '2.0.0');
+ await add('plain', '1.0.0');
+ const err = new Error('db failed');
+ vi.spyOn(InstallToolService.prototype, 'uninstall').mockRejectedValueOnce(
+ err,
+ );
+
+ expect(await svc.cleanupTools()).toBe(1);
+
+ expect(await installed('parent')).toEqual(['1.0.0', '2.0.0']);
+ expect(await installed('plain')).toEqual([]);
+ expect(logger.warn).toHaveBeenCalledWith(
+ { tool: 'parent', version: '2.0.0', err },
+ 'cannot uninstall unlinked tool version',
+ );
+ expect(logger.warn).toHaveBeenCalledWith(
+ { tools: ['parent@2.0.0'] },
+ 'unlinked tool versions left installed',
+ );
+ });
+});
diff --git a/src/cli/cleanup/cleanup-tool.service.ts b/src/cli/cleanup/cleanup-tool.service.ts
new file mode 100644
index 0000000000..f4857cf69a
--- /dev/null
+++ b/src/cli/cleanup/cleanup-tool.service.ts
@@ -0,0 +1,175 @@
+import { inject, injectable, multiInject, optional } from 'inversify';
+import type { BaseInstallService } from '../install-tool/base-install.service.ts';
+import {
+ INSTALL_TOOL_TOKEN,
+ InstallToolService,
+} from '../install-tool/install-tool.service.ts';
+import { EnvService, VersionService } from '../services/index.ts';
+import type { Tool } from '../services/version.schema.ts';
+import { logger } from '../utils/index.ts';
+
+/** The key of a tool version in sets, eg. `node@22.0.0`. */
+function toolKey({ name, version }: Tool): string {
+ return `${name}@${version}`;
+}
+
+/**
+ * The child versions of a tool version, also transitively, from the map of
+ * parent version keys to their direct child versions.
+ */
+function findChilds(tool: Tool, childMap: Map): Tool[] {
+ // starts with the tool itself, which ends a cycle
+ const found = new Map([[toolKey(tool), tool]]);
+ const queue = [tool];
+ for (let parent = queue.shift(); parent; parent = queue.shift()) {
+ for (const child of childMap.get(toolKey(parent)) ?? []) {
+ const key = toolKey(child);
+ if (!found.has(key)) {
+ found.set(key, child);
+ queue.push(child);
+ }
+ }
+ }
+ return Array.from(found.values()).slice(1);
+}
+
+@injectable()
+export class CleanupToolService {
+ @multiInject(INSTALL_TOOL_TOKEN)
+ @optional()
+ private readonly toolSvcs: BaseInstallService[] = [];
+ @inject(EnvService)
+ private readonly envSvc!: EnvService;
+ @inject(InstallToolService)
+ private readonly installSvc!: InstallToolService;
+ @inject(VersionService)
+ private readonly versionSvc!: VersionService;
+
+ /**
+ * Uninstalls every installed tool version which is not the linked one of
+ * its tool, with all its child versions. The linked version is looked up
+ * by the tool name, not by the alias it's linked as, eg. `java-jdk` for
+ * `java`.
+ *
+ * A version is kept with all its child versions when one of them, also
+ * transitively, is the linked version of its tool, eg. an unlinked node
+ * version with the linked pnpm version installed for it. It's also kept
+ * when it or a child version can't be uninstalled, eg. a v1 shell tool or
+ * a root only tool when not running as root, or when a child version is
+ * kept for another version, as uninstalling removes a version for all
+ * parents.
+ *
+ * A version which fails to uninstall, with an exit code or an error, is
+ * logged and the others are still uninstalled.
+ *
+ * @returns the first exit code of a version which could not be uninstalled,
+ * `1` for an error
+ */
+ async cleanupTools(dryRun = false): Promise {
+ const tools = await this.versionSvc.listInstalled();
+ const linked = new Map(tools.map(({ name, version }) => [name, version]));
+
+ const childMap = new Map();
+ for (const { name, versions } of tools) {
+ for (const { version, parent } of versions) {
+ if (parent) {
+ const key = toolKey(parent);
+ childMap.set(key, [...(childMap.get(key) ?? []), { name, version }]);
+ }
+ }
+ }
+
+ const candidates: { tool: Tool; childs: Tool[] }[] = [];
+ for (const { name, version: current, versions } of tools) {
+ for (const version of new Set(versions.map((v) => v.version))) {
+ if (version !== current) {
+ const tool = { name, version };
+ candidates.push({ tool, childs: findChilds(tool, childMap) });
+ }
+ }
+ }
+
+ const kept = new Set();
+ /** Keeps the version with its child versions and logs why. */
+ const keep = (tool: Tool, childs: Tool[], reason: string): void => {
+ logger.info(
+ { tool: tool.name, version: tool.version },
+ `keeping unlinked tool version, ${reason}`,
+ );
+ for (const t of [tool, ...childs]) {
+ kept.add(toolKey(t));
+ }
+ };
+ for (const { tool, childs } of candidates) {
+ if (childs.some((child) => linked.get(child.name) === child.version)) {
+ keep(tool, childs, 'a child tool version is linked');
+ } else if (![tool, ...childs].every((t) => this.canUninstall(t.name))) {
+ keep(tool, childs, 'it or a child tool cannot be uninstalled');
+ }
+ }
+ // keeping a version can keep the versions it's a child of
+ for (let changed = true; changed;) {
+ changed = false;
+ for (const { tool, childs } of candidates) {
+ if (
+ !kept.has(toolKey(tool)) &&
+ childs.some((child) => kept.has(toolKey(child)))
+ ) {
+ keep(tool, childs, 'a child tool version is kept');
+ changed = true;
+ }
+ }
+ }
+
+ const removed = new Set();
+ const failed: string[] = [];
+ let code: number | undefined;
+ for (const { tool, childs } of candidates) {
+ const key = toolKey(tool);
+ if (kept.has(key) || removed.has(key)) {
+ continue;
+ }
+ logger.info(`Uninstalling unlinked tool ${key}...`);
+ let res: number | void;
+ try {
+ res = await this.installSvc.uninstall(
+ tool.name,
+ tool.version,
+ dryRun,
+ true,
+ );
+ } catch (err) {
+ logger.warn(
+ { tool: tool.name, version: tool.version, err },
+ 'cannot uninstall unlinked tool version',
+ );
+ res = 1;
+ }
+ if (res) {
+ failed.push(key);
+ code ??= res;
+ continue;
+ }
+ for (const t of [tool, ...childs]) {
+ removed.add(toolKey(t));
+ }
+ }
+ if (failed.length) {
+ logger.warn({ tools: failed }, 'unlinked tool versions left installed');
+ }
+ return code;
+ }
+
+ /**
+ * Whether the tool can be uninstalled, which v1 shell tools, tools like
+ * `git` and root only tools when not running as root can't.
+ */
+ private canUninstall(name: string): boolean {
+ const toolSvc = this.toolSvcs.find((t) => t.name === name);
+ return (
+ !!toolSvc &&
+ toolSvc.canUninstall &&
+ (!toolSvc.needsRoot || this.envSvc.isRoot)
+ );
+ }
+}
diff --git a/src/cli/cleanup/index.spec.ts b/src/cli/cleanup/index.spec.ts
index 8d98cb758d..533406998c 100644
--- a/src/cli/cleanup/index.spec.ts
+++ b/src/cli/cleanup/index.spec.ts
@@ -1,20 +1,87 @@
import fs from 'node:fs/promises';
import { join } from 'node:path';
import { beforeAll, describe, expect, test, vi } from 'vitest';
-import { CacheService } from '../services/index.ts';
+import { installTool } from '../install-tool/index.ts';
+import {
+ CacheService,
+ PathService,
+ VersionService,
+ createContainer,
+} from '../services/index.ts';
import { logger, pathExists } from '../utils/index.ts';
-import { cleanupCache, cleanupDownloads } from './index.ts';
+import { cleanupCache, cleanupDownloads, cleanupTools } from './index.ts';
import { ensurePaths, rootPath } from '~test/path.ts';
+vi.mock('del');
+vi.mock('execa');
+vi.mock('../tools/bun.ts');
+
describe('cli/cleanup/index', () => {
beforeAll(async () => {
await ensurePaths([
+ 'opt/containerbase/bin',
+ 'opt/containerbase/data',
'tmp/containerbase/cache',
+ 'tmp/containerbase/tool.init.d',
'usr/local/containerbase/tools/v2',
- 'var/lib/containerbase',
+ 'var/lib/containerbase/tool.prep.d',
]);
});
+ describe('cleanupTools', () => {
+ test('removes the unlinked versions', async () => {
+ const verSvc = await createContainer().getAsync(VersionService);
+ expect(await installTool('bun', '3.0.0')).toBeUndefined();
+ expect(await installTool('bun', '3.0.1')).toBeUndefined();
+ // an unlinked version with a child which has no install service
+ await verSvc.addInstalled({ name: 'bun', version: '2.9.0' });
+ await verSvc.addInstalled({
+ name: 'bun-plugin',
+ version: '1.0.0',
+ parent: { name: 'bun', version: '2.9.0' },
+ });
+
+ expect(await cleanupTools(true)).toBeUndefined();
+ expect(await verSvc.isInstalled({ name: 'bun', version: '3.0.0' })).toBe(
+ true,
+ );
+
+ expect(await cleanupTools()).toBeUndefined();
+ expect(await verSvc.isInstalled({ name: 'bun', version: '3.0.0' })).toBe(
+ false,
+ );
+ expect(await verSvc.isInstalled({ name: 'bun', version: '3.0.1' })).toBe(
+ true,
+ );
+ // `bun-plugin` has no install service, so its parent is kept
+ expect(await verSvc.isInstalled({ name: 'bun', version: '2.9.0' })).toBe(
+ true,
+ );
+ });
+
+ test('a dry run does not write the databases', async () => {
+ const { dataPath } = await createContainer().getAsync(PathService);
+ const files = (await fs.readdir(dataPath))
+ .filter((file) => file.endsWith('.nedb'))
+ .map((file) => join(dataPath, file));
+ /** The modification times of the database files. */
+ const mtimes = (): Promise =>
+ Promise.all(files.map(async (file) => (await fs.stat(file)).mtimeMs));
+ const before = await mtimes();
+ // fresh modules, so the shared data service loads the databases again
+ vi.resetModules();
+ const services = await import('../services/index.ts');
+ const setOwner = vi.spyOn(services.PathService.prototype, 'setOwner');
+ const fresh = await import('./index.ts');
+
+ expect(await fresh.cleanupTools(true)).toBeUndefined();
+
+ expect(files).not.toEqual([]);
+ expect(await mtimes()).toEqual(before);
+ expect(setOwner).not.toHaveBeenCalled();
+ });
+ });
+
describe('cleanupCache', () => {
test('removes the caches of the registered tools', async () => {
const cache = rootPath('tmp/containerbase/cache/.cargo/registry');
diff --git a/src/cli/cleanup/index.ts b/src/cli/cleanup/index.ts
index 43b40daf07..684b38786b 100644
--- a/src/cli/cleanup/index.ts
+++ b/src/cli/cleanup/index.ts
@@ -1,7 +1,26 @@
-import { prepareInstallContainer } from '../install-tool/index.ts';
+import {
+ prepareInstallContainer,
+ prepareUninstallContainer,
+} from '../install-tool/index.ts';
import { CacheService, createContainer } from '../services/index.ts';
import { logger } from '../utils/index.ts';
import { CleanupCacheService } from './cleanup-cache.service.ts';
+import { CleanupToolService } from './cleanup-tool.service.ts';
+
+/**
+ * Uninstalls all unlinked tool versions, see
+ * `CleanupToolService.cleanupTools`. Generic install services are registered
+ * for every installed `gem`, `npm` or `pip` package.
+ *
+ * @throws when the containerbase folders are not writable, except for a dry
+ * run
+ */
+export async function cleanupTools(dryRun = false): Promise {
+ const container = await prepareUninstallContainer(dryRun);
+ container.bind(CleanupToolService).toSelf();
+ const svc = await container.getAsync(CleanupToolService);
+ return svc.cleanupTools(dryRun);
+}
/**
* Removes the known tool caches, see `CleanupCacheService.cleanupCache`, and
diff --git a/src/cli/command/cleanup-tool.spec.ts b/src/cli/command/cleanup-tool.spec.ts
new file mode 100644
index 0000000000..0ff2e856c9
--- /dev/null
+++ b/src/cli/command/cleanup-tool.spec.ts
@@ -0,0 +1,46 @@
+import { describe, expect, test, vi } from 'vitest';
+import { NotSupported } from '../utils/codes.ts';
+import { logger } from '../utils/index.ts';
+import { testCli } from '~test/di.ts';
+
+const mocks = vi.hoisted(() => ({
+ cleanupTools: vi.fn(),
+}));
+
+vi.mock('../cleanup/index.ts', () => mocks);
+
+describe('cli/command/cleanup-tool', () => {
+ test('works', async () => {
+ const cli = testCli('containerbase-cli');
+
+ expect(await cli.run(['cleanup', 'tool'])).toBe(0);
+ expect(mocks.cleanupTools).toHaveBeenCalledExactlyOnceWith(false);
+ expect(logger.info).toHaveBeenCalledWith(
+ expect.stringContaining('Cleanup tool succeeded'),
+ );
+
+ expect(await cli.run(['cleanup', 'tool', '--dry-run'])).toBe(0);
+ expect(mocks.cleanupTools).toHaveBeenLastCalledWith(true);
+ });
+
+ test('fails', async () => {
+ const cli = testCli('containerbase-cli');
+
+ // a non-zero exit code from the cleanup is reported as a failure
+ mocks.cleanupTools.mockResolvedValueOnce(NotSupported);
+ expect(await cli.run(['cleanup', 'tool'])).toBe(NotSupported);
+ expect(logger.fatal).toHaveBeenCalledWith(
+ expect.stringContaining('Cleanup tool failed'),
+ );
+
+ mocks.cleanupTools.mockRejectedValueOnce(new Error('test'));
+ expect(await cli.run(['cleanup', 'tool'])).toBe(1);
+ expect(logger.error).toHaveBeenCalledWith('test');
+
+ // a rejection which is not an `Error` has no message to report
+ mocks.cleanupTools.mockRejectedValueOnce('boom');
+ expect(await cli.run(['cleanup', 'tool'])).toBe(1);
+ expect(logger.debug).toHaveBeenCalledWith('boom');
+ expect(logger.error).not.toHaveBeenCalledWith('boom');
+ });
+});
diff --git a/src/cli/command/cleanup-tool.ts b/src/cli/command/cleanup-tool.ts
new file mode 100644
index 0000000000..3f0515a4b5
--- /dev/null
+++ b/src/cli/command/cleanup-tool.ts
@@ -0,0 +1,58 @@
+import { Command, Option } from 'clipanion';
+import prettyMilliseconds from 'pretty-ms';
+import { cleanupTools } from '../cleanup/index.ts';
+import { logger } from '../utils/index.ts';
+import { command } from './utils.ts';
+
+@command('containerbase-cli')
+export class CleanupToolCommand extends Command {
+ static override paths = [['cleanup', 'tool']];
+
+ static override usage = Command.Usage({
+ description: 'Removes all unlinked tools from `/opt/containerbase/tools`.',
+ examples: [
+ ['Removes all unlinked tool versions', '$0 cleanup tool'],
+ [
+ 'Logs the tool versions which would be removed',
+ '$0 cleanup tool --dry-run',
+ ],
+ ],
+ });
+
+ dryRun = Option.Boolean('-d,--dry-run', false);
+
+ /**
+ * Uninstalls the unlinked tool versions, returning a non-zero exit code on
+ * failure.
+ */
+ async execute(): Promise {
+ const start = Date.now();
+ let error = false;
+ logger.info('Cleanup tool ...');
+ try {
+ const res = await cleanupTools(this.dryRun);
+ if (res) {
+ error = true;
+ }
+ return res;
+ } catch (err) {
+ error = true;
+ logger.debug(err);
+ if (err instanceof Error) {
+ logger.error(err.message);
+ }
+ return 1;
+ /* v8 ignore next -- coverage bug */
+ } finally {
+ if (error) {
+ logger.fatal(
+ `Cleanup tool failed in ${prettyMilliseconds(Date.now() - start)}.`,
+ );
+ } else {
+ logger.info(
+ `Cleanup tool succeeded in ${prettyMilliseconds(Date.now() - start)}.`,
+ );
+ }
+ }
+ }
+}
diff --git a/src/cli/command/index.ts b/src/cli/command/index.ts
index 56e53c9dd5..b33463ed4b 100644
--- a/src/cli/command/index.ts
+++ b/src/cli/command/index.ts
@@ -1,6 +1,7 @@
import './cleanup-cache.ts';
import './cleanup-downloads.ts';
import './cleanup-path.ts';
+import './cleanup-tool.ts';
import './file-download.ts';
import './file-exists.ts';
import './init-tool.ts';
diff --git a/src/cli/install-tool/index.ts b/src/cli/install-tool/index.ts
index 0f0ff4533e..69caf8a905 100644
--- a/src/cli/install-tool/index.ts
+++ b/src/cli/install-tool/index.ts
@@ -1,6 +1,7 @@
import { Container, injectFromHierarchy, injectable } from 'inversify';
import type { ShellWrapperConfig } from '../services/index.ts';
import {
+ DataService,
IpcClient,
LinkToolService,
PathService,
@@ -491,8 +492,27 @@ export async function uninstallTool({
dryRun = false,
recursive = false,
}: UninstallToolConfig): Promise {
+ const container = await prepareUninstallContainer(dryRun);
+ const svc = await container.getAsync(InstallToolService);
+ return svc.uninstall(tool, version, dryRun, recursive);
+}
+
+/**
+ * Creates the install container with a generic install service for every
+ * installed `gem`, `npm` or `pip` package, so they can be uninstalled. A dry
+ * run opens the databases read-only, so they are never written.
+ *
+ * @throws when the containerbase folders are not writable, except for a dry
+ * run
+ */
+export async function prepareUninstallContainer(
+ dryRun: boolean,
+): Promise {
const container = await prepareInstallContainer();
- if (!dryRun) {
+ if (dryRun) {
+ // only reads, so the databases are never written
+ (await container.getAsync(DataService)).readOnly();
+ } else {
await (await container.getAsync(PathService)).ensureWritable();
}
const verSvc = await container.getAsync(VersionService);
@@ -527,7 +547,5 @@ export async function uninstallTool({
}
}
}
-
- const svc = await container.getAsync(InstallToolService);
- return svc.uninstall(tool, version, dryRun, recursive);
+ return container;
}
diff --git a/src/cli/install-tool/install-tool.service.spec.ts b/src/cli/install-tool/install-tool.service.spec.ts
index ba98999d8e..11ef6911ef 100644
--- a/src/cli/install-tool/install-tool.service.spec.ts
+++ b/src/cli/install-tool/install-tool.service.spec.ts
@@ -6,6 +6,7 @@ import {
CacheService,
EnvService,
LinkToolService,
+ PathService,
VersionService,
createContainer,
} from '../services/index.ts';
@@ -313,8 +314,11 @@ describe('cli/install-tool/install-tool.service', () => {
test('dry run', async () => {
expect(await install.install('bun', '3.0.0')).toBeUndefined();
+ const ensure = vi.spyOn(PathService.prototype, 'ensureBasePaths');
expect(await install.uninstall('bun', '3.0.0', true)).toBeUndefined();
+ // a dry run doesn't create any folders
+ expect(ensure).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
'Dry run: uninstall tool bun ...',
);
diff --git a/src/cli/install-tool/install-tool.service.ts b/src/cli/install-tool/install-tool.service.ts
index 8c9943834b..fb624fddd2 100644
--- a/src/cli/install-tool/install-tool.service.ts
+++ b/src/cli/install-tool/install-tool.service.ts
@@ -219,6 +219,8 @@ export class InstallToolService {
/**
* Removes a tool version, or every installed version when none is given.
+ * A dry run only logs and skips `ensureBasePaths`, so it sets up no tmp
+ * link or base folders.
*
* @returns an exit code when the tool could not be uninstalled, eg. because
* another tool depends on the version
@@ -234,7 +236,10 @@ export class InstallToolService {
'InstallToolService.uninstall: supported tools',
);
- await this.pathSvc.ensureBasePaths();
+ if (!dryRun) {
+ // a dry run sets up no tmp link or base folders
+ await this.pathSvc.ensureBasePaths();
+ }
if (!isNonEmptyStringAndNotWhitespace(version)) {
const versions = new Set();