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();