Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions application/src/Ashlar.CLI/Commands/DoctorCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,18 @@ public DoctorCommand() : base("doctor", "Run onboarding readiness report (depend
var fixOpt = new Option<bool>("--fix", () => false, "Attempt safe remediation for fixable onboarding failures.");
var dryRunOpt = new Option<bool>("--dry-run", () => false, "With --fix, list remediation actions without running them.");
var yesOpt = new Option<bool>("--yes", () => false, "Auto-approve remediation actions when --fix is enabled.");
var pathOpt = new Option<DirectoryInfo>(
"--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);
AddOption(jsonOpt);
AddOption(fixOpt);
AddOption(dryRunOpt);
AddOption(yesOpt);
AddOption(pathOpt);

this.SetHandler(async (InvocationContext ctx) =>
{
Expand All @@ -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);
});
}

Expand All @@ -53,8 +59,14 @@ internal static async Task<int> 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();
Expand Down Expand Up @@ -131,6 +143,17 @@ internal static async Task<int> 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",
Expand Down Expand Up @@ -188,6 +211,18 @@ internal static async Task<int> 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;
Expand Down
123 changes: 123 additions & 0 deletions application/src/Ashlar.CLI/Commands/DoctorProjectReadiness.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using Ashlar.Manifest;
using Ashlar.Manifest.Ledger;
using Ashlar.Manifest.Signing;

namespace Ashlar.CLI.Commands;

/// <summary>
/// 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.
/// </summary>
public sealed record DoctorProjectReadiness
{
/// <summary>Verify passed every course.</summary>
public required bool Verified { get; init; }

/// <summary><c>present</c>, <c>absent</c>, or <c>corrupt</c>.</summary>
public required string KeyStatus { get; init; }

/// <summary>Operator key fingerprint when present; otherwise null.</summary>
public string? Fingerprint { get; init; }

/// <summary><c>intact</c>, <c>none</c>, or <c>corrupt</c>.</summary>
public required string LedgerStatus { get; init; }

/// <summary>How many signed entries the ledger holds.</summary>
public required int LedgerEntries { get; init; }

/// <summary>A short verdict word for the block header.</summary>
public required string Verdict { get; init; }

/// <summary>The single next action that advances readiness.</summary>
public required string NextStep { get; init; }

/// <summary>True only when verified, keyed, and the ledger is intact with at least one entry.</summary>
public bool Ready => Verified && KeyStatus == "present" && LedgerStatus == "intact";

/// <summary>
/// Assesses the project at <paramref name="directory"/>, 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.
/// </summary>
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");
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[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<LedgerEntry> 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();
}
}
Loading