diff --git a/application/src/Ashlar.CLI/Commands/DoctorCommand.cs b/application/src/Ashlar.CLI/Commands/DoctorCommand.cs index 4b2e16099..4965802f2 100644 --- a/application/src/Ashlar.CLI/Commands/DoctorCommand.cs +++ b/application/src/Ashlar.CLI/Commands/DoctorCommand.cs @@ -26,6 +26,10 @@ public DoctorCommand() : base("doctor", "Run onboarding readiness report (depend var fixOpt = new Option("--fix", () => false, "Attempt safe remediation for fixable onboarding failures."); var dryRunOpt = new Option("--dry-run", () => false, "With --fix, list remediation actions without running them."); var yesOpt = new Option("--yes", () => false, "Auto-approve remediation actions when --fix is enabled."); + var pathOpt = new Option( + "--path", + () => new DirectoryInfo(Environment.CurrentDirectory), + "Project directory to also report readiness for (defaults to current). Ignored when not an ashlar project."); AddOption(includeOptionalOpt); AddOption(profileOpt); @@ -33,6 +37,7 @@ public DoctorCommand() : base("doctor", "Run onboarding readiness report (depend AddOption(fixOpt); AddOption(dryRunOpt); AddOption(yesOpt); + AddOption(pathOpt); this.SetHandler(async (InvocationContext ctx) => { @@ -42,7 +47,8 @@ public DoctorCommand() : base("doctor", "Run onboarding readiness report (depend var fix = ctx.ParseResult.GetValueForOption(fixOpt); var dryRun = ctx.ParseResult.GetValueForOption(dryRunOpt); var yes = ctx.ParseResult.GetValueForOption(yesOpt); - ctx.ExitCode = await ExecuteAsync(profile, includeOptional, json, fix, dryRun, yes, ctx.GetCancellationToken()).ConfigureAwait(false); + var path = ctx.ParseResult.GetValueForOption(pathOpt)?.FullName; + ctx.ExitCode = await ExecuteAsync(profile, includeOptional, json, fix, dryRun, yes, ctx.GetCancellationToken(), path).ConfigureAwait(false); }); } @@ -53,8 +59,14 @@ internal static async Task ExecuteAsync( bool fix, bool dryRun, bool autoApproveFixes, - CancellationToken ct) + CancellationToken ct, + string? projectPath = null) { + // Project readiness is a SEPARATE question from environment health, reported alongside it + // but never folded into the environment exit code. It is null when the path is not an + // ashlar project, and the block is then omitted entirely. + var readiness = DoctorProjectReadiness.Assess(projectPath ?? Environment.CurrentDirectory); + var dependencyAssessment = await BootstrapRuntime.AssessDemoAsync(profile, includeOptional, ct).ConfigureAwait(false); var hostOs = RuntimeInformation.OSDescription; var osSupported = OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsWindows(); @@ -131,6 +143,17 @@ internal static async Task ExecuteAsync( }, remediation = remediation }, + projectReadiness = readiness is null ? null : new + { + verdict = readiness.Verdict, + ready = readiness.Ready, + verified = readiness.Verified, + key = readiness.KeyStatus, + fingerprint = readiness.Fingerprint, + ledger = readiness.LedgerStatus, + ledgerEntries = readiness.LedgerEntries, + nextStep = readiness.NextStep + }, nextSteps = new { devContainer = "Open repo in Cursor/VS Code → Dev Containers: Reopen in Container", @@ -188,6 +211,18 @@ internal static async Task ExecuteAsync( Console.WriteLine(" - dev container: Reopen in Container (.devcontainer/)"); Console.WriteLine(" - container lane: docker run --rm ghcr.io/ianfrelinger/nexo-cli:latest --help"); Console.WriteLine(" - remediation lane: dotnet run --project application/src/Ashlar.CLI -- doctor --fix --yes"); + + if (readiness is not null) + { + Console.WriteLine(); + Console.WriteLine($"project readiness: {readiness.Verdict}"); + Console.WriteLine($" verify: {(readiness.Verified ? "pass" : "fail")}"); + Console.WriteLine($" operator key: {readiness.KeyStatus}" + + (readiness.Fingerprint is { } fp ? $" ({fp})" : string.Empty)); + Console.WriteLine($" ledger: {readiness.LedgerStatus}" + + (readiness.LedgerStatus == "intact" ? $" ({readiness.LedgerEntries} signed)" : string.Empty)); + Console.WriteLine($" next: {readiness.NextStep}"); + } } return overallOk ? 0 : 1; diff --git a/application/src/Ashlar.CLI/Commands/DoctorProjectReadiness.cs b/application/src/Ashlar.CLI/Commands/DoctorProjectReadiness.cs new file mode 100644 index 000000000..c5195d5d1 --- /dev/null +++ b/application/src/Ashlar.CLI/Commands/DoctorProjectReadiness.cs @@ -0,0 +1,123 @@ +using Ashlar.Manifest; +using Ashlar.Manifest.Ledger; +using Ashlar.Manifest.Signing; + +namespace Ashlar.CLI.Commands; + +/// +/// A one-glance answer to "is THIS project ready to ship?", aggregating the signals the product +/// loop already exposes — does it verify, is there an operator key, is the signed ledger intact, +/// has it been certified — plus the single next action that moves it forward. The environment +/// doctor answers "is my machine set up"; this answers "is my project set up", and the two are +/// deliberately separate questions with separate verdicts. +/// +public sealed record DoctorProjectReadiness +{ + /// Verify passed every course. + public required bool Verified { get; init; } + + /// present, absent, or corrupt. + public required string KeyStatus { get; init; } + + /// Operator key fingerprint when present; otherwise null. + public string? Fingerprint { get; init; } + + /// intact, none, or corrupt. + public required string LedgerStatus { get; init; } + + /// How many signed entries the ledger holds. + public required int LedgerEntries { get; init; } + + /// A short verdict word for the block header. + public required string Verdict { get; init; } + + /// The single next action that advances readiness. + public required string NextStep { get; init; } + + /// True only when verified, keyed, and the ledger is intact with at least one entry. + public bool Ready => Verified && KeyStatus == "present" && LedgerStatus == "intact"; + + /// + /// Assesses the project at , or returns null when it is not an + /// ashlar project (no manifest/policy) — the doctor then simply omits the project block. The + /// key directory is overridable for tests; production passes null to use the machine key. + /// + public static DoctorProjectReadiness? Assess(string directory, string? keyDir = null) + { + var manifestPath = Path.Combine(directory, "ashlar.yaml"); + var policyPath = Path.Combine(directory, "ashlar.policy.yaml"); + if (!File.Exists(manifestPath) || !File.Exists(policyPath)) + { + return null; + } + + var verification = ProjectVerifier.Verify( + File.ReadAllText(manifestPath), File.ReadAllText(policyPath), directory); + var verified = verification.Verified; + + string keyStatus; + string? fingerprint = null; + try + { + var signer = OperatorKey.TryLoad(keyDir); + keyStatus = signer is null ? "absent" : "present"; + fingerprint = signer?.Fingerprint; + } + catch (InvalidOperationException) + { + keyStatus = "corrupt"; + } + + string ledgerStatus; + var ledgerEntries = 0; + try + { + var chain = new InstanceLedger(Path.Combine(directory, ".ashlar")).VerifyChain(); + ledgerEntries = chain.Count; + ledgerStatus = chain.Count == 0 ? "none" : "intact"; + } + catch (InvalidOperationException) + { + ledgerStatus = "corrupt"; + } + + var (verdict, next) = Decide(verified, keyStatus, ledgerStatus); + return new DoctorProjectReadiness + { + Verified = verified, + KeyStatus = keyStatus, + Fingerprint = fingerprint, + LedgerStatus = ledgerStatus, + LedgerEntries = ledgerEntries, + Verdict = verdict, + NextStep = next, + }; + } + + // The verdict ladder, most-broken first: a corrupt ledger or key is a hard block; then + // failing verification; then the ordinary "verified but not yet certified" progression. + private static (string Verdict, string NextStep) Decide(bool verified, string keyStatus, string ledgerStatus) + { + if (ledgerStatus == "corrupt") + { + return ("BLOCKED", "the signed ledger is corrupt — history was altered; inspect .ashlar/ledger/"); + } + if (keyStatus == "corrupt") + { + return ("BLOCKED", "the operator key is corrupt — run `ashlar keys init --rotate` to write a fresh pair"); + } + if (!verified) + { + return ("NOT READY", "run `ashlar verify` to see the failing course, then fix it"); + } + if (keyStatus == "absent") + { + return ("NOT CERTIFIED", "run `ashlar keys init`, then `ashlar verify` to certify this project"); + } + if (ledgerStatus == "none") + { + return ("READY TO CERTIFY", "run `ashlar verify` to write the first signed ledger entry"); + } + return ("CERTIFIED", "certified and ready — nothing to do"); + } +} diff --git a/application/src/Ashlar.Tests.CLI/Tests/Commands/DoctorProjectReadinessTests.cs b/application/src/Ashlar.Tests.CLI/Tests/Commands/DoctorProjectReadinessTests.cs new file mode 100644 index 000000000..8ca54fa3f --- /dev/null +++ b/application/src/Ashlar.Tests.CLI/Tests/Commands/DoctorProjectReadinessTests.cs @@ -0,0 +1,115 @@ +using Ashlar.CLI.Commands; +using Ashlar.Manifest; +using Ashlar.Manifest.Ledger; +using Ashlar.Manifest.Signing; +using FluentAssertions; +using Xunit; + +namespace Ashlar.Tests.CLI.Tests.Commands; + +/// +/// Pins the "is THIS project ready to ship?" ladder the doctor renders: not-a-project is skipped, +/// and a real project climbs NOT CERTIFIED → READY TO CERTIFY → CERTIFIED as a key and a signed +/// ledger appear — while a corrupt key or ledger is a hard BLOCK. +/// +[Trait("Category", "CLI")] +public sealed class DoctorProjectReadinessTests : IDisposable +{ + private readonly string _dir; + private readonly string _keyDir; + + public DoctorProjectReadinessTests() + { + _dir = Path.Combine(Path.GetTempPath(), "doctor-ready-" + Guid.NewGuid().ToString("N")); + _keyDir = Path.Combine(_dir, "keys"); + Directory.CreateDirectory(_dir); + } + + public void Dispose() + { + if (Directory.Exists(_dir)) + { + Directory.Delete(_dir, recursive: true); + } + } + + private void Scaffold() + { + ProjectScaffold.TryScaffold("doctor-demo", out var m, out var p, out var reason).Should().BeTrue(reason); + File.WriteAllText(Path.Combine(_dir, "ashlar.yaml"), m); + File.WriteAllText(Path.Combine(_dir, "ashlar.policy.yaml"), p); + } + + private async Task Certify(SigningIdentity signer) + { + var m = File.ReadAllText(Path.Combine(_dir, "ashlar.yaml")); + var p = File.ReadAllText(Path.Combine(_dir, "ashlar.policy.yaml")); + return await new InstanceLedger(Path.Combine(_dir, ".ashlar")).AppendVerificationAsync( + signer, InstanceLedger.Subject(m, p), verified: true, + [new LedgerCourse { Name = "contract", Passed = true, Detail = "ok" }], DateTimeOffset.UtcNow); + } + + [Fact] + public void A_non_project_directory_yields_no_readiness_block() + { + DoctorProjectReadiness.Assess(_dir, _keyDir).Should().BeNull("no manifest/policy — nothing to assess"); + } + + [Fact] + public void A_verified_project_with_no_key_is_not_certified() + { + Scaffold(); + + var r = DoctorProjectReadiness.Assess(_dir, _keyDir)!; + + r.Verified.Should().BeTrue(); + r.KeyStatus.Should().Be("absent"); + r.Verdict.Should().Be("NOT CERTIFIED"); + r.NextStep.Should().Contain("keys init"); + r.Ready.Should().BeFalse(); + } + + [Fact] + public void A_keyed_project_with_no_ledger_is_ready_to_certify() + { + Scaffold(); + OperatorKey.Generate(_keyDir); + + var r = DoctorProjectReadiness.Assess(_dir, _keyDir)!; + + r.KeyStatus.Should().Be("present"); + r.Fingerprint.Should().StartWith("ed25519:"); + r.LedgerStatus.Should().Be("none"); + r.Verdict.Should().Be("READY TO CERTIFY"); + } + + [Fact] + public async Task A_keyed_project_with_a_signed_ledger_is_certified_and_ready() + { + Scaffold(); + var signer = OperatorKey.Generate(_keyDir); + await Certify(signer); + + var r = DoctorProjectReadiness.Assess(_dir, _keyDir)!; + + r.Verdict.Should().Be("CERTIFIED"); + r.LedgerStatus.Should().Be("intact"); + r.LedgerEntries.Should().Be(1); + r.Ready.Should().BeTrue(); + } + + [Fact] + public async Task A_corrupt_ledger_is_a_hard_block() + { + Scaffold(); + var signer = OperatorKey.Generate(_keyDir); + await Certify(signer); + await File.WriteAllTextAsync(Path.Combine(_dir, ".ashlar", "ledger", "000001.json"), "{ not valid"); + + var r = DoctorProjectReadiness.Assess(_dir, _keyDir)!; + + r.LedgerStatus.Should().Be("corrupt"); + r.Verdict.Should().Be("BLOCKED"); + r.Ready.Should().BeFalse(); + } +}