diff --git a/src/cli/install-tool/install-tool.service.ts b/src/cli/install-tool/install-tool.service.ts index 78fb08370d..d68b43ec29 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,11 @@ export class InstallToolService { } } + /** + * 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, version: string, @@ -346,6 +363,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/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/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/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/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/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, 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';