UNPKG

@hashgraph/solo

Version:

An opinionated CLI tool to deploy and manage private Hedera Networks.

327 lines (286 loc) 11.3 kB
// 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 {} }