diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b0d86e267..c8639ffbda 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 862b3fe422..d94c283a72 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. @@ -221,6 +222,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..56fcb7d1b5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,7 @@ export default tseslint.config( { ignores: [ 'dist/', + 'packages/*/dist/', 'tmp/', 'bin/', 'coverage/', @@ -228,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/package.json b/package.json index 73b8f3b31c..2cce898063 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..531370372f --- /dev/null +++ b/packages/base/README.md @@ -0,0 +1,50 @@ +# `@containerbase/base` + +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 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 + +```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`, 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. + +## 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..22e0a7f712 --- /dev/null +++ b/packages/base/data/tools.json @@ -0,0 +1,81 @@ +{ + "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": { "root": true }, + "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..e104c23003 --- /dev/null +++ b/packages/base/data/tools.schema.json @@ -0,0 +1,38 @@ +{ + "$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" + }, + "root": { + "type": "boolean", + "const": true, + "description": "the tool can only be installed as root, so only at image build time" + } + }, + "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..e45fb3e2ab --- /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.5" + }, + "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..c5caed9900 --- /dev/null +++ b/packages/base/src/data.ts @@ -0,0 +1,85 @@ +// 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: { root: true }, + '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..26d1a7074f --- /dev/null +++ b/packages/base/src/schema.ts @@ -0,0 +1,44 @@ +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.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', + ).exactOptional(), + parent: z + .string() + .describe('the tool this tool depends on, eg. composer depends on php') + .exactOptional(), + deprecated: z + .literal(true) + .describe('deprecated tools should not be used any more') + .exactOptional(), + root: z + .literal(true) + .describe( + 'the tool can only be installed as root, so only at image build time', + ) + .exactOptional(), +}); + +/** + * Schema of `tools.json`. + * + * The generated json schema lives in `data/tools.schema.json`. + */ +export const SupportedTools = z.strictObject({ + 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..3177a5dcaf --- /dev/null +++ b/packages/base/src/types.ts @@ -0,0 +1,26 @@ +/** + * 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; + /** + * The tool can only be installed as root, so only at image build time. + */ + root?: 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 4e56de2160..04bd67ff02 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.19.0)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)(yaml@2.9.1)) + packages/base: + devDependencies: + zod: + specifier: 4.6.5 + version: 4.6.5 + 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..9b89d10d95 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,32 @@ 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 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 }, + ]), + ); + }); + }); + 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 78213d1d51..b3883f6cc5 100644 --- a/src/cli/install-tool/index.ts +++ b/src/cli/install-tool/index.ts @@ -39,7 +39,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, getToolType } from '../tools/index.ts'; +import { DeprecatedTools, ResolverMap, getToolType } from '../tools/index.ts'; import { AndroidSdkCmdlineToolsInstallService, AndroidSdkCmdlineToolsVersionResolver, @@ -114,6 +114,7 @@ import { TofuInstallService } from '../tools/tofu.ts'; import { VendirInstallService } from '../tools/vendir.ts'; import { WallyInstallService } from '../tools/wally.ts'; import { type InstallToolType, logger } from '../utils/index.ts'; +import type { BaseInstallService } from './base-install.service.ts'; import { V1ToolInstallService, V2ToolInstallService, @@ -248,6 +249,93 @@ 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; + /** + * The tool can only be installed as root, so only at image build time. + */ + root?: true; +} + +/** + * Adds the tools which are implicitly 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, 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(); + 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 }), + ...(svc.needsRoot && { root: true }), + }); + } + + // 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 }), + ); +} + /** * Installs a tool version. For a `gem`, `npm` or `pip` type, a generic * install service is registered for the package first. 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)); 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(); + }); +}); diff --git a/tools/tools.ts b/tools/tools.ts new file mode 100644 index 0000000000..dacca83de8 --- /dev/null +++ b/tools/tools.ts @@ -0,0 +1,73 @@ +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'); + +/** + * 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); + 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" + ] }