From bebe26eb66b80b0cb27b4eabc18daca77bbfb3cc Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Wed, 23 Sep 2026 13:01:40 +0200 Subject: [PATCH 1/2] docs: document the recently touched functions Adds doc comments to the git install service, the install tool service, the apt and link tool services and the version lint script, matching the style of the surrounding code. No behaviour changes. Co-Authored-By: Claude Opus 5 --- src/cli/install-tool/install-tool.service.ts | 20 ++++++++++++++++++++ src/cli/services/apt.service.ts | 6 ++++++ src/cli/services/link-tool.service.ts | 6 ++++++ src/cli/tools/git/index.ts | 14 ++++++++++++++ tools/lint-versions.ts | 3 +++ 5 files changed, 49 insertions(+) diff --git a/src/cli/install-tool/install-tool.service.ts b/src/cli/install-tool/install-tool.service.ts index 78fb08370d..33f77f2bf7 100644 --- a/src/cli/install-tool/install-tool.service.ts +++ b/src/cli/install-tool/install-tool.service.ts @@ -50,6 +50,12 @@ export class InstallToolService { @inject(VersionService) private readonly versionSvc!: VersionService; + /** + * Runs the install lifecycle of a tool: prepare, initialize, validate, + * install, link and test. + * + * @returns an exit code when the tool could not be installed + */ async install( tool: string, version: string, @@ -206,6 +212,12 @@ export class InstallToolService { } } + /** + * Removes a tool version, or every installed version when none is given. + * + * @returns an exit code when the tool could not be uninstalled, eg. because + * another tool depends on the version + */ async uninstall( tool: string, version?: string, @@ -317,6 +329,10 @@ export class InstallToolService { } } + /** + * Links the version onto the path, records it as the current one and runs + * the tool test, unless tests are skipped. + */ private async linkAndTest( toolSvc: BaseInstallService, version: string, @@ -346,6 +362,10 @@ export class InstallToolService { } } + /** + * Records which shell wrappers the last link step created, so they can be + * removed again when the version is uninstalled. + */ private async _storeLinks(tool: string, version: string): Promise { const links = this._link.links; logger.debug({ tool, version, links }, 'linked tools'); diff --git a/src/cli/services/apt.service.ts b/src/cli/services/apt.service.ts index 85e18e7184..5698e68f2f 100644 --- a/src/cli/services/apt.service.ts +++ b/src/cli/services/apt.service.ts @@ -10,6 +10,9 @@ export class AptService { @inject(EnvService) private readonly envSvc!: EnvService; + /** + * Installs the packages which are not installed yet, honouring the apt proxy. + */ async install(...packages: string[]): Promise { const todo: string[] = []; @@ -56,6 +59,9 @@ export class AptService { } } + /** + * Whether dpkg reports the package as installed and configured. + */ private async isInstalled(pkg: string): Promise { try { const res = await execa('dpkg', ['-s', pkg]); diff --git a/src/cli/services/link-tool.service.ts b/src/cli/services/link-tool.service.ts index b01cbe17ac..2cc7c22e3e 100644 --- a/src/cli/services/link-tool.service.ts +++ b/src/cli/services/link-tool.service.ts @@ -47,6 +47,11 @@ export class LinkToolService { this._links.length = 0; } + /** + * Writes the shell wrapper which puts a tool on the path. It loads the + * containerbase env, initializes the tool on first use, sources the tool envs + * and finally executes the tool. + */ async shellwrapper( tool: string, { args, name, srcDir, exports, extraToolEnvs, body }: ShellWrapperConfig, @@ -101,6 +106,7 @@ export class LinkToolService { await this.pathSvc.setOwner({ path: tgt }); } + /** Removes a shell wrapper, if it exists. */ async rm(name: string): Promise { const tgt = join(this.pathSvc.binDir, name); if (await pathExists(tgt, 'file')) { diff --git a/src/cli/tools/git/index.ts b/src/cli/tools/git/index.ts index 7a49d107cf..7373eedbad 100644 --- a/src/cli/tools/git/index.ts +++ b/src/cli/tools/git/index.ts @@ -70,6 +70,9 @@ export class GitInstallService extends BaseInstallService { /** git is installed system wide by apt, other tools depend on it */ override readonly canUninstall = false; + /** + * Installs git from the ppa and verifies the version is new enough. + */ override async install(_version: string): Promise { // TODO: the ppa only serves the latest version, so the requested version is ignored await this.aptSvc.install(this.name); @@ -89,16 +92,27 @@ export class GitInstallService extends BaseInstallService { return Promise.resolve(); } + /** + * Marks every repository as safe, whoever owns it. + */ override async postInstall(_version: string): Promise { // flutter workaround // allow all, so it works in older git versions when the ppa is not working await this._spawn(this.name, ['config', '--system', 'safe.directory', '*']); } + /** + * Prints the installed version, which also proves git runs. + */ override async test(_version: string): Promise { await this._spawn(this.name, ['--version']); } + /** + * The version apt installed, which is not necessarily the requested one. + * + * @throws when the output holds no version at all + */ private async installedVersion(): Promise { const res = await execa(this.name, ['--version']); // `git --version` prints eg. `git version 2.55.0`, but the vendor may add a diff --git a/tools/lint-versions.ts b/tools/lint-versions.ts index 76aa27e45f..b754ff77f5 100644 --- a/tools/lint-versions.ts +++ b/tools/lint-versions.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'; // checks that the tool versions pinned in `mise.toml` match the ones used by CI +/** Reads a file from the repository root. */ async function read(file: string): Promise { return await readFile(new URL(`../${file}`, import.meta.url), 'utf8'); } @@ -14,11 +15,13 @@ const { packageManager } = JSON.parse(await read('package.json')) as { let failed = false; +/** Reports a mismatch and makes the script exit non zero. */ function fail(message: string): void { console.error(message); failed = true; } +/** Compares the version `mise.toml` pins for a tool with the given one. */ function compare(tool: string, version: string, source: string): void { const match = new RegExp(`^${tool} = "(?[^"]+)"`, 'm').exec(mise); const pinned = match?.groups?.version ?? 'nothing'; From 679e9711b89e7cf561669cdb6957b7204abd02a2 Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Wed, 23 Sep 2026 13:39:33 +0200 Subject: [PATCH 2/2] docs: document the version, http and prepare services Documents the four stores behind `VersionService` and the exact match semantics of its queries, the caching and retry behaviour of `HttpService`, and the prepare and initialize lifecycle of the prepare services. Also completes the `linkAndTest` comment, which left out the skipped relink, the post-install step and the recorded shell wrappers. Co-Authored-By: Claude Opus 5.5 --- src/cli/install-tool/install-tool.service.ts | 5 +-- src/cli/prepare-tool/base-prepare.service.ts | 13 +++++++ src/cli/prepare-tool/prepare-tool.service.ts | 14 ++++++++ src/cli/services/http.service.ts | 26 ++++++++++++++ src/cli/services/version.service.ts | 36 ++++++++++++++++++++ 5 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/cli/install-tool/install-tool.service.ts b/src/cli/install-tool/install-tool.service.ts index 33f77f2bf7..d68b43ec29 100644 --- a/src/cli/install-tool/install-tool.service.ts +++ b/src/cli/install-tool/install-tool.service.ts @@ -330,8 +330,9 @@ export class InstallToolService { } /** - * Links the version onto the path, records it as the current one and runs - * the tool test, unless tests are skipped. + * Links the version onto the path and records it as the current one, unless + * it already is. Then runs the post-install step, records the created shell + * wrappers and runs the tool test, unless tests are skipped. */ private async linkAndTest( toolSvc: BaseInstallService, diff --git a/src/cli/prepare-tool/base-prepare.service.ts b/src/cli/prepare-tool/base-prepare.service.ts index cc4726b965..7adc80ddf8 100644 --- a/src/cli/prepare-tool/base-prepare.service.ts +++ b/src/cli/prepare-tool/base-prepare.service.ts @@ -3,6 +3,14 @@ import { EnvService, PathService } from '../services/index.ts'; import { NoInitTools, NoPrepareTools } from '../tools/index.ts'; import { type SpawnOptions, type SpawnResult, spawn } from '../utils/index.ts'; +/** + * The setup a tool needs before it can be installed, split in two steps: + * + * - prepare runs once per image, as root, eg. to install apt packages or add + * an apt source + * - initialize runs once per container, as whoever first runs the tool, eg. to + * create the folders and config files the tool expects in the home directory + */ @injectable() export abstract class BasePrepareService { @inject(PathService) @@ -12,17 +20,22 @@ export abstract class BasePrepareService { abstract readonly name: string; + /** Runs the one time, root only setup. */ prepare(): Promise | void { // noting to do; } + + /** Runs the per container setup. */ initialize(): Promise | void { // noting to do; } + /** Whether the tool has an initialize step, see `NoInitTools`. */ needsInitialize(): boolean { return !NoInitTools.includes(this.name); } + /** Whether the tool has a prepare step, see `NoPrepareTools`. */ needsPrepare(): boolean { return !NoPrepareTools.includes(this.name); } diff --git a/src/cli/prepare-tool/prepare-tool.service.ts b/src/cli/prepare-tool/prepare-tool.service.ts index cb33c44cbd..c79f0c5b6e 100644 --- a/src/cli/prepare-tool/prepare-tool.service.ts +++ b/src/cli/prepare-tool/prepare-tool.service.ts @@ -17,6 +17,12 @@ export class PrepareToolService { @inject(EnvService) private readonly envSvc!: EnvService; + /** + * Prepares the given tools, or every tool for `all`, and marks them as + * prepared. Must run as root. + * + * @returns an exit code when the tools could not be prepared + */ async prepare(tools: string[], dryRun = false): Promise { const supportedTools = this.toolSvcs.map((t) => t.name).sort(); logger.trace( @@ -74,6 +80,12 @@ export class PrepareToolService { } } + /** + * Initializes the given tools, or for `all` every tool which was prepared in + * this image, and marks them as initialized. + * + * @returns an exit code when the tools could not be initialized + */ async initialize(tools: string[], dryRun = false): Promise { const supportedTools = this.toolSvcs.map((t) => t.name).sort(); logger.trace( @@ -112,6 +124,7 @@ export class PrepareToolService { } } + /** Initializes a tool, unless it is ignored, needs no init or already had it. */ private async _initTool( tool: BasePrepareService, _dryRun: boolean, @@ -134,6 +147,7 @@ export class PrepareToolService { await tool.initialize(); } + /** Prepares a tool, unless it is ignored, needs no prepare or already had it. */ private async _prepareTool( tool: BasePrepareService, _dryRun: boolean, diff --git a/src/cli/services/http.service.ts b/src/cli/services/http.service.ts index 9cb8d18fae..098dc409b4 100644 --- a/src/cli/services/http.service.ts +++ b/src/cli/services/http.service.ts @@ -54,6 +54,16 @@ export class HttpService { }); } + /** + * Downloads a file into the cache and returns its path. + * + * The cache folder is derived from the url, so a file downloaded before is + * reused, as long as it still matches the expected checksum when one is + * given. Urls go through the configured url replacements, eg. a CDN. A + * failed download or checksum mismatch is tried up to three times. + * + * @throws when all attempts failed + */ async download({ url, expectedChecksum, @@ -123,6 +133,11 @@ export class HttpService { throw new Error('download failed'); } + /** + * Whether the url exists, checked with a `HEAD` request. + * + * @throws on any error other than a 404 + */ async exists(url: string): Promise { try { await got.head(this.envSvc.replaceUrl(url), this._opts); @@ -137,6 +152,11 @@ export class HttpService { } } + /** + * Fetches the body as text, trying up to three times. + * + * @throws when all attempts failed + */ async get( url: string, opts: OptionsOfTextResponseBody = {}, @@ -167,6 +187,12 @@ export class HttpService { throw new Error('download failed'); } + /** + * Fetches and parses a json body, trying up to three times. The result is + * not validated, parse it with a schema. + * + * @throws when all attempts failed + */ async getJson( url: string, opts: OptionsOfJSONResponseBody = {}, diff --git a/src/cli/services/version.service.ts b/src/cli/services/version.service.ts index 092261aec3..e756f8555a 100644 --- a/src/cli/services/version.service.ts +++ b/src/cli/services/version.service.ts @@ -41,6 +41,15 @@ export interface ToolType { type: InstallToolType; } +/** + * Keeps track of the installed tools in four separate stores: + * + * - versions: every installed version, a tool can have many, each optionally + * installed for a parent tool version, eg. a npm package for a node version + * - state: the current version per tool, the one on the path + * - links: the shell wrapper names created for a tool version + * - types: the installer of dynamically installed tools, eg. `npm` + */ @injectable() export class VersionService { @inject(DataService) @@ -54,10 +63,14 @@ export class VersionService { private _types!: Database>; private _versions!: Database>; + /** + * Whether exactly this version is recorded, including its parent when given. + */ async isInstalled(tool: ToolVersion): Promise { return (await this._versions.findOneAsync(tool)) !== null; } + /** All recorded versions of a tool, for any parent. */ findInstalled(name: string): Promise[]> { return this._versions.findAsync({ name }); } @@ -100,46 +113,66 @@ export class VersionService { }); } + /** Records an installed version. */ async addInstalled(tool: ToolVersion): Promise { await this._versions.insertAsync(tool); } + /** Removes every recorded version matching the given fields. */ async removeInstalled(tool: Partial): Promise { await this._versions.removeAsync(tool, { multi: true }); } + /** + * The versions installed for exactly this parent version. Children of other + * versions of the same parent tool are not included. + */ getChilds(parent: Tool): Promise[]> { return this._versions.findAsync({ parent }); } + /** Whether the shell wrapper name points at exactly this tool version. */ async isLinked(tool: ToolLink): Promise { return (await this._links.findOneAsync(tool)) !== null; } + /** The shell wrapper names created for a tool version. */ findLinks(tool: Tool): Promise[]> { return this._links.findAsync({ tool }); } + /** + * Points a shell wrapper name at a tool version, replacing whatever it + * pointed at before. + */ async setLink(tool: ToolLink): Promise { await this._links.updateAsync({ name: tool.name }, tool, { upsert: true }); } + /** Forgets every shell wrapper name created for a tool version. */ async removeLinks(tool: Tool): Promise { await this._links.removeAsync({ tool }, { multi: true }); } + /** Whether exactly this version, and parent, is the current one. */ async isCurrent(tool: ToolState): Promise { return (await this._state.findOneAsync(tool)) !== null; } + /** Makes a version the current one, replacing the previous current one. */ async setCurrent(tool: ToolState): Promise { await this._state.updateAsync({ name: tool.name }, tool, { upsert: true }); } + /** + * The current version, looked up by the name the tool is linked as, which + * is its alias, eg. `java` for `java-jdk`. + */ async getCurrent(name: string): Promise { return await this._state.findOneAsync({ name }); } + /** Forgets the current version and removes its legacy version file. */ async removeCurrent(name: string): Promise { await this._state.removeAsync({ name }, { multi: false }); const path = join(this.pathSvc.versionPath, tool2path(name)); @@ -150,15 +183,18 @@ export class VersionService { } } + /** The installer a dynamically installed tool was installed with. */ async getType(name: string): Promise { const doc = await this._types.findOneAsync({ name }); return doc?.type; } + /** Every dynamically installed tool with its installer. */ async getTypes(): Promise { return await this._types.findAsync({}); } + /** Records the installer of a dynamically installed tool. */ async setType( name: string, type: InstallToolType | undefined,