UNPKG

@hashgraph/solo

Version:

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

360 lines (325 loc) 13.7 kB
// SPDX-License-Identifier: Apache-2.0 import chalk from 'chalk'; import fs from 'node:fs'; import os from 'node:os'; import {spawnSync, type SpawnSyncReturns} from 'node:child_process'; import {confirm as confirmPrompt} from '@inquirer/prompts'; import {Listr} from 'listr2'; import {ShellRunner} from '../../core/shell-runner.js'; import {SoloError} from '../../core/errors/solo-error.js'; import {PathEx} from '../../business/utils/path-ex.js'; import * as constants from '../../core/constants.js'; import {type SoloLogger} from '../../core/logging/solo-logger.js'; /** Options for building a GitHub issue body from diagnostic information. */ export type DiagnosticsIssueBodyOptions = { soloVersion: string; deployment: string; timestamp: string; /** Directory where diagnostics-analysis.txt was written by DiagnosticsAnalyzer. */ analysisDirectory: string; /** Absolute path to the debug zip archive, if one was created. */ zipFilePath?: string; }; export type DiagnosticsReportRunOptions = { logger: SoloLogger; deployment: string; outputDirectory: string; soloVersion: string; isQuiet: boolean; collectDebug: () => Promise<void>; }; type DiagnosticsReportContext = { zipFilePath?: string; issueTitle?: string; issueBody?: string; }; type FilePathModificationTime = { filePath: string; mtime: number; }; /** * Utility class for the `deployment diagnostics report` command. * Handles gh CLI availability checks, issue body assembly, and issue creation. */ export class DiagnosticsReporter { /** * Orchestrates `deployment diagnostics report` flow: * 1) collect debug archive, 2) build issue payload, 3) optionally prompt, 4) create GitHub issue. */ public static async runDiagnosticsReport(options: DiagnosticsReportRunOptions): Promise<void> { const {logger, deployment, outputDirectory, soloVersion, isQuiet, collectDebug} = options; // collectDebug() runs its own commandAction/Listr2 renderer internally. // It must be called at the top level — not inside a Listr2 task — to avoid // the "ProcessOutput has been already hijacked!" error from nested renderers. const zipSearchDirectory: string = PathEx.join(outputDirectory, '..'); const startTime: number = Date.now(); const analysisDirectory: string = outputDirectory === constants.SOLO_LOGS_DIR ? PathEx.join(constants.SOLO_LOGS_DIR, 'hiero-components-logs') : outputDirectory; await collectDebug(); // Phase 1: verify CLI + build payload (no interactive prompts — Listr2 owns the terminal here) const context: DiagnosticsReportContext = {}; const prepareTasks: Listr<DiagnosticsReportContext, 'default', 'default'> = new Listr( [ { title: 'Verify GitHub CLI availability', task: async (_context_): Promise<void> => { if (!(await DiagnosticsReporter.isGhCliAvailable(logger))) { throw new SoloError( 'The GitHub CLI (gh) is required for this command but was not found.\n' + 'Please install it from https://cli.github.com/ and authenticate with: gh auth login\n' + `Diagnostic logs are available at: ${analysisDirectory}`, ); } }, }, { title: 'Prepare GitHub issue payload', task: async (context_): Promise<void> => { context_.zipFilePath = DiagnosticsReporter.findLatestDebugZip(zipSearchDirectory, deployment, startTime); const timestamp: string = new Date().toISOString().slice(0, 19).replaceAll(':', '-'); context_.issueTitle = `[Solo v${soloVersion}] Diagnostic Report - ${deployment} - ${timestamp}`; context_.issueBody = DiagnosticsReporter.buildIssueBody({ soloVersion, deployment, timestamp, analysisDirectory, zipFilePath: context_.zipFilePath, }); }, }, ], constants.LISTR_DEFAULT_OPTIONS.DEFAULT, ); await prepareTasks.run(context); // Phase 2: interactive confirmation — must run OUTSIDE Listr2 so the prompt // can render normally (Listr2 hijacks the terminal while it is running). if (!isQuiet) { logger.showUser(chalk.cyan('\nReady to create a GitHub issue with the collected diagnostic information.')); logger.showUser(chalk.cyan(` Issue title: ${context.issueTitle}`)); if (context.zipFilePath) { logger.showUser(chalk.cyan(` Debug archive: ${context.zipFilePath}`)); } logger.showUser( chalk.red.bold( '\n⚠ Warning: The collected diagnostic archive may contain sensitive node configuration\n' + ' (TLS certificates, onboard data). Review its contents before sharing publicly.\n' + ' Private keys under data/keys are NOT included.', ), ); const confirmed: boolean = await confirmPrompt({ message: 'Create a GitHub issue with the diagnostic information?', default: true, }); if (!confirmed) { logger.showUser(chalk.yellow('\nIssue creation cancelled.')); logger.showUser(chalk.cyan(`Diagnostic logs are available at: ${analysisDirectory}`)); if (context.zipFilePath) { logger.showUser(chalk.cyan(`Debug archive: ${context.zipFilePath}`)); } return; } } // Phase 3: create the issue (Listr2 again for progress display) const createTasks: Listr<DiagnosticsReportContext, 'default', 'default'> = new Listr( [ { title: 'Create GitHub issue', task: async (context_): Promise<void> => { await DiagnosticsReporter.createGitHubIssue( logger, context_.issueTitle ?? '', context_.issueBody ?? '', analysisDirectory, context_.zipFilePath, ); }, }, ], constants.LISTR_DEFAULT_OPTIONS.DEFAULT, ); await createTasks.run(context); } /** * Checks whether the GitHub CLI (`gh`) is available on the system PATH. * @returns true if `gh` is installed and reachable, false otherwise */ public static async isGhCliAvailable(logger: SoloLogger): Promise<boolean> { try { const shellRunner: ShellRunner = new ShellRunner(logger); const command: string = os.platform() === 'win32' ? 'where' : 'which'; await shellRunner.run(command, ['gh']); return true; } catch { return false; } } /** * Searches for the most recently modified debug zip archive that was created * at or after `afterTimestampMs` in the given directory. * * The `deployment diagnostics debug` command writes files named * `solo-debug-<deployment>-<timestamp>.zip` one level above the logs directory. * * @param searchDirectory Directory to search (typically `~/.solo`). * @param deployment Deployment name used as part of the filename prefix. * @param afterTimestampMs Milliseconds epoch; only files modified at or after * this time are considered. * @returns The absolute path to the found zip, or `undefined` if none matched. */ public static findLatestDebugZip( searchDirectory: string, deployment: string, afterTimestampMs: number, ): string | undefined { if (!fs.existsSync(searchDirectory)) { return undefined; } const prefix: string = `solo-debug-${deployment}-`; const candidates: FilePathModificationTime[] = fs .readdirSync(searchDirectory) .filter((file: string): boolean => file.startsWith(prefix) && file.endsWith('.zip')) .map((file: string): FilePathModificationTime => { const filePath: string = PathEx.join(searchDirectory, file); const mtime: number = fs.statSync(filePath).mtimeMs; return {filePath, mtime}; }) .filter(({mtime}: FilePathModificationTime): boolean => mtime >= afterTimestampMs) // eslint-disable-next-line unicorn/no-array-sort .sort((a: FilePathModificationTime, b: FilePathModificationTime): number => b.mtime - a.mtime); return candidates.length > 0 ? candidates[0].filePath : undefined; } /** * Reads the diagnostics-analysis.txt file from the logs directory, if present. * @param logsDirectory Directory where the analysis file is expected. * @returns File contents, or an empty string if the file does not exist. */ public static readAnalysisContent(logsDirectory: string): string { const analysisFilePath: string = PathEx.join(logsDirectory, 'diagnostics-analysis.txt'); if (fs.existsSync(analysisFilePath)) { return fs.readFileSync(analysisFilePath, 'utf8'); } return ''; } /** * Assembles the Markdown body for a GitHub issue from the provided diagnostic * information. */ public static buildIssueBody(options: DiagnosticsIssueBodyOptions): string { const {soloVersion, deployment, timestamp, analysisDirectory, zipFilePath} = options; const analysisContent: string = DiagnosticsReporter.readAnalysisContent(analysisDirectory); const lines: string[] = [ '## Solo Diagnostic Report', '', `- **Solo Version**: ${soloVersion}`, `- **Deployment**: ${deployment || '(not specified)'}`, `- **Timestamp**: ${timestamp}`, `- **Platform**: ${os.platform()} ${os.release()}`, `- **Node.js**: ${process.version}`, `- **Diagnostic logs**: ${analysisDirectory}`, ]; if (zipFilePath) { lines.push(`- **Debug archive**: ${zipFilePath}`); } lines.push( '', '## Diagnostics Analysis', '', analysisContent ? '```\n' + analysisContent + '\n```' : '_No analysis available_', '', '## Description', '', '_Please describe the issue you encountered..._', '', '## Steps to Reproduce', '', '_Please list the steps to reproduce the issue..._', ); if (zipFilePath) { lines.push( '', '---', `_Note: A debug archive was generated at \`${zipFilePath}\`. Please attach it to this issue via the GitHub web interface._`, ); } return lines.join('\n'); } /** * Executes `gh issue create` with the provided args using `spawnSync` (without a shell) * so that space-containing arguments such as the issue title are passed verbatim. * * Extracted as a public static method so that unit tests can stub it without invoking * the real `gh` CLI. * * @param arguments_ Arguments to pass to the `gh` CLI. * @returns The `SpawnSyncReturns` result from the `gh` process. */ public static executeGhCommand(arguments_: string[]): SpawnSyncReturns<string> { return spawnSync('gh', arguments_, {encoding: 'utf8', env: process.env}); } /** * Creates a GitHub issue using the `gh` CLI with the supplied title and body. * If a zip archive path is provided, the user is reminded to attach it manually * since the GitHub Issues API does not support binary attachments. * * @param logger Logger for user-facing output. * @param title Issue title. * @param body Issue body in Markdown. * @param zipFilePath Optional path to the debug zip archive to mention. * @returns The URL of the newly created issue, or an empty string if not found. */ public static async createGitHubIssue( logger: SoloLogger, title: string, body: string, analysisDirectory: string, zipFilePath?: string, ): Promise<string> { // Write body to a temp file to avoid any shell interpretation of the markdown content. // We use spawnSync without shell:true so the title and all other args are passed // verbatim — ShellRunner uses shell:true which splits space-containing args into separate // tokens, breaking both multi-word titles and multi-line bodies. const bodyFilePath: string = PathEx.join(os.tmpdir(), `solo-gh-issue-body-${Date.now()}.md`); fs.writeFileSync(bodyFilePath, body, 'utf8'); try { const result: SpawnSyncReturns<string> = DiagnosticsReporter.executeGhCommand([ 'issue', 'create', '--repo', 'hiero-ledger/solo', '--title', title, '--body-file', bodyFilePath, ]); if (result.status !== 0) { throw new Error(result.stderr?.trim() || `gh exited with status ${result.status}`); } const issueUrl: string = result.stdout.split('\n').find((line: string): boolean => line.startsWith('https://')) ?? ''; logger.showUser(chalk.green('\n✓ GitHub issue created successfully!')); if (issueUrl) { logger.showUser(chalk.cyan(` Issue URL: ${issueUrl}`)); } logger.showUser(chalk.cyan(` Diagnostic logs: ${analysisDirectory}`)); if (zipFilePath && fs.existsSync(zipFilePath)) { logger.showUser(chalk.cyan(` Debug archive: ${zipFilePath}`)); logger.showUser(''); logger.showUser(chalk.bgYellow.black.bold(' ACTION REQUIRED ')); logger.showUser( chalk.yellow.bold( ' ⚠ Please attach the debug archive to the GitHub issue:\n' + ` ${zipFilePath}\n` + ' Go to the issue URL above → click "attach files" → upload the zip.', ), ); } return issueUrl; } catch (error: Error | unknown) { throw new SoloError(`Failed to create GitHub issue: ${(error as Error).message}`, error as Error); } finally { fs.rmSync(bodyFilePath, {force: true}); } } }