audit, advisories and doctors/setup work
This commit is contained in:
@@ -1,14 +1,25 @@
|
||||
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;
|
||||
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;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
@@ -17,6 +28,9 @@ namespace StellaOps.Cli.Commands;
|
||||
/// </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,
|
||||
@@ -24,10 +38,23 @@ internal static class DoctorCommandGroup
|
||||
{
|
||||
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));
|
||||
|
||||
return doctor;
|
||||
}
|
||||
@@ -36,6 +63,26 @@ internal static class DoctorCommandGroup
|
||||
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" })
|
||||
{
|
||||
@@ -84,8 +131,7 @@ internal static class DoctorCommandGroup
|
||||
Description = "Exit with non-zero code on warnings (default: only fail on errors)"
|
||||
};
|
||||
|
||||
var run = new Command("run", "Execute diagnostic checks.")
|
||||
{
|
||||
return new DoctorRunCommandOptions(
|
||||
formatOption,
|
||||
modeOption,
|
||||
categoryOption,
|
||||
@@ -94,39 +140,57 @@ internal static class DoctorCommandGroup
|
||||
parallelOption,
|
||||
timeoutOption,
|
||||
outputOption,
|
||||
failOnWarnOption,
|
||||
verboseOption
|
||||
};
|
||||
failOnWarnOption);
|
||||
}
|
||||
|
||||
run.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var format = parseResult.GetValue(formatOption) ?? "text";
|
||||
var mode = parseResult.GetValue(modeOption);
|
||||
var category = parseResult.GetValue(categoryOption);
|
||||
var tags = parseResult.GetValue(tagOption) ?? [];
|
||||
var checkId = parseResult.GetValue(checkOption);
|
||||
var parallel = parseResult.GetValue(parallelOption) ?? 4;
|
||||
var timeout = parseResult.GetValue(timeoutOption) ?? 30;
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var failOnWarn = parseResult.GetValue(failOnWarnOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
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(verboseOption);
|
||||
}
|
||||
|
||||
await RunDoctorAsync(
|
||||
services,
|
||||
format,
|
||||
mode,
|
||||
category,
|
||||
tags,
|
||||
checkId,
|
||||
parallel,
|
||||
timeout,
|
||||
output,
|
||||
failOnWarn,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
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);
|
||||
|
||||
return run;
|
||||
await RunDoctorAsync(
|
||||
services,
|
||||
format,
|
||||
mode,
|
||||
category,
|
||||
tags,
|
||||
checkId,
|
||||
parallel,
|
||||
timeout,
|
||||
output,
|
||||
failOnWarn,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Command BuildListCommand(
|
||||
@@ -169,10 +233,10 @@ internal static class DoctorCommandGroup
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output ZIP file path",
|
||||
IsRequired = true
|
||||
Arity = ArgumentArity.ExactlyOne
|
||||
};
|
||||
|
||||
var includeLogsOption = new Option<bool>("--include-logs")
|
||||
@@ -221,6 +285,41 @@ internal static class DoctorCommandGroup
|
||||
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,
|
||||
@@ -324,7 +423,8 @@ internal static class DoctorCommandGroup
|
||||
Tags = tags.Length > 0 ? tags : null,
|
||||
CheckIds = checkId != null ? [checkId] : null,
|
||||
Parallelism = parallel,
|
||||
Timeout = TimeSpan.FromSeconds(timeout)
|
||||
Timeout = TimeSpan.FromSeconds(timeout),
|
||||
DoctorCommand = Environment.CommandLine
|
||||
};
|
||||
|
||||
// Progress reporting for verbose mode
|
||||
@@ -358,6 +458,581 @@ internal static class DoctorCommandGroup
|
||||
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,
|
||||
@@ -438,4 +1113,29 @@ internal static class DoctorCommandGroup
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user