@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
250 lines • 12.6 kB
JavaScript
// SPDX-License-Identifier: Apache-2.0
import chalk from 'chalk';
import fs from 'node:fs';
import os from 'node:os';
import { spawnSync } 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';
/**
* 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.
*/
static async runDiagnosticsReport(options) {
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 = PathEx.join(outputDirectory, '..');
const startTime = Date.now();
const analysisDirectory = 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 = {};
const prepareTasks = new Listr([
{
title: 'Verify GitHub CLI availability',
task: async (_context_) => {
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_) => {
context_.zipFilePath = DiagnosticsReporter.findLatestDebugZip(zipSearchDirectory, deployment, startTime);
const timestamp = 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 = 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 = new Listr([
{
title: 'Create GitHub issue',
task: async (context_) => {
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
*/
static async isGhCliAvailable(logger) {
try {
const shellRunner = new ShellRunner(logger);
const command = 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.
*/
static findLatestDebugZip(searchDirectory, deployment, afterTimestampMs) {
if (!fs.existsSync(searchDirectory)) {
return undefined;
}
const prefix = `solo-debug-${deployment}-`;
const candidates = fs
.readdirSync(searchDirectory)
.filter((file) => file.startsWith(prefix) && file.endsWith('.zip'))
.map((file) => {
const filePath = PathEx.join(searchDirectory, file);
const mtime = fs.statSync(filePath).mtimeMs;
return { filePath, mtime };
})
.filter(({ mtime }) => mtime >= afterTimestampMs)
// eslint-disable-next-line unicorn/no-array-sort
.sort((a, b) => 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.
*/
static readAnalysisContent(logsDirectory) {
const analysisFilePath = 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.
*/
static buildIssueBody(options) {
const { soloVersion, deployment, timestamp, analysisDirectory, zipFilePath } = options;
const analysisContent = DiagnosticsReporter.readAnalysisContent(analysisDirectory);
const lines = [
'## 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.
*/
static executeGhCommand(arguments_) {
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.
*/
static async createGitHubIssue(logger, title, body, analysisDirectory, zipFilePath) {
// 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 = PathEx.join(os.tmpdir(), `solo-gh-issue-body-${Date.now()}.md`);
fs.writeFileSync(bodyFilePath, body, 'utf8');
try {
const result = 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 = result.stdout.split('\n').find((line) => 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) {
throw new SoloError(`Failed to create GitHub issue: ${error.message}`, error);
}
finally {
fs.rmSync(bodyFilePath, { force: true });
}
}
}
//# sourceMappingURL=diagnostics-reporter.js.map