From a876c09b03857abed094fc5b52b9bf58cd1f5ff3 Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Tue, 22 Sep 2026 11:16:59 +0200 Subject: [PATCH 1/8] feat: add `@containerbase/base` package with the supported tool list Generates the list of tools `install-tool` accepts, together with their install type, parent and deprecation state, from the install services and the `ResolverMap`/`DeprecatedTools` maps. The data ships as a workspace package, so consumers like Renovate can type against it and compare its version with the containerbase version deployed in an image. The v1 shell tools are left out, they need root privileges and can't be installed on the fly. Refs: #6166 Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 1 + .gitignore | 1 + docs/new-tool.md | 2 + eslint.config.js | 1 + package.json | 7 ++- packages/base/README.md | 48 ++++++++++++++++ packages/base/data/tools.json | 80 ++++++++++++++++++++++++++ packages/base/data/tools.schema.json | 33 +++++++++++ packages/base/package.json | 48 ++++++++++++++++ packages/base/src/data.ts | 84 ++++++++++++++++++++++++++++ packages/base/src/index.ts | 14 +++++ packages/base/src/schema.ts | 37 ++++++++++++ packages/base/src/types.ts | 22 ++++++++ packages/base/src/zod.ts | 10 ++++ packages/base/tsconfig.json | 11 ++++ pnpm-lock.yaml | 6 ++ pnpm-workspace.yaml | 3 +- src/cli/install-tool/index.spec.ts | 25 +++++++++ src/cli/install-tool/index.ts | 84 +++++++++++++++++++++++++++- src/cli/tools/index.ts | 4 +- tools/tools.ts | 68 ++++++++++++++++++++++ tsconfig.json | 9 ++- 22 files changed, 591 insertions(+), 7 deletions(-) create mode 100644 packages/base/README.md create mode 100644 packages/base/data/tools.json create mode 100644 packages/base/data/tools.schema.json create mode 100644 packages/base/package.json create mode 100644 packages/base/src/data.ts create mode 100644 packages/base/src/index.ts create mode 100644 packages/base/src/schema.ts create mode 100644 packages/base/src/types.ts create mode 100644 packages/base/src/zod.ts create mode 100644 packages/base/tsconfig.json create mode 100644 tools/tools.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8519711b6f..2d7b0d31bd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,6 +52,7 @@ jobs: pnpm lint:markdown pnpm lint:types pnpm lint:schema + pnpm lint:tools pnpm lint:versions - name: shellcheck diff --git a/.gitignore b/.gitignore index 026d4399d5..7d1729ffaa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ /bin/ /tmp/ /dist/ +/packages/*/dist/ /coverage /html/ diff --git a/docs/new-tool.md b/docs/new-tool.md index 1120c304e7..508d4e64f5 100644 --- a/docs/new-tool.md +++ b/docs/new-tool.md @@ -62,6 +62,7 @@ Files to change: ``` 1. [`.github/renovate.json`](../.github/renovate.json) - add the tool name to **both** `matchDepNames` lists (the "Don't separate minor and patch updates in tests" rule and the "Automerge test selected minor updates in tests" rule). +1. [`packages/base`](../packages/base/) - run `pnpm tools` and commit the regenerated files, so the new tool shows up in the published tool list. `pnpm lint:tools` fails if you forget. Note the arm64 test files use one image stage per tool, terminating in a `COPY --from=test- /.dummy /.dummy` line in the final stage - add both halves. @@ -219,6 +220,7 @@ Resolvers are bound with `container.bind(TOOL_VERSION_RESOLVER).to(...)` in the ``` 1. [`.github/renovate.json`](../.github/renovate.json) - add the tool name to both `matchDepNames` lists. +1. [`packages/base`](../packages/base/) - run `pnpm tools` and commit the regenerated files, so the new tool shows up in the published tool list. `pnpm lint:tools` fails if you forget. > [!NOTE] > You'll notice that we lean on integration tests with Docker instead of unit tests. diff --git a/eslint.config.js b/eslint.config.js index 63fd93a39f..7b92546822 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,7 @@ export default tseslint.config( { ignores: [ 'dist/', + 'packages/*/dist/', 'tmp/', 'bin/', 'coverage/', diff --git a/package.json b/package.json index 9a1243f2ab..b661ba9d1b 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,14 @@ "scripts": { "bats": "node tools/bats.js --timing --verbose-run", "build": "run-s 'build:*'", + "build:base": "tsc -p packages/base", "build:cli": "node tools/build.js", "eslint": "eslint --cache .", "eslint-fix": "eslint --cache --fix .", - "lint": "run-s prettier eslint lint:types lint:markdown lint:schema lint:versions", + "lint": "run-s prettier eslint lint:types lint:markdown lint:schema lint:tools lint:versions", "lint:markdown": "markdownlint-cli2 '**/*.md'", "lint:schema": "pnpm schema && git diff --exit-code docs/list-tools.schema.json", + "lint:tools": "pnpm tools && git diff --exit-code packages/base", "lint:types": "run-p 'lint:types:*'", "lint:types:default": "tsc", "lint:types:dist": "tsc -p tsconfig.dist.json", @@ -34,7 +36,8 @@ "test": "run-s 'test:*'", "test:bats": "node tools/bats.js --timing --verbose-run test/bash/ test/bash/v2", "test:docker": "node tools/test.js", - "test:vitest": "vitest run --coverage" + "test:vitest": "vitest run --coverage", + "tools": "tsx tools/tools.ts" }, "dependencies": { "@nodable/compact-builder": "2.0.0", diff --git a/packages/base/README.md b/packages/base/README.md new file mode 100644 index 0000000000..89516c4e72 --- /dev/null +++ b/packages/base/README.md @@ -0,0 +1,48 @@ +# `@containerbase/base` + +Metadata about the tools [containerbase](https://github.com/containerbase/base) supports. + +The data is generated from the containerbase sources and released with the same version as containerbase itself. +A consumer can therefore compare this package's version with the version an image reports in `/usr/local/containerbase/version`: if the image version is greater than or equal to the package version, every tool listed here can be installed in that image. + +## Usage + +```ts +import { toolNames, tools, type ToolName } from '@containerbase/base'; + +tools.composer; // { parent: 'php' } +tools.kas; // { type: 'pip', parent: 'python' } + +function install(tool: ToolName): void { + // `ToolName` is a union of all supported names +} +``` + +The raw data and its json schema are also exported, for consumers which don't use TypeScript: + +```ts +import tools from '@containerbase/base/tools.json' with { type: 'json' }; +``` + +The zod schemas live behind a separate entry point, so the default export stays dependency free: + +```ts +import { SupportedTools, ToolName } from '@containerbase/base/zod'; +``` + +## Contents + +Each entry may carry the following metadata: + +| Field | Description | +| ------------ | ------------------------------------------------------------- | +| `type` | the installer used for the tool, one of `gem`, `npm` or `pip` | +| `parent` | the tool it depends on, eg. `composer` depends on `php` | +| `deprecated` | the tool should not be used any more | + +Only the names `install-tool` accepts are listed. +Packages installed with an arbitrary name via `install-gem`, `install-npm` or `install-pip` are not, as that list is unbounded, and neither are the v1 shell tools, which need root privileges and can't be installed on the fly. + +## Development + +The files in `data/` and `src/data.ts` are generated, run `pnpm tools` in the repository root to update them. diff --git a/packages/base/data/tools.json b/packages/base/data/tools.json new file mode 100644 index 0000000000..f6184a21e4 --- /dev/null +++ b/packages/base/data/tools.json @@ -0,0 +1,80 @@ +{ + "tools": { + "android-sdk-cmdline-tools": { "parent": "java" }, + "apko": {}, + "apm": {}, + "bazelisk": {}, + "bower": { "type": "npm", "parent": "node", "deprecated": true }, + "buf": {}, + "buildx": { "parent": "docker" }, + "bun": {}, + "bundler": { "type": "gem", "parent": "ruby" }, + "cabal": {}, + "checkov": { "type": "pip", "parent": "python" }, + "cocoapods": { "parent": "ruby" }, + "composer": { "parent": "php" }, + "conan": { "parent": "python" }, + "copier": { "type": "pip", "parent": "python" }, + "corepack": { "type": "npm", "parent": "node" }, + "dart": {}, + "deno": {}, + "devbox": {}, + "docker": {}, + "docker-compose": { "parent": "docker" }, + "dotnet": {}, + "elixir": { "parent": "erlang" }, + "erlang": {}, + "flutter": {}, + "flux": {}, + "gh": {}, + "ghc": {}, + "git-lfs": { "parent": "git" }, + "gleam": {}, + "golang": {}, + "gradle": { "parent": "java" }, + "hashin": { "type": "pip", "parent": "python" }, + "helm": {}, + "helmfile": {}, + "java": {}, + "java-jdk": {}, + "java-jre": {}, + "jb": {}, + "kas": { "type": "pip", "parent": "python" }, + "kubectl": {}, + "kustomize": {}, + "lerna": { "type": "npm", "parent": "node", "deprecated": true }, + "maven": { "parent": "java" }, + "mise": {}, + "mono": {}, + "nix": {}, + "node": {}, + "npm": { "type": "npm", "parent": "node" }, + "nuget": { "parent": "mono" }, + "paket": { "parent": "dotnet" }, + "pdm": { "type": "pip", "parent": "python" }, + "php": {}, + "pip-tools": { "type": "pip", "parent": "python" }, + "pipenv": { "type": "pip", "parent": "python" }, + "pixi": {}, + "pnpm": { "type": "npm", "parent": "node" }, + "poetry": { "type": "pip", "parent": "python" }, + "powershell": {}, + "protoc": {}, + "python": {}, + "renovate": { "parent": "node" }, + "ruby": {}, + "rust": {}, + "sbt": { "parent": "java" }, + "scala": { "parent": "java" }, + "skopeo": {}, + "sops": {}, + "swift": {}, + "terraform": {}, + "tofu": {}, + "uv": { "type": "pip", "parent": "python" }, + "vendir": {}, + "wally": {}, + "yarn": { "parent": "node" }, + "yarn-slim": { "parent": "node" } + } +} diff --git a/packages/base/data/tools.schema.json b/packages/base/data/tools.schema.json new file mode 100644 index 0000000000..840d5720bb --- /dev/null +++ b/packages/base/data/tools.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "tools": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["gem", "npm", "pip"], + "description": "the installer used for this tool, only set for dynamically installed tools" + }, + "parent": { + "type": "string", + "description": "the tool this tool depends on, eg. composer depends on php" + }, + "deprecated": { + "type": "boolean", + "const": true, + "description": "deprecated tools should not be used any more" + } + }, + "additionalProperties": false + }, + "description": "all supported tools, keyed by tool name" + } + }, + "required": ["tools"], + "additionalProperties": false +} diff --git a/packages/base/package.json b/packages/base/package.json new file mode 100644 index 0000000000..00ba567f09 --- /dev/null +++ b/packages/base/package.json @@ -0,0 +1,48 @@ +{ + "name": "@containerbase/base", + "version": "0.0.0-semantic-release", + "description": "Metadata about the tools supported by containerbase", + "keywords": [ + "containerbase", + "tools" + ], + "homepage": "https://github.com/containerbase/base#readme", + "repository": { + "type": "git", + "url": "https://github.com/containerbase/base.git", + "directory": "packages/base" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./zod": { + "types": "./dist/zod.d.ts", + "default": "./dist/zod.js" + }, + "./tools.json": "./data/tools.json", + "./tools.schema.json": "./data/tools.schema.json" + }, + "files": [ + "data", + "dist" + ], + "devDependencies": { + "zod": "4.6.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/packages/base/src/data.ts b/packages/base/src/data.ts new file mode 100644 index 0000000000..0839657a3c --- /dev/null +++ b/packages/base/src/data.ts @@ -0,0 +1,84 @@ +// generated by `pnpm tools`, do not edit +import type { ToolMetadata } from './types.ts'; + +/** + * All tools `install-tool` supports, sorted by name. + */ +export const tools = { + 'android-sdk-cmdline-tools': { parent: 'java' }, + apko: {}, + apm: {}, + bazelisk: {}, + bower: { type: 'npm', parent: 'node', deprecated: true }, + buf: {}, + buildx: { parent: 'docker' }, + bun: {}, + bundler: { type: 'gem', parent: 'ruby' }, + cabal: {}, + checkov: { type: 'pip', parent: 'python' }, + cocoapods: { parent: 'ruby' }, + composer: { parent: 'php' }, + conan: { parent: 'python' }, + copier: { type: 'pip', parent: 'python' }, + corepack: { type: 'npm', parent: 'node' }, + dart: {}, + deno: {}, + devbox: {}, + docker: {}, + 'docker-compose': { parent: 'docker' }, + dotnet: {}, + elixir: { parent: 'erlang' }, + erlang: {}, + flutter: {}, + flux: {}, + gh: {}, + ghc: {}, + 'git-lfs': { parent: 'git' }, + gleam: {}, + golang: {}, + gradle: { parent: 'java' }, + hashin: { type: 'pip', parent: 'python' }, + helm: {}, + helmfile: {}, + java: {}, + 'java-jdk': {}, + 'java-jre': {}, + jb: {}, + kas: { type: 'pip', parent: 'python' }, + kubectl: {}, + kustomize: {}, + lerna: { type: 'npm', parent: 'node', deprecated: true }, + maven: { parent: 'java' }, + mise: {}, + mono: {}, + nix: {}, + node: {}, + npm: { type: 'npm', parent: 'node' }, + nuget: { parent: 'mono' }, + paket: { parent: 'dotnet' }, + pdm: { type: 'pip', parent: 'python' }, + php: {}, + 'pip-tools': { type: 'pip', parent: 'python' }, + pipenv: { type: 'pip', parent: 'python' }, + pixi: {}, + pnpm: { type: 'npm', parent: 'node' }, + poetry: { type: 'pip', parent: 'python' }, + powershell: {}, + protoc: {}, + python: {}, + renovate: { parent: 'node' }, + ruby: {}, + rust: {}, + sbt: { parent: 'java' }, + scala: { parent: 'java' }, + skopeo: {}, + sops: {}, + swift: {}, + terraform: {}, + tofu: {}, + uv: { type: 'pip', parent: 'python' }, + vendir: {}, + wally: {}, + yarn: { parent: 'node' }, + 'yarn-slim': { parent: 'node' }, +} as const satisfies Record; diff --git a/packages/base/src/index.ts b/packages/base/src/index.ts new file mode 100644 index 0000000000..f915d8bccc --- /dev/null +++ b/packages/base/src/index.ts @@ -0,0 +1,14 @@ +import { tools } from './data.ts'; + +export { tools } from './data.ts'; +export type { InstallToolType, ToolMetadata } from './types.ts'; + +/** + * A tool name `install-tool` accepts. + */ +export type ToolName = keyof typeof tools; + +/** + * All supported tool names, sorted alphabetically. + */ +export const toolNames = Object.keys(tools) as ToolName[]; diff --git a/packages/base/src/schema.ts b/packages/base/src/schema.ts new file mode 100644 index 0000000000..e494a313a3 --- /dev/null +++ b/packages/base/src/schema.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +export const InstallToolType = z + .enum(['gem', 'npm', 'pip']) + .describe('the installer a dynamically installed tool is installed with'); + +/** + * Schema of a single entry of `tools.json`. + * + * Keep in sync with the `ToolMetadata` interface in `types.ts`, which is the + * zod free version used by the default export. + */ +export const ToolMetadata = z.object({ + type: InstallToolType.describe( + 'the installer used for this tool, only set for dynamically installed tools', + ).optional(), + parent: z + .string() + .describe('the tool this tool depends on, eg. composer depends on php') + .optional(), + deprecated: z + .literal(true) + .describe('deprecated tools should not be used any more') + .optional(), +}); + +/** + * Schema of `tools.json`. + * + * The generated json schema lives in `data/tools.schema.json`. + */ +export const SupportedTools = z.object({ + tools: z + .record(z.string(), ToolMetadata) + .describe('all supported tools, keyed by tool name'), +}); +export type SupportedTools = z.infer; diff --git a/packages/base/src/types.ts b/packages/base/src/types.ts new file mode 100644 index 0000000000..44f8e2f87d --- /dev/null +++ b/packages/base/src/types.ts @@ -0,0 +1,22 @@ +/** + * The installer a dynamically installed tool is installed with. + */ +export type InstallToolType = 'gem' | 'npm' | 'pip'; + +/** + * What containerbase knows about a supported tool. + */ +export interface ToolMetadata { + /** + * The installer used for this tool, only set for dynamically installed tools. + */ + type?: InstallToolType; + /** + * The tool this tool depends on, eg. `composer` depends on `php`. + */ + parent?: string; + /** + * Deprecated tools should not be used any more. + */ + deprecated?: true; +} diff --git a/packages/base/src/zod.ts b/packages/base/src/zod.ts new file mode 100644 index 0000000000..383e08a0ac --- /dev/null +++ b/packages/base/src/zod.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { toolNames } from './index.ts'; + +export * from './schema.ts'; + +/** + * Enum of all tool names supported by the containerbase version this package + * was released with. + */ +export const ToolName = z.enum(toolNames); diff --git a/packages/base/tsconfig.json b/packages/base/tsconfig.json new file mode 100644 index 0000000000..aeb6f28dc9 --- /dev/null +++ b/packages/base/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "rewriteRelativeImportExtensions": true + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 478010331d..b75c14904a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -221,6 +221,12 @@ importers: specifier: 4.1.11 version: 4.1.11(@types/node@24.13.4)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.1)(tsx@4.23.13)(yaml@2.9.0)) + packages/base: + devDependencies: + zod: + specifier: 4.6.2 + version: 4.6.2 + packages: '@actions/core@3.0.1': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ce044607a..f807e228d6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ -packages: [] +packages: + - packages/* allowUnusedPatches: true updateNotifier: false diff --git a/src/cli/install-tool/index.spec.ts b/src/cli/install-tool/index.spec.ts index 2e4b20d6ce..41b00e8bd1 100644 --- a/src/cli/install-tool/index.spec.ts +++ b/src/cli/install-tool/index.spec.ts @@ -18,6 +18,7 @@ import { isDockerBuild, logger, pathExists } from '../utils/index.ts'; import { installTool, linkTool, + listSupportedTools, resolveVersion, uninstallTool, } from './index.ts'; @@ -144,6 +145,30 @@ describe('cli/install-tool/index', () => { }); }); + describe('listSupportedTools', () => { + test('works', async () => { + const tools = await listSupportedTools(); + const names = tools.map((t) => t.name); + + expect(names).toEqual( + [...names].sort((a, b) => a.localeCompare(b, 'en', { numeric: true })), + ); + // v2 shell tools are supported + expect(names).toContain('dummy'); + // v1 shell tools need root and can't be installed on the fly + expect(names).not.toContain('leg'); + + expect(tools).toEqual( + expect.arrayContaining([ + { name: 'apko' }, + { name: 'maven', parent: 'java' }, + { name: 'kas', type: 'pip', parent: 'python' }, + { name: 'bower', type: 'npm', parent: 'node', deprecated: true }, + ]), + ); + }); + }); + describe('resolveVersion', () => { test('works', async () => { expect(await resolveVersion('composer', '1.0.0')).toBe('1.0.0'); diff --git a/src/cli/install-tool/index.ts b/src/cli/install-tool/index.ts index 80ea5d39b7..b0e294bcd7 100644 --- a/src/cli/install-tool/index.ts +++ b/src/cli/install-tool/index.ts @@ -38,7 +38,7 @@ import { CabalInstallService } from '../tools/haskell/cabal.ts'; import { GhcInstallService } from '../tools/haskell/ghc.ts'; import { HelmInstallService } from '../tools/helm.ts'; import { HelmfileInstallService } from '../tools/helmfile.ts'; -import { ResolverMap } from '../tools/index.ts'; +import { DeprecatedTools, ResolverMap } from '../tools/index.ts'; import { AndroidSdkCmdlineToolsInstallService, AndroidSdkCmdlineToolsVersionResolver, @@ -114,6 +114,7 @@ import { VendirInstallService } from '../tools/vendir.ts'; import { WallyInstallService } from '../tools/wally.ts'; import { type InstallToolType, logger } from '../utils/index.ts'; import { isNotKnownV2Tool } from '../utils/v2-tool.ts'; +import type { BaseInstallService } from './base-install.service.ts'; import { V1ToolInstallService, V2ToolInstallService, @@ -242,6 +243,87 @@ function prepareResolveContainer(): Container { return container; } +/** + * The parent tool a dynamically installed tool is installed for. + */ +const dynamicParents: Record = { + gem: 'ruby', + npm: 'node', + pip: 'python', +}; + +/** + * A tool `install-tool` accepts by name. + */ +export interface SupportedTool { + /** + * Tool name + */ + name: string; + /** + * The installer used for this tool, only set for dynamically installed tools. + */ + type?: InstallToolType; + /** + * The tool this tool depends on, eg. composer depends on php. + */ + parent?: string; + /** + * Deprecated tools should not be used any more. + */ + deprecated?: true; +} + +/** + * Adds the tools which are implicit mapped to `install-`, they have no + * install service of their own. + */ +function addDynamicTools( + tools: Map, + map: Record, + deprecated?: true, +): void { + for (const [name, type] of Object.entries(map)) { + tools.set(name, { + name, + type, + parent: dynamicParents[type], + ...(deprecated && { deprecated }), + }); + } +} + +/** + * Lists all tools `install-tool` supports, sorted by name. + * + * Tools installed with an arbitrary package name via `install-gem`, + * `install-npm` or `install-pip` are not included, as that list is unbounded. + * The v1 shell tools are not included either, they need root privileges and + * can't be installed on the fly. + */ +export async function listSupportedTools(): Promise { + const container = await prepareInstallContainer(); + const tools = new Map(); + + // the implicit mappings first, a tool with an install service overrides them + addDynamicTools(tools, ResolverMap); + addDynamicTools(tools, DeprecatedTools, true); + + const toolSvcs = + await container.getAllAsync(INSTALL_TOOL_TOKEN); + for (const svc of toolSvcs) { + tools.set(svc.name, { + name: svc.name, + ...(svc.parent && { parent: svc.parent }), + }); + } + + // explicit locale, the generated data must not depend on the system locale + return Array.from(tools.values()).sort((a, b) => + a.name.localeCompare(b.name, 'en', { numeric: true }), + ); +} + export async function installTool( tool: string, version: string, diff --git a/src/cli/tools/index.ts b/src/cli/tools/index.ts index 7e0b817fc6..65efafca11 100644 --- a/src/cli/tools/index.ts +++ b/src/cli/tools/index.ts @@ -67,7 +67,7 @@ export const NoInitTools = [ * Tools in this map are implicit mapped from `install-tool` to `install-`. * So no need for an extra install service. */ -export const ResolverMap: Record = { +export const ResolverMap: Record = { bundler: 'gem', checkov: 'pip', copier: 'pip', @@ -87,7 +87,7 @@ export const ResolverMap: Record = { * This tools are deprecated and should not be used anymore via `install-tool`. * They are implicit mapped from `install-tool` to `install-`. */ -export const DeprecatedTools: Record = { +export const DeprecatedTools: Record = { bower: 'npm', lerna: 'npm', }; diff --git a/tools/tools.ts b/tools/tools.ts new file mode 100644 index 0000000000..961a24e454 --- /dev/null +++ b/tools/tools.ts @@ -0,0 +1,68 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { format, resolveConfig } from 'prettier'; +import { z } from 'zod'; +import type { SupportedTools } from '../packages/base/src/schema.ts'; +import { SupportedTools as SupportedToolsSchema } from '../packages/base/src/schema.ts'; + +// generates the data of the `@containerbase/base` package +const pkgDir = new URL('../packages/base/', import.meta.url); + +// the install services read from and write below ``, so run them +// against a temporary root which only links the shell tools of this repo +const rootDir = await mkdtemp(join(tmpdir(), 'containerbase-tools-')); +await mkdir(join(rootDir, 'usr/local'), { recursive: true }); +await symlink( + fileURLToPath(new URL('../src/usr/local/containerbase', import.meta.url)), + join(rootDir, 'usr/local/containerbase'), + 'dir', +); +globalThis.rootDir = rootDir; + +const { listSupportedTools } = await import('../src/cli/install-tool/index.ts'); + +async function write(file: string, content: string): Promise { + const target = fileURLToPath(new URL(file, pkgDir)); + const options = await resolveConfig(target); + await writeFile( + target, + await format(content, { ...options, filepath: target }), + { encoding: 'utf8' }, + ); +} + +const tools: SupportedTools['tools'] = {}; + +try { + for (const { name, ...metadata } of await listSupportedTools()) { + tools[name] = metadata; + } +} finally { + await rm(rootDir, { recursive: true, force: true }); +} + +await mkdir(new URL('data/', pkgDir), { recursive: true }); + +await write('data/tools.json', JSON.stringify({ tools })); + +await write( + 'data/tools.schema.json', + JSON.stringify(z.toJSONSchema(SupportedToolsSchema)), +); + +await write( + 'src/data.ts', + `// generated by \`pnpm tools\`, do not edit +import type { ToolMetadata } from './types.ts'; + +/** + * All tools \`install-tool\` supports, sorted by name. + */ +export const tools = ${JSON.stringify(tools)} as const satisfies Record< + string, + ToolMetadata +>; +`, +); diff --git a/tsconfig.json b/tsconfig.json index 70b7072f31..330013568e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,12 @@ }, "allowImportingTsExtensions": true }, - "exclude": ["node_modules", "bin", "dist", "coverage", "html"] + "exclude": [ + "node_modules", + "bin", + "dist", + "packages/*/dist", + "coverage", + "html" + ] } From 9004bd0a47018b70de4c2c67eab3b9b5f595ecbb Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Wed, 23 Sep 2026 15:29:25 +0200 Subject: [PATCH 2/8] feat: mark root only tools in the tool list Tools whose install service needs root are listed with `root: true`, so consumers know they can only be installed at image build time. git is the only one so far, which also resolves the `git-lfs` parent. Co-Authored-By: Claude Opus 5.5 --- packages/base/README.md | 13 +++++++------ packages/base/data/tools.json | 1 + packages/base/data/tools.schema.json | 5 +++++ packages/base/src/data.ts | 1 + packages/base/src/schema.ts | 6 ++++++ packages/base/src/types.ts | 4 ++++ src/cli/install-tool/index.spec.ts | 4 +++- src/cli/install-tool/index.ts | 10 ++++++++-- 8 files changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/base/README.md b/packages/base/README.md index 89516c4e72..ed0407c0b0 100644 --- a/packages/base/README.md +++ b/packages/base/README.md @@ -34,14 +34,15 @@ import { SupportedTools, ToolName } from '@containerbase/base/zod'; Each entry may carry the following metadata: -| Field | Description | -| ------------ | ------------------------------------------------------------- | -| `type` | the installer used for the tool, one of `gem`, `npm` or `pip` | -| `parent` | the tool it depends on, eg. `composer` depends on `php` | -| `deprecated` | the tool should not be used any more | +| Field | Description | +| ------------ | -------------------------------------------------------------- | +| `type` | the installer used for the tool, one of `gem`, `npm` or `pip` | +| `parent` | the tool it depends on, eg. `composer` depends on `php` | +| `deprecated` | the tool should not be used any more | +| `root` | the tool can only be installed as root, so at image build time | Only the names `install-tool` accepts are listed. -Packages installed with an arbitrary name via `install-gem`, `install-npm` or `install-pip` are not, as that list is unbounded, and neither are the v1 shell tools, which need root privileges and can't be installed on the fly. +Packages installed with an arbitrary name via `install-gem`, `install-npm` or `install-pip` are not, as that list is unbounded. ## Development diff --git a/packages/base/data/tools.json b/packages/base/data/tools.json index f6184a21e4..22e0a7f712 100644 --- a/packages/base/data/tools.json +++ b/packages/base/data/tools.json @@ -28,6 +28,7 @@ "flux": {}, "gh": {}, "ghc": {}, + "git": { "root": true }, "git-lfs": { "parent": "git" }, "gleam": {}, "golang": {}, diff --git a/packages/base/data/tools.schema.json b/packages/base/data/tools.schema.json index 840d5720bb..e104c23003 100644 --- a/packages/base/data/tools.schema.json +++ b/packages/base/data/tools.schema.json @@ -21,6 +21,11 @@ "type": "boolean", "const": true, "description": "deprecated tools should not be used any more" + }, + "root": { + "type": "boolean", + "const": true, + "description": "the tool can only be installed as root, so only at image build time" } }, "additionalProperties": false diff --git a/packages/base/src/data.ts b/packages/base/src/data.ts index 0839657a3c..c5caed9900 100644 --- a/packages/base/src/data.ts +++ b/packages/base/src/data.ts @@ -33,6 +33,7 @@ export const tools = { flux: {}, gh: {}, ghc: {}, + git: { root: true }, 'git-lfs': { parent: 'git' }, gleam: {}, golang: {}, diff --git a/packages/base/src/schema.ts b/packages/base/src/schema.ts index e494a313a3..f66917d17e 100644 --- a/packages/base/src/schema.ts +++ b/packages/base/src/schema.ts @@ -22,6 +22,12 @@ export const ToolMetadata = z.object({ .literal(true) .describe('deprecated tools should not be used any more') .optional(), + root: z + .literal(true) + .describe( + 'the tool can only be installed as root, so only at image build time', + ) + .optional(), }); /** diff --git a/packages/base/src/types.ts b/packages/base/src/types.ts index 44f8e2f87d..3177a5dcaf 100644 --- a/packages/base/src/types.ts +++ b/packages/base/src/types.ts @@ -19,4 +19,8 @@ export interface ToolMetadata { * Deprecated tools should not be used any more. */ deprecated?: true; + /** + * The tool can only be installed as root, so only at image build time. + */ + root?: true; } diff --git a/src/cli/install-tool/index.spec.ts b/src/cli/install-tool/index.spec.ts index 41b00e8bd1..9b89d10d95 100644 --- a/src/cli/install-tool/index.spec.ts +++ b/src/cli/install-tool/index.spec.ts @@ -155,12 +155,14 @@ describe('cli/install-tool/index', () => { ); // v2 shell tools are supported expect(names).toContain('dummy'); - // v1 shell tools need root and can't be installed on the fly + // v1 shell tools have no install service to describe expect(names).not.toContain('leg'); expect(tools).toEqual( expect.arrayContaining([ { name: 'apko' }, + { name: 'git', root: true }, + { name: 'git-lfs', parent: 'git' }, { name: 'maven', parent: 'java' }, { name: 'kas', type: 'pip', parent: 'python' }, { name: 'bower', type: 'npm', parent: 'node', deprecated: true }, diff --git a/src/cli/install-tool/index.ts b/src/cli/install-tool/index.ts index c178d52710..757a987afe 100644 --- a/src/cli/install-tool/index.ts +++ b/src/cli/install-tool/index.ts @@ -279,6 +279,10 @@ export interface SupportedTool { * Deprecated tools should not be used any more. */ deprecated?: true; + /** + * The tool can only be installed as root, so only at image build time. + */ + root?: true; } /** @@ -305,8 +309,9 @@ function addDynamicTools( * * Tools installed with an arbitrary package name via `install-gem`, * `install-npm` or `install-pip` are not included, as that list is unbounded. - * The v1 shell tools are not included either, they need root privileges and - * can't be installed on the fly. + * The v1 shell tools are not included either, this repository ships none and + * they have no install service to describe. Tools which need root to install + * are included, marked with `root`. */ export async function listSupportedTools(): Promise { const container = await prepareInstallContainer(); @@ -322,6 +327,7 @@ export async function listSupportedTools(): Promise { tools.set(svc.name, { name: svc.name, ...(svc.parent && { parent: svc.parent }), + ...(svc.needsRoot && { root: true }), }); } From e576cecf14eb91177dcc26f3d3871e691dde24c9 Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Wed, 30 Sep 2026 13:14:01 +0200 Subject: [PATCH 3/8] fix: reject unknown tool metadata keys in the zod schemas Use strict objects, so the zod schemas match the json schema, and apply the readme wording from review. Co-Authored-By: Claude Opus 5.5 --- packages/base/README.md | 18 +++++++++--------- packages/base/src/schema.ts | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/base/README.md b/packages/base/README.md index ed0407c0b0..a21aa294fe 100644 --- a/packages/base/README.md +++ b/packages/base/README.md @@ -1,9 +1,9 @@ # `@containerbase/base` -Metadata about the tools [containerbase](https://github.com/containerbase/base) supports. +Metadata about the tools that [Containerbase](https://github.com/containerbase/base) supports. -The data is generated from the containerbase sources and released with the same version as containerbase itself. -A consumer can therefore compare this package's version with the version an image reports in `/usr/local/containerbase/version`: if the image version is greater than or equal to the package version, every tool listed here can be installed in that image. +The data is generated from the Containerbase sources and released with the same version as Containerbase itself. +This allows a consumer of this library to determine which tools can be used with a given version of Containerbase (via `/usr/local/containerbase/version`): if the image version is greater than or equal to the package version, every tool listed here can be installed in that image. ## Usage @@ -34,12 +34,12 @@ import { SupportedTools, ToolName } from '@containerbase/base/zod'; Each entry may carry the following metadata: -| Field | Description | -| ------------ | -------------------------------------------------------------- | -| `type` | the installer used for the tool, one of `gem`, `npm` or `pip` | -| `parent` | the tool it depends on, eg. `composer` depends on `php` | -| `deprecated` | the tool should not be used any more | -| `root` | the tool can only be installed as root, so at image build time | +| Field | Description | +| ------------ | --------------------------------------------------------------------------------------------------------------- | +| `type` | the installer used for the tool, one of `gem`, `npm` or `pip` | +| `parent` | the tool it depends on, eg. `composer` depends on `php`, which requires `php` to be installed before `composer` | +| `deprecated` | the tool should not be used any more | +| `root` | the tool can only be installed as root, so at image build time | Only the names `install-tool` accepts are listed. Packages installed with an arbitrary name via `install-gem`, `install-npm` or `install-pip` are not, as that list is unbounded. diff --git a/packages/base/src/schema.ts b/packages/base/src/schema.ts index f66917d17e..90b34530d9 100644 --- a/packages/base/src/schema.ts +++ b/packages/base/src/schema.ts @@ -10,7 +10,7 @@ export const InstallToolType = z * Keep in sync with the `ToolMetadata` interface in `types.ts`, which is the * zod free version used by the default export. */ -export const ToolMetadata = z.object({ +export const ToolMetadata = z.strictObject({ type: InstallToolType.describe( 'the installer used for this tool, only set for dynamically installed tools', ).optional(), @@ -35,7 +35,7 @@ export const ToolMetadata = z.object({ * * The generated json schema lives in `data/tools.schema.json`. */ -export const SupportedTools = z.object({ +export const SupportedTools = z.strictObject({ tools: z .record(z.string(), ToolMetadata) .describe('all supported tools, keyed by tool name'), From 0d0399832360a8e5a309793c0b19431060ba26e7 Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Wed, 30 Sep 2026 15:15:53 +0200 Subject: [PATCH 4/8] test: keep the zod schemas and the zod free types in sync Check with expectTypeOf that the inferred schema types equal the interfaces, which needs exact optionals in the schema. tsc fails when they drift apart. Co-Authored-By: Claude Opus 5.5 --- eslint.config.js | 5 +++++ packages/base/src/schema.ts | 9 +++++---- test/packages/base.spec.ts | 23 +++++++++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 test/packages/base.spec.ts diff --git a/eslint.config.js b/eslint.config.js index 7b92546822..56fcb7d1b5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -229,6 +229,11 @@ export default tseslint.config( 'max-classes-per-file': 0, 'class-methods-use-this': 0, 'no-console': 0, + // type tests only assert with `expectTypeOf` + 'vitest/expect-expect': [ + 'error', + { assertFunctionNames: ['expect', 'expectTypeOf'] }, + ], }, }, { diff --git a/packages/base/src/schema.ts b/packages/base/src/schema.ts index 90b34530d9..26d1a7074f 100644 --- a/packages/base/src/schema.ts +++ b/packages/base/src/schema.ts @@ -11,23 +11,24 @@ export const InstallToolType = z * zod free version used by the default export. */ export const ToolMetadata = z.strictObject({ + // exact optionals, so the inferred type matches the zod free interface type: InstallToolType.describe( 'the installer used for this tool, only set for dynamically installed tools', - ).optional(), + ).exactOptional(), parent: z .string() .describe('the tool this tool depends on, eg. composer depends on php') - .optional(), + .exactOptional(), deprecated: z .literal(true) .describe('deprecated tools should not be used any more') - .optional(), + .exactOptional(), root: z .literal(true) .describe( 'the tool can only be installed as root, so only at image build time', ) - .optional(), + .exactOptional(), }); /** diff --git a/test/packages/base.spec.ts b/test/packages/base.spec.ts new file mode 100644 index 0000000000..88c59162b1 --- /dev/null +++ b/test/packages/base.spec.ts @@ -0,0 +1,23 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import type { z } from 'zod'; +import type { + InstallToolType, + ToolMetadata, +} from '../../packages/base/src/schema.ts'; +import type * as types from '../../packages/base/src/types.ts'; + +// type-only checks, `tsc` fails when the zod schemas of the base package and +// its zod free types drift apart +describe('packages/base', () => { + test('InstallToolType matches the zod free type', () => { + expectTypeOf< + z.infer + >().toEqualTypeOf(); + }); + + test('ToolMetadata matches the zod free interface', () => { + expectTypeOf< + z.infer + >().toEqualTypeOf(); + }); +}); From 2631b1032c6d7df59ec3e5a31fbfccd3a87b4eb7 Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Thu, 1 Oct 2026 10:10:07 +0200 Subject: [PATCH 5/8] docs(base): limit the compatibility rule to the same major version Co-Authored-By: Claude Opus 5.5 --- packages/base/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/base/README.md b/packages/base/README.md index a21aa294fe..1625e160c3 100644 --- a/packages/base/README.md +++ b/packages/base/README.md @@ -3,7 +3,8 @@ Metadata about the tools that [Containerbase](https://github.com/containerbase/base) supports. The data is generated from the Containerbase sources and released with the same version as Containerbase itself. -This allows a consumer of this library to determine which tools can be used with a given version of Containerbase (via `/usr/local/containerbase/version`): if the image version is greater than or equal to the package version, every tool listed here can be installed in that image. +This allows a consumer of this library to determine which tools can be used with a given version of Containerbase (via `/usr/local/containerbase/version`): if the image has the same major version and a greater or equal version than the package, every tool listed here can be installed in that image. +A new major version may remove tools, so compare against the package of the image's major version. ## Usage From 7a0e7888dba84b40c95d6f707a66eebf7ff0fa93 Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Thu, 1 Oct 2026 10:50:34 +0200 Subject: [PATCH 6/8] docs(tools): document the generated file writer Co-Authored-By: Claude Opus 5.5 --- tools/tools.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/tools.ts b/tools/tools.ts index 961a24e454..dacca83de8 100644 --- a/tools/tools.ts +++ b/tools/tools.ts @@ -23,6 +23,11 @@ globalThis.rootDir = rootDir; const { listSupportedTools } = await import('../src/cli/install-tool/index.ts'); +/** + * Writes a generated file of the base package, formatted with prettier. + * @param file - path relative to `packages/base/` + * @param content - the unformatted content + */ async function write(file: string, content: string): Promise { const target = fileURLToPath(new URL(file, pkgDir)); const options = await resolveConfig(target); From 03e10e8457f17dd26af0927b52d6eb91ff093a68 Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Thu, 1 Oct 2026 12:26:46 +0200 Subject: [PATCH 7/8] docs: clarify the version comparison and fix the wording Co-Authored-By: Claude Opus 5.5 --- packages/base/README.md | 2 +- src/cli/install-tool/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/base/README.md b/packages/base/README.md index 1625e160c3..531370372f 100644 --- a/packages/base/README.md +++ b/packages/base/README.md @@ -3,7 +3,7 @@ Metadata about the tools that [Containerbase](https://github.com/containerbase/base) supports. The data is generated from the Containerbase sources and released with the same version as Containerbase itself. -This allows a consumer of this library to determine which tools can be used with a given version of Containerbase (via `/usr/local/containerbase/version`): if the image has the same major version and a greater or equal version than the package, every tool listed here can be installed in that image. +This allows a consumer of this library to determine which tools can be used with a given version of Containerbase (via `/usr/local/containerbase/version`): if the image has the same major version as this package and a version greater than or equal to the package version, every tool listed here can be installed in that image. A new major version may remove tools, so compare against the package of the image's major version. ## Usage diff --git a/src/cli/install-tool/index.ts b/src/cli/install-tool/index.ts index 426c0c1c43..1531eda581 100644 --- a/src/cli/install-tool/index.ts +++ b/src/cli/install-tool/index.ts @@ -286,7 +286,7 @@ export interface SupportedTool { } /** - * Adds the tools which are implicit mapped to `install-`, they have no + * Adds the tools which are implicitly mapped to `install-`, they have no * install service of their own. */ function addDynamicTools( From 04bbad4053c1114cf90f6bc62b96c4fb0bf50c91 Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Thu, 1 Oct 2026 14:03:27 +0200 Subject: [PATCH 8/8] fix: list no v2 tools when their folder is missing The repository ships no v2 tool any more, so the folder only exists in images. The tool list generator runs against the sources and failed on it. Co-Authored-By: Claude Opus 5.5 --- src/cli/services/path.service.spec.ts | 10 ++++++++++ src/cli/services/path.service.ts | 11 +++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/cli/services/path.service.spec.ts b/src/cli/services/path.service.spec.ts index d80825d287..acf91a9182 100644 --- a/src/cli/services/path.service.spec.ts +++ b/src/cli/services/path.service.spec.ts @@ -95,6 +95,16 @@ describe('cli/services/path.service', () => { ]); }); + test('findLegacyTools', async () => { + // no v2 folder, eg. when running from the repository + expect(await pathSvc.findLegacyTools()).toEqual([]); + + await ensurePaths('usr/local/containerbase/tools/v2'); + await writeFile(rootPath('usr/local/containerbase/tools/v2/leg.sh'), ''); + await writeFile(rootPath('usr/local/containerbase/tools/v2/readme'), ''); + expect(await pathSvc.findLegacyTools()).toEqual(['leg']); + }); + test('isLegacyTool', async () => { await ensurePaths([ 'usr/local/containerbase/tools', diff --git a/src/cli/services/path.service.ts b/src/cli/services/path.service.ts index b6be3bc292..e28d8165ad 100644 --- a/src/cli/services/path.service.ts +++ b/src/cli/services/path.service.ts @@ -236,9 +236,16 @@ export class PathService { return null; } - /** Returns the names of the v2 shell tools. */ + /** + * Returns the names of the v2 shell tools. The repository ships none, so + * only images, and custom images adding their own, have the folder. + */ async findLegacyTools(): Promise { - const tools = await fs.readdir(join(this.usrPath, 'tools/v2')); + const dir = join(this.usrPath, 'tools/v2'); + if (!(await pathExists(dir, 'dir'))) { + return []; + } + const tools = await fs.readdir(dir); return tools .filter((t) => t.endsWith('.sh')) .map((t) => t.substring(0, t.length - 3));