diff --git a/docs/tools.md b/docs/tools.md
index 1f4f7793a1..143084d3f5 100644
--- a/docs/tools.md
+++ b/docs/tools.md
@@ -16,6 +16,8 @@
- [uninstall-pip](#uninstall-pip-command)
- [List](#list)
- [list-tools](#list-tools-command)
+- [Cleanup](#cleanup)
+ - [cleanup-downloads](#cleanup-downloads-command)
---
@@ -518,3 +520,33 @@ When writing to a file, JSON output is minified.
```bash
containerbase-cli list tools --json --out /tmp/tools.json
```
+
+
+
+## Cleanup
+
+### `containerbase-cli cleanup downloads`
+
+#### Description
+
+Evicts the oldest files from the download cache in `CONTAINERBASE_CACHE_DIR` down to `CONTAINERBASE_MAX_ALLOCATED_DISK`.
+
+It's the same cleanup which runs after each install, see [download cache](../README.md#download-cache): the oldest files are removed while the disk holding the folder is filled to `CONTAINERBASE_MAX_ALLOCATED_DISK` percent or more.
+Nothing is removed when `CONTAINERBASE_CACHE_DIR` isn't set.
+`CONTAINERBASE_MAX_ALLOCATED_DISK` defaults to `100`, so only a full disk is cleaned, set a lower value, eg. `80`, to evict earlier, see [download cache](../README.md#download-cache).
+With `--dry-run` only the oldest file is logged, as nothing is freed to check the fill level again.
+
+#### Usage
+
+`$ containerbase-cli cleanup downloads [-d,--dry-run]`
+
+#### Examples
+
+- Evicts the oldest downloads
+ ```bash
+ containerbase-cli cleanup downloads
+ ```
+- Logs the oldest download which would be removed
+ ```bash
+ containerbase-cli cleanup downloads --dry-run
+ ```
diff --git a/src/cli/cleanup/index.spec.ts b/src/cli/cleanup/index.spec.ts
new file mode 100644
index 0000000000..e61f7b3b4b
--- /dev/null
+++ b/src/cli/cleanup/index.spec.ts
@@ -0,0 +1,31 @@
+import { describe, expect, test, vi } from 'vitest';
+import { CacheService } from '../services/index.ts';
+import { logger } from '../utils/index.ts';
+import { cleanupDownloads } from './index.ts';
+
+describe('cli/cleanup/index', () => {
+ describe('cleanupDownloads', () => {
+ test('evicts the download cache', async () => {
+ const spy = vi
+ .spyOn(CacheService.prototype, 'evict')
+ .mockResolvedValue(['/cache/a']);
+
+ expect(await cleanupDownloads(true)).toEqual(['/cache/a']);
+ expect(spy).toHaveBeenCalledExactlyOnceWith(true);
+ // the dry run file is logged by `evict` already
+ expect(logger.info).not.toHaveBeenCalled();
+
+ expect(await cleanupDownloads()).toEqual(['/cache/a']);
+ expect(logger.info).toHaveBeenCalledExactlyOnceWith(
+ 'Removed 1 downloads',
+ );
+ });
+
+ test('logs when there is nothing to evict', async () => {
+ vi.spyOn(CacheService.prototype, 'evict').mockResolvedValue([]);
+
+ expect(await cleanupDownloads()).toEqual([]);
+ expect(logger.info).toHaveBeenCalledExactlyOnceWith('Nothing to evict');
+ });
+ });
+});
diff --git a/src/cli/cleanup/index.ts b/src/cli/cleanup/index.ts
new file mode 100644
index 0000000000..d8655d2ff0
--- /dev/null
+++ b/src/cli/cleanup/index.ts
@@ -0,0 +1,21 @@
+import { CacheService, createContainer } from '../services/index.ts';
+import { logger } from '../utils/index.ts';
+
+/**
+ * Evicts the oldest files from the download cache, see `CacheService.evict`,
+ * and logs how many were removed.
+ *
+ * @returns the removed files, with `dryRun` the file which would be removed
+ * first
+ */
+export async function cleanupDownloads(dryRun = false): Promise {
+ const svc = await createContainer().getAsync(CacheService);
+ const removed = await svc.evict(dryRun);
+ if (!removed.length) {
+ logger.info('Nothing to evict');
+ } else if (!dryRun) {
+ // a dry run only returns the oldest file, which `evict` already logs
+ logger.info(`Removed ${removed.length} downloads`);
+ }
+ return removed;
+}
diff --git a/src/cli/command/cleanup-downloads.spec.ts b/src/cli/command/cleanup-downloads.spec.ts
new file mode 100644
index 0000000000..3d31ca88ec
--- /dev/null
+++ b/src/cli/command/cleanup-downloads.spec.ts
@@ -0,0 +1,50 @@
+import { describe, expect, test, vi } from 'vitest';
+import { logger } from '../utils/index.ts';
+import { testCli } from '~test/di.ts';
+
+const mocks = vi.hoisted(() => ({
+ cleanupDownloads: vi.fn(),
+}));
+
+vi.mock('../cleanup/index.ts', () => mocks);
+
+describe('cli/command/cleanup-downloads', () => {
+ test('works', async () => {
+ const cli = testCli('containerbase-cli');
+ mocks.cleanupDownloads.mockResolvedValueOnce(['/cache/a', '/cache/b']);
+
+ expect(await cli.run(['cleanup', 'downloads'])).toBe(0);
+
+ expect(mocks.cleanupDownloads).toHaveBeenCalledExactlyOnceWith(false);
+ expect(logger.info).toHaveBeenCalledWith(
+ expect.stringContaining('Cleanup downloads succeeded'),
+ );
+ });
+
+ test('dry run', async () => {
+ const cli = testCli('containerbase-cli');
+ mocks.cleanupDownloads.mockResolvedValueOnce(['/cache/a']);
+
+ expect(await cli.run(['cleanup', 'downloads', '-d'])).toBe(0);
+
+ expect(mocks.cleanupDownloads).toHaveBeenCalledExactlyOnceWith(true);
+ });
+
+ test('fails', async () => {
+ const cli = testCli('containerbase-cli');
+ mocks.cleanupDownloads.mockRejectedValueOnce(new Error('test'));
+
+ expect(await cli.run(['cleanup', 'downloads'])).toBe(1);
+
+ expect(logger.error).toHaveBeenCalledWith('test');
+ expect(logger.fatal).toHaveBeenCalledWith(
+ expect.stringContaining('Cleanup downloads failed'),
+ );
+
+ // a rejection which is not an `Error` has no message to report
+ mocks.cleanupDownloads.mockRejectedValueOnce('boom');
+ expect(await cli.run(['cleanup', 'downloads'])).toBe(1);
+ expect(logger.debug).toHaveBeenCalledWith('boom');
+ expect(logger.error).not.toHaveBeenCalledWith('boom');
+ });
+});
diff --git a/src/cli/command/cleanup-downloads.ts b/src/cli/command/cleanup-downloads.ts
new file mode 100644
index 0000000000..6fc3170524
--- /dev/null
+++ b/src/cli/command/cleanup-downloads.ts
@@ -0,0 +1,56 @@
+import { Command, Option } from 'clipanion';
+import prettyMilliseconds from 'pretty-ms';
+import { cleanupDownloads } from '../cleanup/index.ts';
+import { logger } from '../utils/index.ts';
+import { command } from './utils.ts';
+
+@command('containerbase-cli')
+export class CleanupDownloadsCommand extends Command {
+ static override paths = [['cleanup', 'downloads']];
+
+ static override usage = Command.Usage({
+ description:
+ 'Evicts the oldest files from `CONTAINERBASE_CACHE_DIR` down to `CONTAINERBASE_MAX_ALLOCATED_DISK`.',
+ examples: [
+ ['Evicts the oldest downloads', '$0 cleanup downloads'],
+ [
+ 'Logs the oldest download which would be removed',
+ '$0 cleanup downloads --dry-run',
+ ],
+ ],
+ });
+
+ dryRun = Option.Boolean('-d,--dry-run', false);
+
+ /**
+ * Evicts the oldest files from the download cache, returning a non-zero exit
+ * code on failure.
+ */
+ async execute(): Promise {
+ const start = Date.now();
+ let error = false;
+ logger.info('Cleanup downloads ...');
+ try {
+ await cleanupDownloads(this.dryRun);
+ return 0;
+ } 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 downloads failed in ${prettyMilliseconds(Date.now() - start)}.`,
+ );
+ } else {
+ logger.info(
+ `Cleanup downloads succeeded in ${prettyMilliseconds(Date.now() - start)}.`,
+ );
+ }
+ }
+ }
+}
diff --git a/src/cli/command/index.ts b/src/cli/command/index.ts
index 4a1f5fa391..8a69ec460b 100644
--- a/src/cli/command/index.ts
+++ b/src/cli/command/index.ts
@@ -1,3 +1,4 @@
+import './cleanup-downloads.ts';
import './cleanup-path.ts';
import './file-download.ts';
import './file-exists.ts';