1168 lines
36 KiB
C#
1168 lines
36 KiB
C#
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.Cli.Extensions;
|
|
using StellaOps.Doctor.Engine;
|
|
using StellaOps.Doctor.Export;
|
|
using StellaOps.Doctor.Models;
|
|
using StellaOps.Doctor.Output;
|
|
using StellaOps.Doctor.Packs;
|
|
using StellaOps.Doctor.Plugins;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.CommandLine;
|
|
using System.CommandLine.Parsing;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace StellaOps.Cli.Commands;
|
|
|
|
/// <summary>
|
|
/// Builds the stella doctor command for diagnostic checks.
|
|
/// </summary>
|
|
internal static class DoctorCommandGroup
|
|
{
|
|
private const int MaxFixOutputChars = 2000;
|
|
private static readonly Regex PlaceholderPattern = new(@"\{[A-Z][A-Z0-9_]*\}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
|
|
internal static Command BuildDoctorCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var doctor = new Command("doctor", "Run diagnostic checks on Stella Ops installation and environment.");
|
|
|
|
var rootRunOptions = CreateRunOptions();
|
|
AddRunOptions(doctor, rootRunOptions, verboseOption);
|
|
doctor.SetAction(async (parseResult, ct) =>
|
|
{
|
|
await RunDoctorFromParseResultAsync(
|
|
parseResult,
|
|
rootRunOptions,
|
|
verboseOption,
|
|
services,
|
|
cancellationToken);
|
|
});
|
|
|
|
// Sub-commands
|
|
doctor.Add(BuildRunCommand(services, verboseOption, cancellationToken));
|
|
doctor.Add(BuildListCommand(services, verboseOption, cancellationToken));
|
|
doctor.Add(BuildExportCommand(services, verboseOption, cancellationToken));
|
|
doctor.Add(BuildFixCommand(services, verboseOption, cancellationToken));
|
|
doctor.Add(KnowledgeSearchCommandGroup.BuildDoctorSuggestCommand(services, verboseOption, cancellationToken));
|
|
|
|
return doctor;
|
|
}
|
|
|
|
private static Command BuildRunCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var runOptions = CreateRunOptions();
|
|
|
|
var run = new Command("run", "Execute diagnostic checks.");
|
|
AddRunOptions(run, runOptions, verboseOption);
|
|
|
|
run.SetAction(async (parseResult, ct) =>
|
|
{
|
|
await RunDoctorFromParseResultAsync(
|
|
parseResult,
|
|
runOptions,
|
|
verboseOption,
|
|
services,
|
|
cancellationToken);
|
|
});
|
|
|
|
return run;
|
|
}
|
|
|
|
private static DoctorRunCommandOptions CreateRunOptions()
|
|
{
|
|
var formatOption = new Option<string>("--format", new[] { "-f" })
|
|
{
|
|
Description = "Output format: text (default), json, markdown"
|
|
};
|
|
formatOption.SetDefaultValue("text");
|
|
|
|
var modeOption = new Option<string?>("--mode", new[] { "-m" })
|
|
{
|
|
Description = "Run mode: quick (fast checks only), normal (default), full (all checks including slow ones)"
|
|
};
|
|
|
|
var categoryOption = new Option<string?>("--category", new[] { "-c" })
|
|
{
|
|
Description = "Filter checks by category (e.g., Core, Database, Security)"
|
|
};
|
|
|
|
var tagOption = new Option<string[]>("--tag", new[] { "-t" })
|
|
{
|
|
Description = "Filter checks by tag (e.g., quick, connectivity). Can be specified multiple times.",
|
|
Arity = ArgumentArity.ZeroOrMore
|
|
};
|
|
|
|
var checkOption = new Option<string?>("--check")
|
|
{
|
|
Description = "Run a specific check by ID (e.g., check.core.disk)"
|
|
};
|
|
|
|
var parallelOption = new Option<int?>("--parallel", new[] { "-p" })
|
|
{
|
|
Description = "Maximum parallel check executions (default: 4)"
|
|
};
|
|
|
|
var timeoutOption = new Option<int?>("--timeout")
|
|
{
|
|
Description = "Per-check timeout in seconds (default: 30)"
|
|
};
|
|
|
|
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
|
{
|
|
Description = "Write output to file instead of stdout"
|
|
};
|
|
|
|
var failOnWarnOption = new Option<bool>("--fail-on-warn")
|
|
{
|
|
Description = "Exit with non-zero code on warnings (default: only fail on errors)"
|
|
};
|
|
|
|
var watchOption = new Option<bool>("--watch", new[] { "-w" })
|
|
{
|
|
Description = "Run in continuous monitoring mode"
|
|
};
|
|
|
|
var intervalOption = new Option<int?>("--interval")
|
|
{
|
|
Description = "Interval in seconds between checks in watch mode (default: 60)"
|
|
};
|
|
|
|
var envOption = new Option<string?>("--env", new[] { "-e" })
|
|
{
|
|
Description = "Target environment for checks (e.g., dev, staging, prod)"
|
|
};
|
|
|
|
return new DoctorRunCommandOptions(
|
|
formatOption,
|
|
modeOption,
|
|
categoryOption,
|
|
tagOption,
|
|
checkOption,
|
|
parallelOption,
|
|
timeoutOption,
|
|
outputOption,
|
|
failOnWarnOption,
|
|
watchOption,
|
|
intervalOption,
|
|
envOption);
|
|
}
|
|
|
|
private static void AddRunOptions(
|
|
Command command,
|
|
DoctorRunCommandOptions options,
|
|
Option<bool> verboseOption)
|
|
{
|
|
command.Add(options.FormatOption);
|
|
command.Add(options.ModeOption);
|
|
command.Add(options.CategoryOption);
|
|
command.Add(options.TagOption);
|
|
command.Add(options.CheckOption);
|
|
command.Add(options.ParallelOption);
|
|
command.Add(options.TimeoutOption);
|
|
command.Add(options.OutputOption);
|
|
command.Add(options.FailOnWarnOption);
|
|
command.Add(options.WatchOption);
|
|
command.Add(options.IntervalOption);
|
|
command.Add(options.EnvOption);
|
|
command.Add(verboseOption);
|
|
}
|
|
|
|
private static async Task RunDoctorFromParseResultAsync(
|
|
ParseResult parseResult,
|
|
DoctorRunCommandOptions options,
|
|
Option<bool> verboseOption,
|
|
IServiceProvider services,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var format = parseResult.GetValue(options.FormatOption) ?? "text";
|
|
var mode = parseResult.GetValue(options.ModeOption);
|
|
var category = parseResult.GetValue(options.CategoryOption);
|
|
var tags = parseResult.GetValue(options.TagOption) ?? [];
|
|
var checkId = parseResult.GetValue(options.CheckOption);
|
|
var parallel = parseResult.GetValue(options.ParallelOption) ?? 4;
|
|
var timeout = parseResult.GetValue(options.TimeoutOption) ?? 30;
|
|
var output = parseResult.GetValue(options.OutputOption);
|
|
var failOnWarn = parseResult.GetValue(options.FailOnWarnOption);
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
await RunDoctorAsync(
|
|
services,
|
|
format,
|
|
mode,
|
|
category,
|
|
tags,
|
|
checkId,
|
|
parallel,
|
|
timeout,
|
|
output,
|
|
failOnWarn,
|
|
verbose,
|
|
cancellationToken);
|
|
}
|
|
|
|
private static Command BuildListCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var categoryOption = new Option<string?>("--category", new[] { "-c" })
|
|
{
|
|
Description = "Filter by category"
|
|
};
|
|
|
|
var tagOption = new Option<string[]>("--tag", new[] { "-t" })
|
|
{
|
|
Description = "Filter by tag",
|
|
Arity = ArgumentArity.ZeroOrMore
|
|
};
|
|
|
|
var list = new Command("list", "List available diagnostic checks.")
|
|
{
|
|
categoryOption,
|
|
tagOption,
|
|
verboseOption
|
|
};
|
|
|
|
list.SetAction((parseResult, ct) =>
|
|
{
|
|
var category = parseResult.GetValue(categoryOption);
|
|
var tags = parseResult.GetValue(tagOption) ?? [];
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
return ListChecksAsync(services, category, tags, verbose, cancellationToken);
|
|
});
|
|
|
|
return list;
|
|
}
|
|
|
|
private static Command BuildExportCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var outputOption = new Option<string>("--output", new[] { "-o" })
|
|
{
|
|
Description = "Output ZIP file path",
|
|
Arity = ArgumentArity.ExactlyOne
|
|
};
|
|
|
|
var includeLogsOption = new Option<bool>("--include-logs")
|
|
{
|
|
Description = "Include recent log files in the bundle (default: true)"
|
|
};
|
|
includeLogsOption.SetDefaultValue(true);
|
|
|
|
var logDurationOption = new Option<string?>("--log-duration")
|
|
{
|
|
Description = "Duration of logs to include (e.g., 1h, 4h, 24h). Default: 1h"
|
|
};
|
|
|
|
var noConfigOption = new Option<bool>("--no-config")
|
|
{
|
|
Description = "Exclude configuration from the bundle"
|
|
};
|
|
|
|
var export = new Command("export", "Generate diagnostic bundle for support")
|
|
{
|
|
outputOption,
|
|
includeLogsOption,
|
|
logDurationOption,
|
|
noConfigOption,
|
|
verboseOption
|
|
};
|
|
|
|
export.SetAction(async (parseResult, ct) =>
|
|
{
|
|
var output = parseResult.GetValue(outputOption)!;
|
|
var includeLogs = parseResult.GetValue(includeLogsOption);
|
|
var logDuration = parseResult.GetValue(logDurationOption);
|
|
var noConfig = parseResult.GetValue(noConfigOption);
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
await ExportDiagnosticBundleAsync(
|
|
services,
|
|
output,
|
|
includeLogs,
|
|
logDuration,
|
|
noConfig,
|
|
verbose,
|
|
cancellationToken);
|
|
});
|
|
|
|
return export;
|
|
}
|
|
|
|
private static Command BuildFixCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var fromOption = new Option<string>("--from")
|
|
{
|
|
Description = "Path to JSON report file with fix commands",
|
|
Arity = ArgumentArity.ExactlyOne
|
|
};
|
|
|
|
var applyOption = new Option<bool>("--apply")
|
|
{
|
|
Description = "Execute non-destructive fixes (default: dry-run preview)"
|
|
};
|
|
|
|
var fix = new Command("fix", "Apply non-destructive fixes from a doctor report.")
|
|
{
|
|
fromOption,
|
|
applyOption,
|
|
verboseOption
|
|
};
|
|
|
|
fix.SetAction(async (parseResult, ct) =>
|
|
{
|
|
var reportPath = parseResult.GetValue(fromOption);
|
|
var apply = parseResult.GetValue(applyOption);
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
await ApplyFixesAsync(services, reportPath, apply, verbose, cancellationToken);
|
|
});
|
|
|
|
return fix;
|
|
}
|
|
|
|
private static async Task ExportDiagnosticBundleAsync(
|
|
IServiceProvider services,
|
|
string outputPath,
|
|
bool includeLogs,
|
|
string? logDuration,
|
|
bool noConfig,
|
|
bool verbose,
|
|
CancellationToken ct)
|
|
{
|
|
var generator = services.GetRequiredService<DiagnosticBundleGenerator>();
|
|
|
|
var duration = ParseDuration(logDuration) ?? TimeSpan.FromHours(1);
|
|
|
|
var options = new DiagnosticBundleOptions
|
|
{
|
|
IncludeConfig = !noConfig,
|
|
IncludeLogs = includeLogs,
|
|
LogDuration = duration
|
|
};
|
|
|
|
Console.WriteLine("Generating diagnostic bundle...");
|
|
|
|
var bundle = await generator.GenerateAsync(options, ct);
|
|
|
|
// Ensure output path has .zip extension
|
|
if (!outputPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
outputPath += ".zip";
|
|
}
|
|
|
|
await generator.ExportToZipAsync(bundle, outputPath, ct);
|
|
|
|
Console.WriteLine();
|
|
Console.WriteLine($"Diagnostic bundle created: {outputPath}");
|
|
Console.WriteLine();
|
|
Console.WriteLine($"Summary:");
|
|
Console.WriteLine($" Passed: {bundle.DoctorReport.Summary.Passed}");
|
|
Console.WriteLine($" Warnings: {bundle.DoctorReport.Summary.Warnings}");
|
|
Console.WriteLine($" Failed: {bundle.DoctorReport.Summary.Failed}");
|
|
Console.WriteLine();
|
|
Console.WriteLine("Share this bundle with Stella Ops support for assistance.");
|
|
}
|
|
|
|
private static TimeSpan? ParseDuration(string? duration)
|
|
{
|
|
if (string.IsNullOrEmpty(duration))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Parse duration strings like "1h", "4h", "30m", "24h"
|
|
if (duration.EndsWith("h", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (double.TryParse(duration.AsSpan(0, duration.Length - 1), NumberStyles.Float, CultureInfo.InvariantCulture, out var hours))
|
|
{
|
|
return TimeSpan.FromHours(hours);
|
|
}
|
|
}
|
|
|
|
if (duration.EndsWith("m", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (double.TryParse(duration.AsSpan(0, duration.Length - 1), NumberStyles.Float, CultureInfo.InvariantCulture, out var minutes))
|
|
{
|
|
return TimeSpan.FromMinutes(minutes);
|
|
}
|
|
}
|
|
|
|
if (duration.EndsWith("d", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (double.TryParse(duration.AsSpan(0, duration.Length - 1), NumberStyles.Float, CultureInfo.InvariantCulture, out var days))
|
|
{
|
|
return TimeSpan.FromDays(days);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static async Task RunDoctorAsync(
|
|
IServiceProvider services,
|
|
string format,
|
|
string? mode,
|
|
string? category,
|
|
string[] tags,
|
|
string? checkId,
|
|
int parallel,
|
|
int timeout,
|
|
string? outputPath,
|
|
bool failOnWarn,
|
|
bool verbose,
|
|
CancellationToken ct)
|
|
{
|
|
var engine = services.GetRequiredService<DoctorEngine>();
|
|
|
|
var runMode = ParseRunMode(mode);
|
|
|
|
var options = new DoctorRunOptions
|
|
{
|
|
Mode = runMode,
|
|
Categories = category != null ? [category] : null,
|
|
Tags = tags.Length > 0 ? tags : null,
|
|
CheckIds = checkId != null ? [checkId] : null,
|
|
Parallelism = parallel,
|
|
Timeout = TimeSpan.FromSeconds(timeout),
|
|
DoctorCommand = Environment.CommandLine
|
|
};
|
|
|
|
// Progress reporting for verbose mode
|
|
IProgress<DoctorCheckProgress>? progress = null;
|
|
if (verbose)
|
|
{
|
|
progress = new Progress<DoctorCheckProgress>(p =>
|
|
{
|
|
Console.WriteLine($"[{p.Completed}/{p.Total}] {p.CheckId} - {p.Severity}");
|
|
});
|
|
}
|
|
|
|
var report = await engine.RunAsync(options, progress, ct);
|
|
|
|
// Format output
|
|
var formatter = GetFormatter(format);
|
|
var output = formatter.Format(report);
|
|
|
|
// Write output
|
|
if (!string.IsNullOrEmpty(outputPath))
|
|
{
|
|
await File.WriteAllTextAsync(outputPath, output, ct);
|
|
Console.WriteLine($"Report written to: {outputPath}");
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine(output);
|
|
}
|
|
|
|
// Set exit code
|
|
SetExitCode(report, failOnWarn);
|
|
}
|
|
|
|
private static async Task ApplyFixesAsync(
|
|
IServiceProvider services,
|
|
string? reportPath,
|
|
bool apply,
|
|
bool verbose,
|
|
CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(reportPath))
|
|
{
|
|
Console.Error.WriteLine("Error: --from is required.");
|
|
Environment.ExitCode = CliExitCodes.MissingRequiredOption;
|
|
return;
|
|
}
|
|
|
|
if (!File.Exists(reportPath))
|
|
{
|
|
Console.Error.WriteLine($"Error: Report file not found: {reportPath}");
|
|
Environment.ExitCode = CliExitCodes.InputFileNotFound;
|
|
return;
|
|
}
|
|
|
|
IReadOnlyList<DoctorFixStep> steps;
|
|
try
|
|
{
|
|
steps = await LoadFixStepsAsync(reportPath, ct);
|
|
}
|
|
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
|
|
{
|
|
Console.Error.WriteLine($"Error: Unable to parse doctor report: {ex.Message}");
|
|
Environment.ExitCode = CliExitCodes.GeneralError;
|
|
return;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"Error: Unable to read doctor report: {ex.Message}");
|
|
Environment.ExitCode = CliExitCodes.GeneralError;
|
|
return;
|
|
}
|
|
|
|
var orderedSteps = steps
|
|
.Where(step => !string.IsNullOrWhiteSpace(step.Command))
|
|
.OrderBy(step => step.CheckId, StringComparer.Ordinal)
|
|
.ThenBy(step => step.Order)
|
|
.ThenBy(step => step.Command, StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
if (orderedSteps.Count == 0)
|
|
{
|
|
Console.WriteLine("No fix commands found.");
|
|
return;
|
|
}
|
|
|
|
var plan = BuildFixPlan(orderedSteps);
|
|
var distinctChecks = plan
|
|
.Select(entry => entry.Step.CheckId)
|
|
.Distinct(StringComparer.Ordinal)
|
|
.Count();
|
|
var safeCount = plan.Count(entry => entry.IsSafe);
|
|
var manualCount = plan.Count - safeCount;
|
|
|
|
Console.WriteLine($"Found {plan.Count} fix command(s) across {distinctChecks} check(s).");
|
|
Console.WriteLine($"Safe commands: {safeCount}. Manual commands: {manualCount}.");
|
|
|
|
if (!apply)
|
|
{
|
|
Console.WriteLine("Dry-run preview. Use --apply to execute safe commands.");
|
|
PrintFixPlan(plan, verbose);
|
|
return;
|
|
}
|
|
|
|
if (safeCount == 0)
|
|
{
|
|
Console.WriteLine("No safe commands to apply.");
|
|
return;
|
|
}
|
|
|
|
Console.WriteLine("Applying safe fix commands...");
|
|
var runner = services.GetRequiredService<IDoctorPackCommandRunner>();
|
|
var context = BuildFixContext(services);
|
|
|
|
var applied = 0;
|
|
var skipped = 0;
|
|
var failed = 0;
|
|
|
|
foreach (var entry in plan)
|
|
{
|
|
if (!entry.IsSafe)
|
|
{
|
|
skipped++;
|
|
WritePlanEntry("SKIP", entry, verbose);
|
|
continue;
|
|
}
|
|
|
|
WritePlanEntry("RUN", entry, verbose);
|
|
var result = await runner.RunAsync(
|
|
new DoctorPackCommand(entry.Step.Command),
|
|
context,
|
|
ct);
|
|
|
|
if (result.ExitCode == 0 && string.IsNullOrWhiteSpace(result.Error))
|
|
{
|
|
applied++;
|
|
if (verbose && !string.IsNullOrWhiteSpace(result.StdOut))
|
|
{
|
|
Console.WriteLine(TrimOutput(result.StdOut));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
failed++;
|
|
Console.WriteLine($"[FAIL] {FormatStep(entry.Step)}");
|
|
if (!string.IsNullOrWhiteSpace(result.Error))
|
|
{
|
|
Console.WriteLine($" Error: {result.Error}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.StdErr))
|
|
{
|
|
Console.WriteLine($" Stderr: {TrimOutput(result.StdErr)}");
|
|
}
|
|
}
|
|
}
|
|
|
|
Console.WriteLine($"Applied: {applied}. Skipped: {skipped}. Failed: {failed}.");
|
|
if (failed > 0)
|
|
{
|
|
Environment.ExitCode = CliExitCodes.GeneralError;
|
|
}
|
|
}
|
|
|
|
private static List<DoctorFixPlanEntry> BuildFixPlan(IReadOnlyList<DoctorFixStep> steps)
|
|
{
|
|
var plan = new List<DoctorFixPlanEntry>(steps.Count);
|
|
foreach (var step in steps)
|
|
{
|
|
var isSafe = IsSafeCommand(step, out var reason);
|
|
plan.Add(new DoctorFixPlanEntry(step, isSafe, isSafe ? string.Empty : reason));
|
|
}
|
|
|
|
return plan;
|
|
}
|
|
|
|
private static void PrintFixPlan(IReadOnlyList<DoctorFixPlanEntry> plan, bool verbose)
|
|
{
|
|
foreach (var entry in plan)
|
|
{
|
|
var label = entry.IsSafe ? "SAFE" : "MANUAL";
|
|
WritePlanEntry(label, entry, verbose);
|
|
}
|
|
}
|
|
|
|
private static void WritePlanEntry(string label, DoctorFixPlanEntry entry, bool verbose)
|
|
{
|
|
var reasonSuffix = entry.IsSafe || string.IsNullOrWhiteSpace(entry.Reason)
|
|
? string.Empty
|
|
: $" ({entry.Reason})";
|
|
|
|
Console.WriteLine($"[{label}] {FormatStep(entry.Step)}: {entry.Step.Command}{reasonSuffix}");
|
|
|
|
if (!verbose)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(entry.Step.Description))
|
|
{
|
|
Console.WriteLine($" {entry.Step.Description}");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(entry.Step.SafetyNote))
|
|
{
|
|
Console.WriteLine($" Safety: {entry.Step.SafetyNote}");
|
|
}
|
|
}
|
|
|
|
private static string FormatStep(DoctorFixStep step)
|
|
{
|
|
return $"{step.CheckId} step {step.Order.ToString(CultureInfo.InvariantCulture)}";
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<DoctorFixStep>> LoadFixStepsAsync(
|
|
string reportPath,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await using var stream = File.OpenRead(reportPath);
|
|
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
|
|
return ExtractFixSteps(doc.RootElement);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return await LoadFixStepsFromJsonLinesAsync(reportPath, ct);
|
|
}
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<DoctorFixStep>> LoadFixStepsFromJsonLinesAsync(
|
|
string reportPath,
|
|
CancellationToken ct)
|
|
{
|
|
var steps = new List<DoctorFixStep>();
|
|
|
|
using var stream = new FileStream(reportPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
using var reader = new StreamReader(stream);
|
|
|
|
string? line;
|
|
while ((line = await reader.ReadLineAsync()) is not null)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
if (string.IsNullOrWhiteSpace(line))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
using var doc = JsonDocument.Parse(line);
|
|
steps.AddRange(ExtractFixSteps(doc.RootElement));
|
|
}
|
|
|
|
return steps;
|
|
}
|
|
|
|
private static IReadOnlyList<DoctorFixStep> ExtractFixSteps(JsonElement root)
|
|
{
|
|
var steps = new List<DoctorFixStep>();
|
|
|
|
if (root.ValueKind == JsonValueKind.Object)
|
|
{
|
|
if (TryGetProperty(root, "results", out var results) ||
|
|
TryGetProperty(root, "checks", out results))
|
|
{
|
|
AppendFixStepsFromResults(results, steps);
|
|
}
|
|
else
|
|
{
|
|
AppendFixStepsFromResult(root, steps);
|
|
}
|
|
}
|
|
else if (root.ValueKind == JsonValueKind.Array)
|
|
{
|
|
AppendFixStepsFromResults(root, steps);
|
|
}
|
|
|
|
return steps;
|
|
}
|
|
|
|
private static void AppendFixStepsFromResults(JsonElement results, List<DoctorFixStep> steps)
|
|
{
|
|
if (results.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var result in results.EnumerateArray())
|
|
{
|
|
AppendFixStepsFromResult(result, steps);
|
|
}
|
|
}
|
|
|
|
private static void AppendFixStepsFromResult(JsonElement result, List<DoctorFixStep> steps)
|
|
{
|
|
var checkId = GetString(result, "checkId") ?? "unknown";
|
|
|
|
if (TryGetProperty(result, "remediation", out var remediation))
|
|
{
|
|
AppendFixStepsFromRemediation(remediation, checkId, steps);
|
|
return;
|
|
}
|
|
|
|
if (TryGetProperty(result, "how_to_fix", out var howToFix) ||
|
|
TryGetProperty(result, "howToFix", out howToFix))
|
|
{
|
|
AppendFixStepsFromHowToFix(howToFix, checkId, steps);
|
|
}
|
|
}
|
|
|
|
private static void AppendFixStepsFromRemediation(
|
|
JsonElement remediation,
|
|
string checkId,
|
|
List<DoctorFixStep> steps)
|
|
{
|
|
var requiresBackup = GetBool(remediation, "requiresBackup");
|
|
var safetyNote = GetString(remediation, "safetyNote");
|
|
|
|
if (TryGetProperty(remediation, "steps", out var stepArray) &&
|
|
stepArray.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var fallbackOrder = 0;
|
|
foreach (var stepElement in stepArray.EnumerateArray())
|
|
{
|
|
fallbackOrder++;
|
|
var command = GetString(stepElement, "command");
|
|
if (string.IsNullOrWhiteSpace(command))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var order = GetInt(stepElement, "order") ?? fallbackOrder;
|
|
if (order <= 0)
|
|
{
|
|
order = fallbackOrder;
|
|
}
|
|
|
|
var description = GetString(stepElement, "description");
|
|
var commandType = GetString(stepElement, "commandType");
|
|
if (string.IsNullOrWhiteSpace(commandType))
|
|
{
|
|
commandType = "shell";
|
|
}
|
|
|
|
steps.Add(new DoctorFixStep(
|
|
checkId,
|
|
order,
|
|
command.Trim(),
|
|
commandType,
|
|
description,
|
|
requiresBackup,
|
|
safetyNote));
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (TryGetProperty(remediation, "commands", out var commands))
|
|
{
|
|
AppendFixStepsFromCommands(
|
|
checkId,
|
|
commands,
|
|
"shell",
|
|
null,
|
|
requiresBackup,
|
|
safetyNote,
|
|
steps);
|
|
}
|
|
}
|
|
|
|
private static void AppendFixStepsFromHowToFix(
|
|
JsonElement howToFix,
|
|
string checkId,
|
|
List<DoctorFixStep> steps)
|
|
{
|
|
if (TryGetProperty(howToFix, "commands", out var commands))
|
|
{
|
|
AppendFixStepsFromCommands(
|
|
checkId,
|
|
commands,
|
|
"shell",
|
|
null,
|
|
requiresBackup: false,
|
|
safetyNote: null,
|
|
steps);
|
|
}
|
|
}
|
|
|
|
private static void AppendFixStepsFromCommands(
|
|
string checkId,
|
|
JsonElement commands,
|
|
string commandType,
|
|
string? description,
|
|
bool requiresBackup,
|
|
string? safetyNote,
|
|
List<DoctorFixStep> steps)
|
|
{
|
|
if (commands.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var order = 0;
|
|
foreach (var commandValue in commands.EnumerateArray())
|
|
{
|
|
if (commandValue.ValueKind != JsonValueKind.String)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var command = commandValue.GetString();
|
|
if (string.IsNullOrWhiteSpace(command))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
order++;
|
|
steps.Add(new DoctorFixStep(
|
|
checkId,
|
|
order,
|
|
command.Trim(),
|
|
commandType,
|
|
description,
|
|
requiresBackup,
|
|
safetyNote));
|
|
}
|
|
}
|
|
|
|
private static DoctorPluginContext BuildFixContext(IServiceProvider services)
|
|
{
|
|
var configuration = services.GetRequiredService<IConfiguration>();
|
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger("DoctorFix");
|
|
var timeProvider = services.GetRequiredService<TimeProvider>();
|
|
var environment = services.GetService<IHostEnvironment>();
|
|
|
|
return new DoctorPluginContext
|
|
{
|
|
Services = services,
|
|
Configuration = configuration,
|
|
TimeProvider = timeProvider,
|
|
Logger = logger,
|
|
EnvironmentName = environment?.EnvironmentName ?? Environments.Production,
|
|
PluginConfig = configuration.GetSection("Doctor:Packs")
|
|
};
|
|
}
|
|
|
|
private static bool IsSafeCommand(DoctorFixStep step, out string reason)
|
|
{
|
|
if (!IsShellCommand(step.CommandType))
|
|
{
|
|
reason = "unsupported command type";
|
|
return false;
|
|
}
|
|
|
|
if (step.RequiresBackup)
|
|
{
|
|
reason = "requires backup";
|
|
return false;
|
|
}
|
|
|
|
if (!IsStellaCommand(step.Command))
|
|
{
|
|
reason = "non-stella command";
|
|
return false;
|
|
}
|
|
|
|
if (ContainsPlaceholders(step.Command))
|
|
{
|
|
reason = "has placeholders";
|
|
return false;
|
|
}
|
|
|
|
reason = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private static bool IsShellCommand(string? commandType)
|
|
{
|
|
return string.IsNullOrWhiteSpace(commandType) ||
|
|
string.Equals(commandType.Trim(), "shell", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool IsStellaCommand(string command)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(command))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var trimmed = command.TrimStart();
|
|
var token = trimmed
|
|
.Split(new[] { ' ', '\t', '\r', '\n' }, 2, StringSplitOptions.RemoveEmptyEntries)
|
|
.FirstOrDefault();
|
|
|
|
if (string.IsNullOrWhiteSpace(token))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
token = token.Trim('"', '\'');
|
|
|
|
return string.Equals(token, "stella", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(token, "stella.exe", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(token, "stella.cmd", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(token, "stella.ps1", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(token, "./stella", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(token, "./stella.exe", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(token, ".\\stella", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(token, ".\\stella.exe", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool ContainsPlaceholders(string command)
|
|
{
|
|
return !string.IsNullOrWhiteSpace(command) &&
|
|
PlaceholderPattern.IsMatch(command);
|
|
}
|
|
|
|
private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value)
|
|
{
|
|
value = default;
|
|
|
|
if (element.ValueKind != JsonValueKind.Object)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
foreach (var property in element.EnumerateObject())
|
|
{
|
|
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
value = property.Value;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static string? GetString(JsonElement element, string propertyName)
|
|
{
|
|
if (!TryGetProperty(element, propertyName, out var value))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return value.ValueKind switch
|
|
{
|
|
JsonValueKind.String => value.GetString(),
|
|
JsonValueKind.Number => value.GetRawText(),
|
|
JsonValueKind.True => "true",
|
|
JsonValueKind.False => "false",
|
|
JsonValueKind.Null => null,
|
|
_ => value.GetRawText()
|
|
};
|
|
}
|
|
|
|
private static bool GetBool(JsonElement element, string propertyName)
|
|
{
|
|
if (!TryGetProperty(element, propertyName, out var value))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return value.ValueKind switch
|
|
{
|
|
JsonValueKind.True => true,
|
|
JsonValueKind.False => false,
|
|
JsonValueKind.String => bool.TryParse(value.GetString(), out var parsed) && parsed,
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
private static int? GetInt(JsonElement element, string propertyName)
|
|
{
|
|
if (!TryGetProperty(element, propertyName, out var value))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (value.ValueKind == JsonValueKind.Number &&
|
|
value.TryGetInt32(out var number))
|
|
{
|
|
return number;
|
|
}
|
|
|
|
if (value.ValueKind == JsonValueKind.String &&
|
|
int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
|
|
{
|
|
return parsed;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string TrimOutput(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
var trimmed = value.Trim();
|
|
if (trimmed.Length <= MaxFixOutputChars)
|
|
{
|
|
return trimmed;
|
|
}
|
|
|
|
return trimmed[..MaxFixOutputChars] + "...(truncated)";
|
|
}
|
|
|
|
private static async Task ListChecksAsync(
|
|
IServiceProvider services,
|
|
string? category,
|
|
string[] tags,
|
|
bool verbose,
|
|
CancellationToken ct)
|
|
{
|
|
var engine = services.GetRequiredService<DoctorEngine>();
|
|
|
|
var options = new DoctorRunOptions
|
|
{
|
|
Categories = category != null ? [category] : null,
|
|
Tags = tags.Length > 0 ? tags : null
|
|
};
|
|
|
|
var checks = engine.ListChecks(options);
|
|
|
|
Console.WriteLine($"Available diagnostic checks ({checks.Count}):");
|
|
Console.WriteLine();
|
|
|
|
string? currentCategory = null;
|
|
|
|
foreach (var check in checks.OrderBy(c => c.Category).ThenBy(c => c.CheckId))
|
|
{
|
|
if (check.Category != currentCategory)
|
|
{
|
|
currentCategory = check.Category;
|
|
Console.WriteLine($"## {currentCategory ?? "Uncategorized"}");
|
|
Console.WriteLine();
|
|
}
|
|
|
|
Console.WriteLine($" {check.CheckId}");
|
|
Console.WriteLine($" Name: {check.Name}");
|
|
if (verbose)
|
|
{
|
|
Console.WriteLine($" Description: {check.Description}");
|
|
Console.WriteLine($" Plugin: {check.PluginId}");
|
|
Console.WriteLine($" Tags: {string.Join(", ", check.Tags)}");
|
|
Console.WriteLine($" Estimated: {check.EstimatedDuration.TotalSeconds:F1}s");
|
|
}
|
|
Console.WriteLine();
|
|
}
|
|
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
private static DoctorRunMode ParseRunMode(string? mode)
|
|
{
|
|
return mode?.ToLowerInvariant() switch
|
|
{
|
|
"quick" => DoctorRunMode.Quick,
|
|
"full" => DoctorRunMode.Full,
|
|
_ => DoctorRunMode.Normal
|
|
};
|
|
}
|
|
|
|
private static IDoctorReportFormatter GetFormatter(string format)
|
|
{
|
|
return format.ToLowerInvariant() switch
|
|
{
|
|
"json" => new JsonReportFormatter(),
|
|
"markdown" or "md" => new MarkdownReportFormatter(),
|
|
_ => new TextReportFormatter()
|
|
};
|
|
}
|
|
|
|
private static void SetExitCode(DoctorReport report, bool failOnWarn)
|
|
{
|
|
var exitCode = report.OverallSeverity switch
|
|
{
|
|
DoctorSeverity.Fail => CliExitCodes.DoctorFailed,
|
|
DoctorSeverity.Warn when failOnWarn => CliExitCodes.DoctorWarning,
|
|
_ => 0
|
|
};
|
|
|
|
if (exitCode != 0)
|
|
{
|
|
Environment.ExitCode = exitCode;
|
|
}
|
|
}
|
|
|
|
private sealed record DoctorRunCommandOptions(
|
|
Option<string> FormatOption,
|
|
Option<string?> ModeOption,
|
|
Option<string?> CategoryOption,
|
|
Option<string[]> TagOption,
|
|
Option<string?> CheckOption,
|
|
Option<int?> ParallelOption,
|
|
Option<int?> TimeoutOption,
|
|
Option<string?> OutputOption,
|
|
Option<bool> FailOnWarnOption,
|
|
Option<bool> WatchOption,
|
|
Option<int?> IntervalOption,
|
|
Option<string?> EnvOption);
|
|
|
|
private sealed record DoctorFixStep(
|
|
string CheckId,
|
|
int Order,
|
|
string Command,
|
|
string CommandType,
|
|
string? Description,
|
|
bool RequiresBackup,
|
|
string? SafetyNote);
|
|
|
|
private sealed record DoctorFixPlanEntry(
|
|
DoctorFixStep Step,
|
|
bool IsSafe,
|
|
string Reason);
|
|
}
|