@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
327 lines (286 loc) • 11.3 kB
text/typescript
// SPDX-License-Identifier: Apache-2.0
import fs from 'node:fs';
import * as helpers from '../helpers.js';
import {type PackageDownloader} from '../package-downloader.js';
import {Templates} from '../templates.js';
import {ShellRunner} from '../shell-runner.js';
import {MissingArgumentError} from '../errors/missing-argument-error.js';
import {SoloError} from '../errors/solo-error.js';
import {PathEx} from '../../business/utils/path-ex.js';
import {OperatingSystem} from '../../business/utils/operating-system.js';
import path from 'node:path';
import {SemanticVersion} from '../../business/utils/semantic-version.js';
/**
* Base class for dependency managers that download and manage CLI tools
* Common functionality for downloading, checking versions, and managing executables
*/
export abstract class BaseDependencyManager extends ShellRunner {
protected readonly osArch: string;
protected localExecutableWithPath: string;
protected globalExecutablePath: string = '';
protected readonly artifactName: string;
protected readonly downloadURL: string;
protected readonly checksumURL: string;
protected readonly executableName: string;
protected constructor(
protected readonly downloader: PackageDownloader,
protected readonly installationDirectory: string,
osArch: string,
protected readonly requiredVersion: string,
dependencyName: string,
protected readonly downloadBaseUrl: string,
) {
super();
if (!installationDirectory) {
throw new MissingArgumentError('installation directory is required');
}
if (!downloader) {
throw new MissingArgumentError('package downloader is required');
}
// Normalize architecture naming - many tools use 'amd64' instead of 'x64'
this.osArch = ['x64', 'x86-64'].includes(osArch as string) ? 'amd64' : (osArch as string);
// Set the path to the local installation
this.localExecutableWithPath = Templates.localInstallationExecutableForDependency(
dependencyName,
installationDirectory,
);
this.executableName = path.basename(this.localExecutableWithPath);
// Set artifact name and URLs - these will be overridden by child classes
this.artifactName = this.getArtifactName();
this.downloadURL = this.getDownloadURL();
this.checksumURL = this.getChecksumURL();
}
protected getArch(): string {
let arch: string = this.osArch;
if (arch === 'x64') {
arch = 'amd64';
} else if (arch === 'arm64' || arch === 'aarch64') {
arch = 'arm64';
}
return arch;
}
/**
* Child classes must implement this to generate the correct artifact name
* based on version, platform, and architecture
*/
protected abstract getArtifactName(): string;
/**
* Get the download URL for the executable
*/
protected abstract getDownloadURL(): string;
/**
* Get the checksum URL for the executable
*/
protected abstract getChecksumURL(): string;
public abstract getVersion(executablePath: string): Promise<string>;
/**
* Handle any post-download processing before copying to destination
* Child classes can override this for custom extraction or processing
*/
protected abstract processDownloadedPackage(packageFilePath: string, temporaryDirectory: string): Promise<string[]>;
/**
* Get the executable to run
*/
public async getExecutable(): Promise<string> {
return this.executableName;
}
/**
* Find the global executable by scanning PATH directories directly in Node.js.
* This avoids spawning a shell subprocess (which, command -v, where) whose
* behaviour varies across shells and CI runner environments.
*/
private getGlobalExecutableWithPath(): false | string {
if (this.globalExecutablePath) {
return this.globalExecutablePath;
}
const executableNames: string[] = OperatingSystem.isWin32()
? [`${this.executableName}.exe`, `${this.executableName}.cmd`, this.executableName]
: [this.executableName];
const pathDirectories: string[] = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
this.logger.debug(`Searching PATH for ${this.executableName}: [${pathDirectories.join(', ')}]`);
for (const directory of pathDirectories) {
for (const name of executableNames) {
const candidate: string = path.join(directory, name);
try {
// On Windows X_OK is not supported and silently degrades to F_OK;
// executability is determined by file extension (.exe/.cmd) already.
fs.accessSync(candidate, OperatingSystem.isWin32() ? fs.constants.F_OK : fs.constants.X_OK);
this.logger.debug(`Found ${this.executableName} at ${candidate}`);
this.globalExecutablePath = candidate;
return candidate;
} catch {
// not found or not executable in this directory — continue
}
}
}
this.logger.warn(`${this.executableName} was not found in PATH`);
return false;
}
/**
* Check if the given installation meets version requirements
*/
public async installationMeetsRequirements(executableWithPath: string): Promise<boolean> {
let version: string;
try {
version = await this.getVersion(executableWithPath);
} catch (error) {
this.logger.debug(
`Failed to get version for ${this.executableName} at ${executableWithPath}: ${error instanceof Error ? error.message : error}`,
);
return false;
}
if (new SemanticVersion<string>(version).greaterThanOrEqual(this.getRequiredVersion())) {
return true;
}
this.logger.info(
`Found version ${version} of ${this.executableName} at ${executableWithPath}, which does not meet the required version ${this.getRequiredVersion()}`,
);
return false;
}
/**
* Check if the tool is installed globally and meets requirements
*/
private async isInstalledGloballyAndMeetsRequirements(): Promise<boolean> {
const path: false | string = this.getGlobalExecutableWithPath();
try {
if (path && (await this.installationMeetsRequirements(path))) {
return true;
} else {
this.logger.info(`${this.executableName}${path ? ` at ${path}` : ''} is not a compatible global installation`);
}
} catch (error) {
this.logger.debug(
`Global installation of ${this.executableName} does not meet version requirements: ${error instanceof Error ? error.message : error}`,
);
}
return false;
}
/**
* Check if the tool is installed locally and meets requirements
*/
private async isInstalledLocallyAndMeetsRequirements(): Promise<boolean> {
try {
if (!this.isInstalledLocally()) {
this.logger.info(`${this.executableName} is not installed locally at ${this.localExecutableWithPath}`);
return false;
}
if (await this.installationMeetsRequirements(this.localExecutableWithPath)) {
return true;
}
this.logger.info(
`${this.executableName} at ${this.localExecutableWithPath} is installed locally but does not meet version requirements`,
);
} catch (error) {
this.logger.debug(
`Local installation of ${this.executableName} does not meet version requirements: ${error instanceof Error ? error.message : error}`,
);
}
return false;
}
/**
* Check if the tool is installed locally
*/
public isInstalledLocally(): boolean {
return fs.existsSync(this.localExecutableWithPath);
}
/**
* Uninstall the local version
*/
public uninstallLocal(): void {
if (this.isInstalledLocally()) {
fs.rmSync(this.localExecutableWithPath);
}
}
/**
* Hook for any pre-installation steps
*/
protected async preInstall(): Promise<void> {}
/**
* Hook to determine if installation should proceed
* Child classes can override this for custom logic
*/
public async shouldInstall(): Promise<boolean> {
return true;
}
/**
* Determine if checksum verification should be performed
* Child classes can override this if needed
*/
public getVerifyChecksum(): boolean {
return true;
}
/**
* Install the tool
*/
public async install(temporaryDirectory: string = helpers.getTemporaryDirectory()): Promise<boolean> {
if (this.installationDirectory === temporaryDirectory) {
throw new SoloError('Installation directory cannot be the same as temporary directory');
}
if (!(await this.shouldInstall())) {
this.logger.debug(`Skipping installation of ${this.executableName}`);
return true;
}
await this.preInstall();
// Check if it is already installed locally
if (await this.isInstalledLocallyAndMeetsRequirements()) {
const localVersion: string = await this.getVersion(this.localExecutableWithPath).catch((): string =>
this.getRequiredVersion(),
);
this.logger.showUser(
`Compatible ${this.executableName} v${localVersion} found at ${this.localExecutableWithPath}`,
);
return true;
}
// If it is installed globally and meets requirements, use the global installation
if (await this.isInstalledGloballyAndMeetsRequirements()) {
const globalVersion: string = await this.getVersion(this.globalExecutablePath).catch((): string =>
this.getRequiredVersion(),
);
this.logger.showUser(`Compatible ${this.executableName} v${globalVersion} found at ${this.globalExecutablePath}`);
return true;
}
// If not installed, download and install
this.logger.showUser(
`Compatible ${this.executableName} ${this.getRequiredVersion()} was not found locally or globally. ` +
`Downloading and installing it into ${this.installationDirectory}...`,
);
this.logger.debug(`Downloading and installing ${this.executableName} executable...`);
const packageFile: string = await this.downloader!.fetchPackage(
this.getDownloadURL(),
this.getChecksumURL(),
temporaryDirectory,
this.getVerifyChecksum(),
);
const processedFiles: string[] = await this.processDownloadedPackage(packageFile, temporaryDirectory);
if (!fs.existsSync(this.installationDirectory!)) {
fs.mkdirSync(this.installationDirectory!, {recursive: true});
}
// In case there is an existing local installation, which did not meet the requirements - remove it
this.uninstallLocal();
try {
for (const processedFile of processedFiles) {
const fileName: string = path.basename(processedFile);
const localExecutable: string = PathEx.join(this.installationDirectory, fileName);
fs.cpSync(processedFile, localExecutable);
fs.chmodSync(localExecutable, 0o755);
}
} catch (error) {
throw new SoloError(`Failed to install ${this.executableName}: ${error.message}`);
}
this.logger.showUser(
`Installed ${this.executableName} ${this.getRequiredVersion()} into ${this.installationDirectory}.`,
);
return this.isInstalledLocally();
}
/**
* Get the tool's required version
*/
public getRequiredVersion(): string {
return this.requiredVersion as string;
}
/**
* Hook for setting up any configuration after installation
* Child classes can override this if needed
*/
public setupConfig(): void {}
}