Skip to content
Open
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
5 changes: 3 additions & 2 deletions docs/output-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ txc env entity list --format text
| `0` (`ExitSuccess`) | Operation completed successfully | Default success path |
| `1` (`ExitError`) | Runtime/operational error | Service call failed, network error, unexpected exception |
| `2` (`ExitValidationError`) | Input validation error or resource not found | Bad arguments, missing required input, entity not found |
| `3` (`ExitAuthRequired`) | Authentication required | A command touched a live environment but there is no usable sign-in (missing token, expired cache, or a headless context that forbids interactive login). The message names the environment URL and the `txc config profile create --url <url>` fix to run manually. |

The base class `TxcLeafCommand.RunAsync()` catches unhandled exceptions and returns `ExitError` (1) automatically. Commands only need explicit exit code handling for validation errors (2).
The base class `TxcLeafCommand.RunAsync()` catches unhandled exceptions and returns `ExitError` (1) automatically. It maps `EnvironmentAuthRequiredException` (anywhere in the inner-exception chain) to `ExitAuthRequired` (3). Commands only need explicit exit code handling for validation errors (2).

## Command implementation pattern

Expand Down Expand Up @@ -81,7 +82,7 @@ public class WidgetListCliCommand : ProfiledCliCommand
- **`OutputContext` setup** — applies the format flag with TTY auto-detection
- **Standardized try/catch** — catches `ConfigurationResolutionException`, `OperationCanceledException`, and general exceptions
- **`ILogger` requirement** — `protected abstract ILogger Logger` ensures every command has logging
- **Exit code constants** — `ExitSuccess` (0), `ExitError` (1), `ExitValidationError` (2)
- **Exit code constants** — `ExitSuccess` (0), `ExitError` (1), `ExitValidationError` (2), `ExitAuthRequired` (3)

### What commands must NOT do

Expand Down
13 changes: 13 additions & 0 deletions src/TALXIS.CLI.Abstractions/ExceptionHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,17 @@ public static Exception GetInnermostException(Exception ex)
ex = ex.InnerException;
return ex;
}

/// <summary>
/// Returns the first exception of type <typeparamref name="T"/> in the
/// inner-exception chain (including <paramref name="ex"/>), or null.
/// </summary>
public static T? FindInChain<T>(Exception ex) where T : Exception
{
for (Exception? current = ex; current is not null; current = current.InnerException)
{
if (current is T match) return match;
}
return null;
}
}
49 changes: 49 additions & 0 deletions src/TALXIS.CLI.Core/Headless/EnvironmentAuthRequiredException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
namespace TALXIS.CLI.Core.Headless;

/// <summary>
/// Auth failure that carries the target environment URL and renders the exact
/// <c>txc config profile create --url &lt;url&gt;</c> remedy the user must run manually.
/// </summary>
public sealed class EnvironmentAuthRequiredException : Exception
{
public string EnvironmentUrl { get; }
public string? HeadlessReason { get; }

#pragma warning disable RS0030 // domain exception - inheriting from Exception is intentional
public EnvironmentAuthRequiredException(string environmentUrl, string? headlessReason = null, Exception? innerException = null)
: base(BuildMessage(environmentUrl, headlessReason), innerException)
#pragma warning restore RS0030
{
EnvironmentUrl = environmentUrl;
HeadlessReason = headlessReason;
}

public static string BuildMessage(string environmentUrl, string? headlessReason)
{
var url = NormalizeUrl(environmentUrl);
var target = url ?? "the target environment";

var lines = new List<string>
{
$"Action required - sign in to {target} before retrying:",
url is not null
? $" txc config profile create --url {url}"
: " txc config profile create --url <environment-url>",
"Run that command manually in an interactive terminal (browser required), then retry.",
};

var why = "txc never signs in on its own: interactive sign-in needs a human in the loop, so agents and non-interactive runs are blocked from it.";
if (!string.IsNullOrWhiteSpace(headlessReason)) why += $" This run is non-interactive ({headlessReason}).";
lines.Add(why);

return string.Join(Environment.NewLine, lines);
}

private static string? NormalizeUrl(string? environmentUrl)
{
if (string.IsNullOrWhiteSpace(environmentUrl)) return null;
return Uri.TryCreate(environmentUrl, UriKind.Absolute, out var uri)
? uri.GetLeftPart(UriPartial.Authority) + "/"
: environmentUrl.TrimEnd('/') + "/";
}
}
16 changes: 16 additions & 0 deletions src/TALXIS.CLI.Core/Shared/TxcLeafCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ public abstract class TxcLeafCommand
protected const int ExitError = 1;
/// <summary>Exit code: input validation error or resource not found.</summary>
protected const int ExitValidationError = 2;
/// <summary>Exit code: authentication required — the user must sign in to the target environment before retrying.</summary>
protected const int ExitAuthRequired = 3;

[CliOption(
Name = "--format",
Expand Down Expand Up @@ -105,6 +107,20 @@ public async Task<int> RunAsync()
exitCode = await ExecuteAsync().ConfigureAwait(false);
return exitCode;
}
catch (Exception ex) when (
ExceptionHelpers.FindInChain<Headless.EnvironmentAuthRequiredException>(ex) is not null)
{
exitCode = ExitAuthRequired;

// Surface the auth exception's own message, not the innermost MSAL error.
var authEx = ExceptionHelpers.FindInChain<Headless.EnvironmentAuthRequiredException>(ex)!;

scope.SetError(exitCode, "auth", authEx.Message);
OutputFormatter.WriteResult("failed", authEx.Message, exitCode: exitCode);
Logger.LogError(authEx, "Authentication required: {Error}", authEx.Message);
LogSupportInfo();
return exitCode;
}
catch (Exception ex) when (ex is Abstractions.ConfigurationResolutionException or ArgumentException)
{
exitCode = ExitValidationError;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Identity.Client;
using TALXIS.CLI.Core.Abstractions;
using TALXIS.CLI.Core.Headless;
using TALXIS.CLI.Core.Identity;
using TALXIS.CLI.Core.Model;
using TALXIS.CLI.Platform.Dataverse.Runtime.Scopes;
Expand Down Expand Up @@ -137,9 +138,9 @@ private async Task<string> AcquirePublicClientSilentAsync(

if (account is null)
{
throw new InvalidOperationException(
$"No cached sign-in found for credential '{credential.Id}'. " +
"Run 'txc config auth login' and retry.");
throw new EnvironmentAuthRequiredException(
connection.EnvironmentUrl,
_headless.IsHeadless ? _headless.Reason : null);
}

try
Expand Down Expand Up @@ -184,9 +185,10 @@ private async Task<string> AcquirePublicClientSilentAsync(
}
}

throw new InvalidOperationException(
$"Cached token for '{credential.Id}' expired or is missing consent. " +
"Run 'txc config auth login' and retry.", ex);
throw new EnvironmentAuthRequiredException(
connection.EnvironmentUrl,
_headless.IsHeadless ? _headless.Reason : null,
ex);
}
}

Expand Down
44 changes: 44 additions & 0 deletions tests/TALXIS.CLI.Tests/Abstractions/ExceptionHelpersTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System;
using TALXIS.CLI.Abstractions;
using Xunit;

namespace TALXIS.CLI.Tests.Abstractions;

public class ExceptionHelpersTests
{
private sealed class MarkerException : Exception
{
public MarkerException(string message) : base(message) { }
}

[Fact]
public void FindInChain_ReturnsMatch_WhenTopLevel()
{
var ex = new MarkerException("top");

var found = ExceptionHelpers.FindInChain<MarkerException>(ex);

Assert.Same(ex, found);
}

[Fact]
public void FindInChain_ReturnsMatch_WhenWrapped()
{
var marker = new MarkerException("inner");
var wrapped = new InvalidOperationException("outer", marker);

var found = ExceptionHelpers.FindInChain<MarkerException>(wrapped);

Assert.Same(marker, found);
}

[Fact]
public void FindInChain_ReturnsNull_WhenAbsent()
{
var ex = new InvalidOperationException("outer", new ArgumentException("inner"));

var found = ExceptionHelpers.FindInChain<MarkerException>(ex);

Assert.Null(found);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using TALXIS.CLI.Core;
using TALXIS.CLI.Core.Headless;
using TALXIS.CLI.Core.Model;
using TALXIS.CLI.Tests.Config.Commands;
using Xunit;

namespace TALXIS.CLI.Tests.Config.Headless;

[Collection("Sequential")]
public class AuthRequiredExitCodeTests
{
private sealed class ThrowingCommand : TxcLeafCommand
{
private readonly Exception _toThrow;
public ThrowingCommand(Exception toThrow) { _toThrow = toThrow; }
protected override ILogger Logger { get; } = NullLogger.Instance;
protected override Task<int> ExecuteAsync() => throw _toThrow;
}

[Fact]
public async Task EnvironmentAuthRequired_MapsToExitCode3()
{
using var _ = new CommandTestHost();
var cmd = new ThrowingCommand(
new EnvironmentAuthRequiredException("https://devbox.crm4.dynamics.com"));

var exit = await cmd.RunAsync();

Assert.Equal(3, exit);
}

[Fact]
public async Task WrappedEnvironmentAuthRequired_MapsToExitCode3()
{
using var _ = new CommandTestHost();
var inner = new EnvironmentAuthRequiredException("https://devbox.crm4.dynamics.com");
var cmd = new ThrowingCommand(new InvalidOperationException("service wrapper", inner));

var exit = await cmd.RunAsync();

Assert.Equal(3, exit);
}

[Fact]
public async Task HeadlessAuthRequired_StaysExitCode1()
{
// Exit 3 is scoped to EnvironmentAuthRequiredException only.
using var _ = new CommandTestHost();
var cmd = new ThrowingCommand(
new HeadlessAuthRequiredException(CredentialKind.InteractiveBrowser, "CI=true"));

var exit = await cmd.RunAsync();

Assert.Equal(1, exit);
}

[Fact]
public async Task UnrelatedError_StillMapsToExitCode1()
{
using var _ = new CommandTestHost();
var cmd = new ThrowingCommand(new InvalidOperationException("boom"));

var exit = await cmd.RunAsync();

Assert.Equal(1, exit);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using TALXIS.CLI.Core.Headless;
using Xunit;

namespace TALXIS.CLI.Tests.Config.Headless;

public class EnvironmentAuthRequiredExceptionTests
{
private const string Url = "https://devbox-2782.crm4.dynamics.com";

[Fact]
public void Message_NamesEnvironmentUrl_AndFixCommand()
{
var ex = new EnvironmentAuthRequiredException(Url);

Assert.Contains("https://devbox-2782.crm4.dynamics.com/", ex.Message);
Assert.Contains("txc config profile create --url https://devbox-2782.crm4.dynamics.com/", ex.Message);
}

[Fact]
public void Message_TellsUserToRunManuallyInInteractiveTerminal()
{
var ex = new EnvironmentAuthRequiredException(Url);

Assert.Contains("interactive terminal", ex.Message);
Assert.Contains("human in the loop", ex.Message);
}

[Fact]
public void Message_NoEmDash()
{
var ex = new EnvironmentAuthRequiredException(Url);

Assert.DoesNotContain('—', ex.Message);
}

[Fact]
public void Message_AppendsHeadlessReason_WhenProvided()
{
var ex = new EnvironmentAuthRequiredException(Url, headlessReason: "CI=true");

Assert.Contains("CI=true", ex.Message);
Assert.Equal("CI=true", ex.HeadlessReason);
}

[Fact]
public void Message_OmitsHeadlessReason_WhenInteractive()
{
var ex = new EnvironmentAuthRequiredException(Url, headlessReason: null);

Assert.DoesNotContain("non-interactive (", ex.Message);
Assert.Null(ex.HeadlessReason);
}

[Theory]
[InlineData("https://contoso.crm4.dynamics.com")]
[InlineData("https://contoso.crm4.dynamics.com/")]
[InlineData("https://contoso.crm4.dynamics.com/main.aspx")]
public void Message_NormalizesUrlToAuthorityWithTrailingSlash(string input)
{
var ex = new EnvironmentAuthRequiredException(input);

Assert.Contains("https://contoso.crm4.dynamics.com/", ex.Message);
Assert.DoesNotContain("main.aspx", ex.Message);
}

[Fact]
public void Message_FallsBack_WhenUrlMissing()
{
var ex = new EnvironmentAuthRequiredException(environmentUrl: "");

Assert.Contains("the target environment", ex.Message);
Assert.Contains("--url <environment-url>", ex.Message);
}

[Fact]
public void Preserves_InnerException()
{
var inner = new InvalidOperationException("token expired");
var ex = new EnvironmentAuthRequiredException(Url, innerException: inner);

Assert.Same(inner, ex.InnerException);
}
}