Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/DoctorCommandGroup.cs

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);
}