diff --git a/src/PPDS.Auth/Cloud/CloudEndpoints.cs b/src/PPDS.Auth/Cloud/CloudEndpoints.cs
index 87f07b4b..aeea195e 100644
--- a/src/PPDS.Auth/Cloud/CloudEndpoints.cs
+++ b/src/PPDS.Auth/Cloud/CloudEndpoints.cs
@@ -131,6 +131,29 @@ public static string GetPowerAutomateApiUrl(CloudEnvironment cloud)
};
}
+ ///
+ /// Gets the Power Apps Service scope URL for the specified cloud environment.
+ /// This is the resource/audience used when acquiring tokens for the Flow API and Connections API.
+ ///
+ ///
+ /// The Flow API (api.flow.microsoft.com) requires tokens with the service.powerapps.com audience,
+ /// not the api.powerapps.com or api.flow.microsoft.com audiences.
+ ///
+ /// The cloud environment.
+ /// The Power Apps Service scope URL (without /.default suffix).
+ public static string GetPowerAppsServiceScope(CloudEnvironment cloud)
+ {
+ return cloud switch
+ {
+ CloudEnvironment.Public => "https://service.powerapps.com",
+ CloudEnvironment.UsGov => "https://service.powerapps.us",
+ CloudEnvironment.UsGovHigh => "https://high.service.powerapps.us",
+ CloudEnvironment.UsGovDod => "https://service.apps.appsplatform.us",
+ CloudEnvironment.China => "https://service.powerapps.cn",
+ _ => throw new ArgumentOutOfRangeException(nameof(cloud), cloud, "Unknown cloud environment")
+ };
+ }
+
///
/// Parses a cloud environment from a string value.
///
diff --git a/src/PPDS.Auth/Credentials/IPowerPlatformTokenProvider.cs b/src/PPDS.Auth/Credentials/IPowerPlatformTokenProvider.cs
index 51e282a9..a4ecb1b2 100644
--- a/src/PPDS.Auth/Credentials/IPowerPlatformTokenProvider.cs
+++ b/src/PPDS.Auth/Credentials/IPowerPlatformTokenProvider.cs
@@ -29,6 +29,16 @@ public interface IPowerPlatformTokenProvider : IDisposable
/// If authentication fails.
Task GetPowerAutomateTokenAsync(CancellationToken cancellationToken = default);
+ ///
+ /// Acquires an access token for the Flow API using the correct service.powerapps.com scope.
+ /// Use this method for Flow API and Connections API operations, which require the
+ /// service.powerapps.com audience rather than api.flow.microsoft.com.
+ ///
+ /// Cancellation token.
+ /// A valid access token with service.powerapps.com audience.
+ /// If authentication fails.
+ Task GetFlowApiTokenAsync(CancellationToken cancellationToken = default);
+
///
/// Acquires an access token for the specified Power Platform resource.
///
diff --git a/src/PPDS.Auth/Credentials/PowerPlatformTokenProvider.cs b/src/PPDS.Auth/Credentials/PowerPlatformTokenProvider.cs
index 4786295d..5c12e8d2 100644
--- a/src/PPDS.Auth/Credentials/PowerPlatformTokenProvider.cs
+++ b/src/PPDS.Auth/Credentials/PowerPlatformTokenProvider.cs
@@ -161,6 +161,15 @@ public Task GetPowerAutomateTokenAsync(CancellationToken can
return GetTokenForResourceAsync(resource, cancellationToken);
}
+ ///
+ public Task GetFlowApiTokenAsync(CancellationToken cancellationToken = default)
+ {
+ // The Flow API and Connections API require tokens with the service.powerapps.com scope,
+ // not the api.flow.microsoft.com scope. This is a Microsoft API design quirk.
+ var resource = CloudEndpoints.GetPowerAppsServiceScope(_cloud);
+ return GetTokenForResourceAsync(resource, cancellationToken);
+ }
+
///
public async Task GetTokenForResourceAsync(string resource, CancellationToken cancellationToken = default)
{
diff --git a/src/PPDS.Cli/CHANGELOG.md b/src/PPDS.Cli/CHANGELOG.md
index afe6c3c6..a272066e 100644
--- a/src/PPDS.Cli/CHANGELOG.md
+++ b/src/PPDS.Cli/CHANGELOG.md
@@ -21,6 +21,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Session-scoped connection pooling for faster subsequent queries
- Graceful degradation when not in a TTY environment
- **Version header at startup** - CLI now outputs diagnostic header to stderr: version info (CLI, SDK, .NET runtime) and platform. Enables correlating issues to specific builds. Skipped for `--help`, `--version`, or no arguments. See [ADR-0022](../../docs/adr/0022_IMPORT_DIAGNOSTICS_ARCHITECTURE.md).
+- **`ppds flows` command group** - Manage cloud flows ([#142](https://github.com/joshsmithxrm/ppds-sdk/issues/142)):
+ - `ppds flows list` - List cloud flows (supports `--solution`, `--state`)
+ - `ppds flows get ` - Get flow details by unique name
+ - `ppds flows url ` - Get Power Automate maker URL for a flow
+- **`ppds connections` command group** - List Power Platform connections ([#144](https://github.com/joshsmithxrm/ppds-sdk/issues/144)):
+ - `ppds connections list` - List connections from Power Apps Admin API (supports `--connector`)
+ - `ppds connections get ` - Get connection details by ID
+- **`ppds connectionreferences` command group** - Manage connection references with orphan detection ([#143](https://github.com/joshsmithxrm/ppds-sdk/issues/143)):
+ - `ppds connectionreferences list` - List connection references (supports `--solution`, `--orphaned`)
+ - `ppds connectionreferences get ` - Get connection reference details by logical name
+ - `ppds connectionreferences flows ` - List flows using a connection reference
+ - `ppds connectionreferences connections ` - Show bound connection details
+ - `ppds connectionreferences analyze` - Analyze flow-to-connection-reference relationships with orphan detection
+- **`ppds deployment-settings` command group** - Generate and sync deployment settings files ([#145](https://github.com/joshsmithxrm/ppds-sdk/issues/145)):
+ - `ppds deployment-settings generate` - Generate deployment settings file from current environment
+ - `ppds deployment-settings sync` - Sync existing file with solution (preserves values, adds new entries, removes stale)
+ - `ppds deployment-settings validate` - Validate deployment settings file against solution
- **`ppds solutions` command group** - Manage Power Platform solutions ([#137](https://github.com/joshsmithxrm/ppds-sdk/issues/137)):
- `ppds solutions list` - List solutions in environment (supports `--include-managed`, `--filter`)
- `ppds solutions get ` - Get solution details by unique name
diff --git a/src/PPDS.Cli/Commands/ConnectionReferences/AnalyzeCommand.cs b/src/PPDS.Cli/Commands/ConnectionReferences/AnalyzeCommand.cs
new file mode 100644
index 00000000..07c53d33
--- /dev/null
+++ b/src/PPDS.Cli/Commands/ConnectionReferences/AnalyzeCommand.cs
@@ -0,0 +1,241 @@
+using System.CommandLine;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.DependencyInjection;
+using PPDS.Cli.Infrastructure;
+using PPDS.Cli.Infrastructure.Errors;
+using PPDS.Cli.Infrastructure.Output;
+using PPDS.Dataverse.Services;
+
+namespace PPDS.Cli.Commands.ConnectionReferences;
+
+///
+/// Analyze flow-connection reference relationships and detect orphans.
+///
+public static class AnalyzeCommand
+{
+ public static Command Create()
+ {
+ var orphansOnlyOption = new Option("--orphans-only")
+ {
+ Description = "Only show orphaned relationships"
+ };
+
+ var command = new Command("analyze", "Analyze flow-connection reference relationships (orphan detection)")
+ {
+ ConnectionReferencesCommandGroup.SolutionOption,
+ orphansOnlyOption,
+ ConnectionReferencesCommandGroup.ProfileOption,
+ ConnectionReferencesCommandGroup.EnvironmentOption
+ };
+
+ GlobalOptions.AddToCommand(command);
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var solution = parseResult.GetValue(ConnectionReferencesCommandGroup.SolutionOption);
+ var orphansOnly = parseResult.GetValue(orphansOnlyOption);
+ var profile = parseResult.GetValue(ConnectionReferencesCommandGroup.ProfileOption);
+ var environment = parseResult.GetValue(ConnectionReferencesCommandGroup.EnvironmentOption);
+ var globalOptions = GlobalOptions.GetValues(parseResult);
+
+ return await ExecuteAsync(solution, orphansOnly, profile, environment, globalOptions, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static async Task ExecuteAsync(
+ string? solution,
+ bool orphansOnly,
+ string? profile,
+ string? environment,
+ GlobalOptionValues globalOptions,
+ CancellationToken cancellationToken)
+ {
+ var writer = ServiceFactory.CreateOutputWriter(globalOptions);
+
+ try
+ {
+ await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync(
+ profile,
+ environment,
+ globalOptions.Verbose,
+ globalOptions.Debug,
+ ProfileServiceFactory.DefaultDeviceCodeCallback,
+ cancellationToken);
+
+ var crService = serviceProvider.GetRequiredService();
+
+ if (!globalOptions.IsJsonMode)
+ {
+ var connectionInfo = serviceProvider.GetRequiredService();
+ ConsoleHeader.WriteConnectedAs(connectionInfo);
+ Console.Error.WriteLine();
+ }
+
+ var analysis = await crService.AnalyzeAsync(solution, cancellationToken);
+
+ var relationships = orphansOnly
+ ? analysis.Relationships.Where(r => r.Type != RelationshipType.FlowToConnectionReference).ToList()
+ : analysis.Relationships;
+
+ if (globalOptions.IsJsonMode)
+ {
+ var output = new AnalysisOutput
+ {
+ Summary = new AnalysisSummary
+ {
+ ValidCount = analysis.ValidCount,
+ OrphanedFlowCount = analysis.OrphanedFlowCount,
+ OrphanedConnectionReferenceCount = analysis.OrphanedConnectionReferenceCount,
+ HasOrphans = analysis.HasOrphans
+ },
+ Relationships = relationships.Select(r => new RelationshipOutput
+ {
+ Type = r.Type.ToString(),
+ FlowUniqueName = r.FlowUniqueName,
+ FlowDisplayName = r.FlowDisplayName,
+ ConnectionReferenceLogicalName = r.ConnectionReferenceLogicalName,
+ ConnectionReferenceDisplayName = r.ConnectionReferenceDisplayName,
+ ConnectorId = r.ConnectorId,
+ IsBound = r.IsBound
+ }).ToList()
+ };
+
+ writer.WriteSuccess(output);
+ }
+ else
+ {
+ Console.Error.WriteLine("Flow-Connection Reference Analysis");
+ Console.Error.WriteLine("===================================");
+ Console.Error.WriteLine();
+ Console.Error.WriteLine($" Valid relationships: {analysis.ValidCount}");
+ Console.Error.WriteLine($" Orphaned flows (missing CRs): {analysis.OrphanedFlowCount}");
+ Console.Error.WriteLine($" Orphaned connection references (unused): {analysis.OrphanedConnectionReferenceCount}");
+ Console.Error.WriteLine();
+
+ if (!analysis.HasOrphans && orphansOnly)
+ {
+ Console.Error.WriteLine("No orphans detected.");
+ return ExitCodes.Success;
+ }
+
+ if (relationships.Count == 0)
+ {
+ Console.Error.WriteLine("No relationships found.");
+ return ExitCodes.Success;
+ }
+
+ // Group by type for cleaner output
+ var orphanedFlows = relationships.Where(r => r.Type == RelationshipType.OrphanedFlow).ToList();
+ var orphanedCRs = relationships.Where(r => r.Type == RelationshipType.OrphanedConnectionReference).ToList();
+ var validRelationships = relationships.Where(r => r.Type == RelationshipType.FlowToConnectionReference).ToList();
+
+ if (orphanedFlows.Count > 0)
+ {
+ Console.Error.WriteLine();
+ Console.Error.WriteLine("ORPHANED FLOWS (referencing missing connection references):");
+ Console.Error.WriteLine("------------------------------------------------------------");
+ foreach (var r in orphanedFlows)
+ {
+ Console.Error.WriteLine($" Flow: {r.FlowDisplayName ?? r.FlowUniqueName}");
+ Console.Error.WriteLine($" References missing CR: {r.ConnectionReferenceLogicalName}");
+ Console.Error.WriteLine();
+ }
+ }
+
+ if (orphanedCRs.Count > 0)
+ {
+ Console.Error.WriteLine();
+ Console.Error.WriteLine("ORPHANED CONNECTION REFERENCES (not used by any flow):");
+ Console.Error.WriteLine("------------------------------------------------------");
+ foreach (var r in orphanedCRs)
+ {
+ var boundStatus = r.IsBound == true ? "Bound" : "Unbound";
+ Console.Error.WriteLine($" {r.ConnectionReferenceDisplayName ?? r.ConnectionReferenceLogicalName}");
+ Console.Error.WriteLine($" Name: {r.ConnectionReferenceLogicalName} Status: {boundStatus}");
+ if (r.ConnectorId != null)
+ {
+ Console.Error.WriteLine($" Connector: {r.ConnectorId}");
+ }
+ Console.Error.WriteLine();
+ }
+ }
+
+ if (!orphansOnly && validRelationships.Count > 0)
+ {
+ Console.Error.WriteLine();
+ Console.Error.WriteLine("VALID RELATIONSHIPS:");
+ Console.Error.WriteLine("--------------------");
+ foreach (var r in validRelationships)
+ {
+ var boundStatus = r.IsBound == true ? "Bound" : "Unbound";
+ Console.Error.WriteLine($" Flow: {r.FlowDisplayName ?? r.FlowUniqueName}");
+ Console.Error.WriteLine($" -> CR: {r.ConnectionReferenceLogicalName} ({boundStatus})");
+ Console.Error.WriteLine();
+ }
+ }
+ }
+
+ return ExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ var error = ExceptionMapper.Map(ex, context: "analyzing connection references", debug: globalOptions.Debug);
+ writer.WriteError(error);
+ return ExceptionMapper.ToExitCode(ex);
+ }
+ }
+
+ #region Output Models
+
+ private sealed class AnalysisOutput
+ {
+ [JsonPropertyName("summary")]
+ public AnalysisSummary Summary { get; set; } = new();
+
+ [JsonPropertyName("relationships")]
+ public List Relationships { get; set; } = new();
+ }
+
+ private sealed class AnalysisSummary
+ {
+ [JsonPropertyName("validCount")]
+ public int ValidCount { get; set; }
+
+ [JsonPropertyName("orphanedFlowCount")]
+ public int OrphanedFlowCount { get; set; }
+
+ [JsonPropertyName("orphanedConnectionReferenceCount")]
+ public int OrphanedConnectionReferenceCount { get; set; }
+
+ [JsonPropertyName("hasOrphans")]
+ public bool HasOrphans { get; set; }
+ }
+
+ private sealed class RelationshipOutput
+ {
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = string.Empty;
+
+ [JsonPropertyName("flowUniqueName")]
+ public string? FlowUniqueName { get; set; }
+
+ [JsonPropertyName("flowDisplayName")]
+ public string? FlowDisplayName { get; set; }
+
+ [JsonPropertyName("connectionReferenceLogicalName")]
+ public string? ConnectionReferenceLogicalName { get; set; }
+
+ [JsonPropertyName("connectionReferenceDisplayName")]
+ public string? ConnectionReferenceDisplayName { get; set; }
+
+ [JsonPropertyName("connectorId")]
+ public string? ConnectorId { get; set; }
+
+ [JsonPropertyName("isBound")]
+ public bool? IsBound { get; set; }
+ }
+
+ #endregion
+}
diff --git a/src/PPDS.Cli/Commands/ConnectionReferences/ConnectionReferencesCommandGroup.cs b/src/PPDS.Cli/Commands/ConnectionReferences/ConnectionReferencesCommandGroup.cs
new file mode 100644
index 00000000..7cae3f50
--- /dev/null
+++ b/src/PPDS.Cli/Commands/ConnectionReferences/ConnectionReferencesCommandGroup.cs
@@ -0,0 +1,43 @@
+using System.CommandLine;
+
+namespace PPDS.Cli.Commands.ConnectionReferences;
+
+///
+/// Command group for connection reference operations.
+///
+public static class ConnectionReferencesCommandGroup
+{
+ /// Shared profile option.
+ public static readonly Option ProfileOption = new("--profile", "-p")
+ {
+ Description = "Authentication profile name"
+ };
+
+ /// Shared environment option.
+ public static readonly Option EnvironmentOption = new("--environment", "-e")
+ {
+ Description = "Environment URL override"
+ };
+
+ /// Shared solution filter option.
+ public static readonly Option SolutionOption = new("--solution", "-s")
+ {
+ Description = "Filter by solution unique name"
+ };
+
+ ///
+ /// Creates the connectionreferences command group.
+ ///
+ public static Command Create()
+ {
+ var command = new Command("connectionreferences", "Manage connection references");
+
+ command.Subcommands.Add(ListCommand.Create());
+ command.Subcommands.Add(GetCommand.Create());
+ command.Subcommands.Add(FlowsCommand.Create());
+ command.Subcommands.Add(ConnectionsCommand.Create());
+ command.Subcommands.Add(AnalyzeCommand.Create());
+
+ return command;
+ }
+}
diff --git a/src/PPDS.Cli/Commands/ConnectionReferences/ConnectionsCommand.cs b/src/PPDS.Cli/Commands/ConnectionReferences/ConnectionsCommand.cs
new file mode 100644
index 00000000..9ac79730
--- /dev/null
+++ b/src/PPDS.Cli/Commands/ConnectionReferences/ConnectionsCommand.cs
@@ -0,0 +1,243 @@
+using System.CommandLine;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.DependencyInjection;
+using PPDS.Cli.Infrastructure;
+using PPDS.Cli.Infrastructure.Errors;
+using PPDS.Cli.Infrastructure.Output;
+using PPDS.Cli.Services;
+using PPDS.Dataverse.Services;
+
+namespace PPDS.Cli.Commands.ConnectionReferences;
+
+///
+/// Show bound connection details for a connection reference.
+///
+public static class ConnectionsCommand
+{
+ public static Command Create()
+ {
+ var nameArgument = new Argument("name")
+ {
+ Description = "The logical name of the connection reference"
+ };
+
+ var command = new Command("connections", "Show bound connection for a connection reference")
+ {
+ nameArgument,
+ ConnectionReferencesCommandGroup.ProfileOption,
+ ConnectionReferencesCommandGroup.EnvironmentOption
+ };
+
+ GlobalOptions.AddToCommand(command);
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var name = parseResult.GetValue(nameArgument)!;
+ var profile = parseResult.GetValue(ConnectionReferencesCommandGroup.ProfileOption);
+ var environment = parseResult.GetValue(ConnectionReferencesCommandGroup.EnvironmentOption);
+ var globalOptions = GlobalOptions.GetValues(parseResult);
+
+ return await ExecuteAsync(name, profile, environment, globalOptions, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static async Task ExecuteAsync(
+ string name,
+ string? profile,
+ string? environment,
+ GlobalOptionValues globalOptions,
+ CancellationToken cancellationToken)
+ {
+ var writer = ServiceFactory.CreateOutputWriter(globalOptions);
+
+ try
+ {
+ await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync(
+ profile,
+ environment,
+ globalOptions.Verbose,
+ globalOptions.Debug,
+ ProfileServiceFactory.DefaultDeviceCodeCallback,
+ cancellationToken);
+
+ var crService = serviceProvider.GetRequiredService();
+
+ if (!globalOptions.IsJsonMode)
+ {
+ var connectionInfo = serviceProvider.GetRequiredService();
+ ConsoleHeader.WriteConnectedAs(connectionInfo);
+ Console.Error.WriteLine();
+ }
+
+ var cr = await crService.GetAsync(name, cancellationToken);
+
+ if (cr == null)
+ {
+ writer.WriteError(new StructuredError(
+ ErrorCodes.Operation.NotFound,
+ $"Connection reference '{name}' not found"));
+ return ExitCodes.NotFoundError;
+ }
+
+ if (!cr.IsBound)
+ {
+ if (globalOptions.IsJsonMode)
+ {
+ var output = new ConnectionBindingOutput
+ {
+ ConnectionReference = new ConnectionRefInfo
+ {
+ LogicalName = cr.LogicalName,
+ DisplayName = cr.DisplayName,
+ ConnectorId = cr.ConnectorId
+ },
+ IsBound = false,
+ Connection = null
+ };
+ writer.WriteSuccess(output);
+ }
+ else
+ {
+ Console.Error.WriteLine($"Connection Reference: {cr.DisplayName ?? cr.LogicalName}");
+ Console.Error.WriteLine();
+ Console.Error.WriteLine("This connection reference is not bound to a connection.");
+ Console.Error.WriteLine("Use the Power Apps portal or pac solution commands to bind a connection.");
+ }
+ return ExitCodes.Success;
+ }
+
+ // Try to get connection details from Power Apps API
+ ConnectionInfo? connection = null;
+ try
+ {
+ var connectionService = serviceProvider.GetService();
+ if (connectionService != null && cr.ConnectionId != null)
+ {
+ connection = await connectionService.GetAsync(cr.ConnectionId, cancellationToken);
+ }
+ }
+ catch (Exception ex)
+ {
+ // Connection service may not be available or may fail (e.g., SPN auth)
+ if (!globalOptions.IsJsonMode)
+ {
+ Console.Error.WriteLine($"Warning: Could not retrieve connection details: {ex.Message}");
+ Console.Error.WriteLine();
+ }
+ }
+
+ if (globalOptions.IsJsonMode)
+ {
+ var output = new ConnectionBindingOutput
+ {
+ ConnectionReference = new ConnectionRefInfo
+ {
+ LogicalName = cr.LogicalName,
+ DisplayName = cr.DisplayName,
+ ConnectorId = cr.ConnectorId
+ },
+ IsBound = true,
+ Connection = connection != null ? new ConnectionDetails
+ {
+ ConnectionId = connection.ConnectionId,
+ DisplayName = connection.DisplayName,
+ ConnectorDisplayName = connection.ConnectorDisplayName,
+ Status = connection.Status.ToString(),
+ CreatedBy = connection.CreatedBy
+ } : new ConnectionDetails
+ {
+ ConnectionId = cr.ConnectionId!,
+ DisplayName = null,
+ ConnectorDisplayName = null,
+ Status = "Unknown",
+ CreatedBy = null
+ }
+ };
+
+ writer.WriteSuccess(output);
+ }
+ else
+ {
+ Console.WriteLine($"Connection Reference: {cr.DisplayName ?? cr.LogicalName}");
+ Console.WriteLine($" Logical Name: {cr.LogicalName}");
+ Console.WriteLine($" Connector ID: {cr.ConnectorId}");
+ Console.WriteLine();
+ Console.WriteLine("Bound Connection:");
+ Console.WriteLine($" Connection ID: {cr.ConnectionId}");
+
+ if (connection != null)
+ {
+ if (connection.DisplayName != null)
+ {
+ Console.WriteLine($" Display Name: {connection.DisplayName}");
+ }
+ if (connection.ConnectorDisplayName != null)
+ {
+ Console.WriteLine($" Connector: {connection.ConnectorDisplayName}");
+ }
+ Console.WriteLine($" Status: {connection.Status}");
+ if (connection.CreatedBy != null)
+ {
+ Console.WriteLine($" Created by: {connection.CreatedBy}");
+ }
+ }
+ }
+
+ return ExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ var error = ExceptionMapper.Map(ex, context: "getting connection binding", debug: globalOptions.Debug);
+ writer.WriteError(error);
+ return ExceptionMapper.ToExitCode(ex);
+ }
+ }
+
+ #region Output Models
+
+ private sealed class ConnectionBindingOutput
+ {
+ [JsonPropertyName("connectionReference")]
+ public ConnectionRefInfo ConnectionReference { get; set; } = new();
+
+ [JsonPropertyName("isBound")]
+ public bool IsBound { get; set; }
+
+ [JsonPropertyName("connection")]
+ public ConnectionDetails? Connection { get; set; }
+ }
+
+ private sealed class ConnectionRefInfo
+ {
+ [JsonPropertyName("logicalName")]
+ public string LogicalName { get; set; } = string.Empty;
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("connectorId")]
+ public string? ConnectorId { get; set; }
+ }
+
+ private sealed class ConnectionDetails
+ {
+ [JsonPropertyName("connectionId")]
+ public string ConnectionId { get; set; } = string.Empty;
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("connectorDisplayName")]
+ public string? ConnectorDisplayName { get; set; }
+
+ [JsonPropertyName("status")]
+ public string Status { get; set; } = string.Empty;
+
+ [JsonPropertyName("createdBy")]
+ public string? CreatedBy { get; set; }
+ }
+
+ #endregion
+}
diff --git a/src/PPDS.Cli/Commands/ConnectionReferences/FlowsCommand.cs b/src/PPDS.Cli/Commands/ConnectionReferences/FlowsCommand.cs
new file mode 100644
index 00000000..ac1be276
--- /dev/null
+++ b/src/PPDS.Cli/Commands/ConnectionReferences/FlowsCommand.cs
@@ -0,0 +1,181 @@
+using System.CommandLine;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.DependencyInjection;
+using PPDS.Cli.Infrastructure;
+using PPDS.Cli.Infrastructure.Errors;
+using PPDS.Cli.Infrastructure.Output;
+using PPDS.Dataverse.Services;
+
+namespace PPDS.Cli.Commands.ConnectionReferences;
+
+///
+/// List flows using a specific connection reference.
+///
+public static class FlowsCommand
+{
+ public static Command Create()
+ {
+ var nameArgument = new Argument("name")
+ {
+ Description = "The logical name of the connection reference"
+ };
+
+ var command = new Command("flows", "List flows that use a connection reference")
+ {
+ nameArgument,
+ ConnectionReferencesCommandGroup.ProfileOption,
+ ConnectionReferencesCommandGroup.EnvironmentOption
+ };
+
+ GlobalOptions.AddToCommand(command);
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var name = parseResult.GetValue(nameArgument)!;
+ var profile = parseResult.GetValue(ConnectionReferencesCommandGroup.ProfileOption);
+ var environment = parseResult.GetValue(ConnectionReferencesCommandGroup.EnvironmentOption);
+ var globalOptions = GlobalOptions.GetValues(parseResult);
+
+ return await ExecuteAsync(name, profile, environment, globalOptions, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static async Task ExecuteAsync(
+ string name,
+ string? profile,
+ string? environment,
+ GlobalOptionValues globalOptions,
+ CancellationToken cancellationToken)
+ {
+ var writer = ServiceFactory.CreateOutputWriter(globalOptions);
+
+ try
+ {
+ await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync(
+ profile,
+ environment,
+ globalOptions.Verbose,
+ globalOptions.Debug,
+ ProfileServiceFactory.DefaultDeviceCodeCallback,
+ cancellationToken);
+
+ var crService = serviceProvider.GetRequiredService();
+
+ if (!globalOptions.IsJsonMode)
+ {
+ var connectionInfo = serviceProvider.GetRequiredService();
+ ConsoleHeader.WriteConnectedAs(connectionInfo);
+ Console.Error.WriteLine();
+ }
+
+ // First verify the CR exists
+ var cr = await crService.GetAsync(name, cancellationToken);
+ if (cr == null)
+ {
+ writer.WriteError(new StructuredError(
+ ErrorCodes.Operation.NotFound,
+ $"Connection reference '{name}' not found"));
+ return ExitCodes.NotFoundError;
+ }
+
+ var flows = await crService.GetFlowsUsingAsync(name, cancellationToken);
+
+ if (globalOptions.IsJsonMode)
+ {
+ var output = new FlowsUsingCrOutput
+ {
+ ConnectionReference = new ConnectionRefSummary
+ {
+ LogicalName = cr.LogicalName,
+ DisplayName = cr.DisplayName,
+ IsBound = cr.IsBound
+ },
+ Flows = flows.Select(f => new FlowSummary
+ {
+ UniqueName = f.UniqueName,
+ DisplayName = f.DisplayName,
+ State = f.State.ToString()
+ }).ToList()
+ };
+
+ writer.WriteSuccess(output);
+ }
+ else
+ {
+ Console.Error.WriteLine($"Connection Reference: {cr.DisplayName ?? cr.LogicalName}");
+ Console.Error.WriteLine();
+
+ if (flows.Count == 0)
+ {
+ Console.Error.WriteLine("No flows use this connection reference.");
+ }
+ else
+ {
+ Console.Error.WriteLine($"Flows using this connection reference ({flows.Count}):");
+ Console.Error.WriteLine();
+
+ foreach (var f in flows)
+ {
+ var stateDisplay = f.State switch
+ {
+ FlowState.Activated => "On",
+ FlowState.Suspended => "Suspended",
+ _ => "Off"
+ };
+
+ Console.WriteLine($" {f.DisplayName ?? f.UniqueName}");
+ Console.WriteLine($" Name: {f.UniqueName} State: {stateDisplay}");
+ Console.WriteLine();
+ }
+ }
+ }
+
+ return ExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ var error = ExceptionMapper.Map(ex, context: "getting flows for connection reference", debug: globalOptions.Debug);
+ writer.WriteError(error);
+ return ExceptionMapper.ToExitCode(ex);
+ }
+ }
+
+ #region Output Models
+
+ private sealed class FlowsUsingCrOutput
+ {
+ [JsonPropertyName("connectionReference")]
+ public ConnectionRefSummary ConnectionReference { get; set; } = new();
+
+ [JsonPropertyName("flows")]
+ public List Flows { get; set; } = new();
+ }
+
+ private sealed class ConnectionRefSummary
+ {
+ [JsonPropertyName("logicalName")]
+ public string LogicalName { get; set; } = string.Empty;
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("isBound")]
+ public bool IsBound { get; set; }
+ }
+
+ private sealed class FlowSummary
+ {
+ [JsonPropertyName("uniqueName")]
+ public string UniqueName { get; set; } = string.Empty;
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("state")]
+ public string State { get; set; } = string.Empty;
+ }
+
+ #endregion
+}
diff --git a/src/PPDS.Cli/Commands/ConnectionReferences/GetCommand.cs b/src/PPDS.Cli/Commands/ConnectionReferences/GetCommand.cs
new file mode 100644
index 00000000..bcef09ee
--- /dev/null
+++ b/src/PPDS.Cli/Commands/ConnectionReferences/GetCommand.cs
@@ -0,0 +1,176 @@
+using System.CommandLine;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.DependencyInjection;
+using PPDS.Cli.Infrastructure;
+using PPDS.Cli.Infrastructure.Errors;
+using PPDS.Cli.Infrastructure.Output;
+using PPDS.Dataverse.Services;
+
+namespace PPDS.Cli.Commands.ConnectionReferences;
+
+///
+/// Get a specific connection reference by logical name.
+///
+public static class GetCommand
+{
+ public static Command Create()
+ {
+ var nameArgument = new Argument("name")
+ {
+ Description = "The logical name of the connection reference"
+ };
+
+ var command = new Command("get", "Get a connection reference by logical name")
+ {
+ nameArgument,
+ ConnectionReferencesCommandGroup.ProfileOption,
+ ConnectionReferencesCommandGroup.EnvironmentOption
+ };
+
+ GlobalOptions.AddToCommand(command);
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var name = parseResult.GetValue(nameArgument)!;
+ var profile = parseResult.GetValue(ConnectionReferencesCommandGroup.ProfileOption);
+ var environment = parseResult.GetValue(ConnectionReferencesCommandGroup.EnvironmentOption);
+ var globalOptions = GlobalOptions.GetValues(parseResult);
+
+ return await ExecuteAsync(name, profile, environment, globalOptions, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static async Task ExecuteAsync(
+ string name,
+ string? profile,
+ string? environment,
+ GlobalOptionValues globalOptions,
+ CancellationToken cancellationToken)
+ {
+ var writer = ServiceFactory.CreateOutputWriter(globalOptions);
+
+ try
+ {
+ await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync(
+ profile,
+ environment,
+ globalOptions.Verbose,
+ globalOptions.Debug,
+ ProfileServiceFactory.DefaultDeviceCodeCallback,
+ cancellationToken);
+
+ var crService = serviceProvider.GetRequiredService();
+
+ if (!globalOptions.IsJsonMode)
+ {
+ var connectionInfo = serviceProvider.GetRequiredService();
+ ConsoleHeader.WriteConnectedAs(connectionInfo);
+ Console.Error.WriteLine();
+ }
+
+ var cr = await crService.GetAsync(name, cancellationToken);
+
+ if (cr == null)
+ {
+ writer.WriteError(new StructuredError(
+ ErrorCodes.Operation.NotFound,
+ $"Connection reference '{name}' not found"));
+ return ExitCodes.NotFoundError;
+ }
+
+ if (globalOptions.IsJsonMode)
+ {
+ var output = new ConnectionReferenceDetail
+ {
+ Id = cr.Id,
+ LogicalName = cr.LogicalName,
+ DisplayName = cr.DisplayName,
+ Description = cr.Description,
+ ConnectorId = cr.ConnectorId,
+ ConnectionId = cr.ConnectionId,
+ IsBound = cr.IsBound,
+ IsManaged = cr.IsManaged,
+ CreatedOn = cr.CreatedOn,
+ ModifiedOn = cr.ModifiedOn
+ };
+
+ writer.WriteSuccess(output);
+ }
+ else
+ {
+ Console.WriteLine($"Connection Reference: {cr.DisplayName ?? cr.LogicalName}");
+ Console.WriteLine($" Logical Name: {cr.LogicalName}");
+ Console.WriteLine($" ID: {cr.Id}");
+ Console.WriteLine($" Status: {(cr.IsBound ? "Bound" : "Unbound")}");
+ Console.WriteLine($" Managed: {(cr.IsManaged ? "Yes" : "No")}");
+
+ if (!string.IsNullOrEmpty(cr.Description))
+ {
+ Console.WriteLine($" Description: {cr.Description}");
+ }
+
+ if (cr.ConnectorId != null)
+ {
+ Console.WriteLine();
+ Console.WriteLine($" Connector ID: {cr.ConnectorId}");
+ }
+
+ if (cr.ConnectionId != null)
+ {
+ Console.WriteLine($" Connection ID: {cr.ConnectionId}");
+ }
+
+ Console.WriteLine();
+ Console.WriteLine($" Created: {cr.CreatedOn}");
+ Console.WriteLine($" Modified: {cr.ModifiedOn}");
+ }
+
+ return ExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ var error = ExceptionMapper.Map(ex, context: "getting connection reference", debug: globalOptions.Debug);
+ writer.WriteError(error);
+ return ExceptionMapper.ToExitCode(ex);
+ }
+ }
+
+ #region Output Models
+
+ private sealed class ConnectionReferenceDetail
+ {
+ [JsonPropertyName("id")]
+ public Guid Id { get; set; }
+
+ [JsonPropertyName("logicalName")]
+ public string LogicalName { get; set; } = string.Empty;
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+
+ [JsonPropertyName("connectorId")]
+ public string? ConnectorId { get; set; }
+
+ [JsonPropertyName("connectionId")]
+ public string? ConnectionId { get; set; }
+
+ [JsonPropertyName("isBound")]
+ public bool IsBound { get; set; }
+
+ [JsonPropertyName("isManaged")]
+ public bool IsManaged { get; set; }
+
+ [JsonPropertyName("createdOn")]
+ public DateTime? CreatedOn { get; set; }
+
+ [JsonPropertyName("modifiedOn")]
+ public DateTime? ModifiedOn { get; set; }
+ }
+
+ #endregion
+}
diff --git a/src/PPDS.Cli/Commands/ConnectionReferences/ListCommand.cs b/src/PPDS.Cli/Commands/ConnectionReferences/ListCommand.cs
new file mode 100644
index 00000000..0d63eebb
--- /dev/null
+++ b/src/PPDS.Cli/Commands/ConnectionReferences/ListCommand.cs
@@ -0,0 +1,164 @@
+using System.CommandLine;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.DependencyInjection;
+using PPDS.Cli.Infrastructure;
+using PPDS.Cli.Infrastructure.Errors;
+using PPDS.Cli.Infrastructure.Output;
+using PPDS.Dataverse.Services;
+
+namespace PPDS.Cli.Commands.ConnectionReferences;
+
+///
+/// List connection references.
+///
+public static class ListCommand
+{
+ public static Command Create()
+ {
+ var unboundOption = new Option("--unbound")
+ {
+ Description = "Only show connection references without a bound connection"
+ };
+
+ var command = new Command("list", "List connection references")
+ {
+ ConnectionReferencesCommandGroup.SolutionOption,
+ unboundOption,
+ ConnectionReferencesCommandGroup.ProfileOption,
+ ConnectionReferencesCommandGroup.EnvironmentOption
+ };
+
+ GlobalOptions.AddToCommand(command);
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var solution = parseResult.GetValue(ConnectionReferencesCommandGroup.SolutionOption);
+ var unboundOnly = parseResult.GetValue(unboundOption);
+ var profile = parseResult.GetValue(ConnectionReferencesCommandGroup.ProfileOption);
+ var environment = parseResult.GetValue(ConnectionReferencesCommandGroup.EnvironmentOption);
+ var globalOptions = GlobalOptions.GetValues(parseResult);
+
+ return await ExecuteAsync(solution, unboundOnly, profile, environment, globalOptions, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static async Task ExecuteAsync(
+ string? solution,
+ bool unboundOnly,
+ string? profile,
+ string? environment,
+ GlobalOptionValues globalOptions,
+ CancellationToken cancellationToken)
+ {
+ var writer = ServiceFactory.CreateOutputWriter(globalOptions);
+
+ try
+ {
+ await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync(
+ profile,
+ environment,
+ globalOptions.Verbose,
+ globalOptions.Debug,
+ ProfileServiceFactory.DefaultDeviceCodeCallback,
+ cancellationToken);
+
+ var crService = serviceProvider.GetRequiredService();
+
+ if (!globalOptions.IsJsonMode)
+ {
+ var connectionInfo = serviceProvider.GetRequiredService();
+ ConsoleHeader.WriteConnectedAs(connectionInfo);
+ Console.Error.WriteLine();
+ }
+
+ var connectionRefs = await crService.ListAsync(solution, unboundOnly, cancellationToken);
+
+ if (globalOptions.IsJsonMode)
+ {
+ var output = connectionRefs.Select(cr => new ConnectionReferenceListItem
+ {
+ Id = cr.Id,
+ LogicalName = cr.LogicalName,
+ DisplayName = cr.DisplayName,
+ ConnectorId = cr.ConnectorId,
+ ConnectionId = cr.ConnectionId,
+ IsBound = cr.IsBound,
+ IsManaged = cr.IsManaged,
+ ModifiedOn = cr.ModifiedOn
+ }).ToList();
+
+ writer.WriteSuccess(output);
+ }
+ else
+ {
+ if (connectionRefs.Count == 0)
+ {
+ Console.Error.WriteLine("No connection references found.");
+ }
+ else
+ {
+ Console.Error.WriteLine($"Found {connectionRefs.Count} connection reference(s):");
+ Console.Error.WriteLine();
+
+ foreach (var cr in connectionRefs)
+ {
+ var boundStatus = cr.IsBound ? "Bound" : "Unbound";
+
+ Console.WriteLine($" {cr.DisplayName ?? cr.LogicalName}");
+ Console.WriteLine($" Name: {cr.LogicalName} Status: {boundStatus}");
+ if (cr.ConnectorId != null)
+ {
+ Console.WriteLine($" Connector: {cr.ConnectorId}");
+ }
+ if (cr.IsManaged)
+ {
+ Console.WriteLine($" Managed: Yes");
+ }
+ Console.WriteLine();
+ }
+ }
+ }
+
+ return ExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ var error = ExceptionMapper.Map(ex, context: "listing connection references", debug: globalOptions.Debug);
+ writer.WriteError(error);
+ return ExceptionMapper.ToExitCode(ex);
+ }
+ }
+
+ #region Output Models
+
+ private sealed class ConnectionReferenceListItem
+ {
+ [JsonPropertyName("id")]
+ public Guid Id { get; set; }
+
+ [JsonPropertyName("logicalName")]
+ public string LogicalName { get; set; } = string.Empty;
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("connectorId")]
+ public string? ConnectorId { get; set; }
+
+ [JsonPropertyName("connectionId")]
+ public string? ConnectionId { get; set; }
+
+ [JsonPropertyName("isBound")]
+ public bool IsBound { get; set; }
+
+ [JsonPropertyName("isManaged")]
+ public bool IsManaged { get; set; }
+
+ [JsonPropertyName("modifiedOn")]
+ public DateTime? ModifiedOn { get; set; }
+ }
+
+ #endregion
+}
diff --git a/src/PPDS.Cli/Commands/Connections/ConnectionsCommandGroup.cs b/src/PPDS.Cli/Commands/Connections/ConnectionsCommandGroup.cs
new file mode 100644
index 00000000..d6e43925
--- /dev/null
+++ b/src/PPDS.Cli/Commands/Connections/ConnectionsCommandGroup.cs
@@ -0,0 +1,38 @@
+using System.CommandLine;
+
+namespace PPDS.Cli.Commands.Connections;
+
+///
+/// Command group for Power Platform connection operations.
+///
+///
+/// Connections are managed through the Power Apps Admin API, not Dataverse.
+/// This command group queries the Power Apps API to list and get connection details.
+///
+public static class ConnectionsCommandGroup
+{
+ /// Shared profile option.
+ public static readonly Option ProfileOption = new("--profile", "-p")
+ {
+ Description = "Authentication profile name"
+ };
+
+ /// Shared environment option.
+ public static readonly Option EnvironmentOption = new("--environment", "-e")
+ {
+ Description = "Environment URL override"
+ };
+
+ ///
+ /// Creates the connections command group.
+ ///
+ public static Command Create()
+ {
+ var command = new Command("connections", "Manage Power Platform connections (Power Apps Admin API)");
+
+ command.Subcommands.Add(ListCommand.Create());
+ command.Subcommands.Add(GetCommand.Create());
+
+ return command;
+ }
+}
diff --git a/src/PPDS.Cli/Commands/Connections/GetCommand.cs b/src/PPDS.Cli/Commands/Connections/GetCommand.cs
new file mode 100644
index 00000000..a22ad0bb
--- /dev/null
+++ b/src/PPDS.Cli/Commands/Connections/GetCommand.cs
@@ -0,0 +1,178 @@
+using System.CommandLine;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.DependencyInjection;
+using PPDS.Cli.Infrastructure;
+using PPDS.Cli.Infrastructure.Errors;
+using PPDS.Cli.Infrastructure.Output;
+using PPDS.Cli.Services;
+
+namespace PPDS.Cli.Commands.Connections;
+
+///
+/// Get a specific Power Platform connection by ID.
+///
+public static class GetCommand
+{
+ public static Command Create()
+ {
+ var idArgument = new Argument("id")
+ {
+ Description = "The connection ID"
+ };
+
+ var command = new Command("get", "Get a connection by ID")
+ {
+ idArgument,
+ ConnectionsCommandGroup.ProfileOption,
+ ConnectionsCommandGroup.EnvironmentOption
+ };
+
+ GlobalOptions.AddToCommand(command);
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var id = parseResult.GetValue(idArgument)!;
+ var profile = parseResult.GetValue(ConnectionsCommandGroup.ProfileOption);
+ var environment = parseResult.GetValue(ConnectionsCommandGroup.EnvironmentOption);
+ var globalOptions = GlobalOptions.GetValues(parseResult);
+
+ return await ExecuteAsync(id, profile, environment, globalOptions, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static async Task ExecuteAsync(
+ string id,
+ string? profile,
+ string? environment,
+ GlobalOptionValues globalOptions,
+ CancellationToken cancellationToken)
+ {
+ var writer = ServiceFactory.CreateOutputWriter(globalOptions);
+
+ try
+ {
+ await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync(
+ profile,
+ environment,
+ globalOptions.Verbose,
+ globalOptions.Debug,
+ ProfileServiceFactory.DefaultDeviceCodeCallback,
+ cancellationToken);
+
+ var connectionService = serviceProvider.GetRequiredService();
+
+ if (!globalOptions.IsJsonMode)
+ {
+ var connectionInfo = serviceProvider.GetRequiredService();
+ ConsoleHeader.WriteConnectedAs(connectionInfo);
+ Console.Error.WriteLine();
+ }
+
+ var connection = await connectionService.GetAsync(id, cancellationToken);
+
+ if (connection == null)
+ {
+ writer.WriteError(new StructuredError(
+ ErrorCodes.Operation.NotFound,
+ $"Connection '{id}' not found"));
+ return ExitCodes.NotFoundError;
+ }
+
+ if (globalOptions.IsJsonMode)
+ {
+ var output = new ConnectionDetail
+ {
+ ConnectionId = connection.ConnectionId,
+ DisplayName = connection.DisplayName,
+ ConnectorId = connection.ConnectorId,
+ ConnectorDisplayName = connection.ConnectorDisplayName,
+ EnvironmentId = connection.EnvironmentId,
+ Status = connection.Status.ToString(),
+ IsShared = connection.IsShared,
+ CreatedBy = connection.CreatedBy,
+ CreatedOn = connection.CreatedOn,
+ ModifiedOn = connection.ModifiedOn
+ };
+
+ writer.WriteSuccess(output);
+ }
+ else
+ {
+ Console.WriteLine($"Connection: {connection.DisplayName ?? connection.ConnectionId}");
+ Console.WriteLine($" ID: {connection.ConnectionId}");
+ Console.WriteLine($" Connector: {connection.ConnectorDisplayName ?? connection.ConnectorId}");
+ Console.WriteLine($" Connector ID: {connection.ConnectorId}");
+ Console.WriteLine($" Status: {connection.Status}");
+ Console.WriteLine($" Shared: {(connection.IsShared ? "Yes" : "No")}");
+
+ if (connection.EnvironmentId != null)
+ {
+ Console.WriteLine($" Environment: {connection.EnvironmentId}");
+ }
+
+ if (connection.CreatedBy != null)
+ {
+ Console.WriteLine($" Created by: {connection.CreatedBy}");
+ }
+
+ Console.WriteLine();
+ Console.WriteLine($" Created: {connection.CreatedOn}");
+ Console.WriteLine($" Modified: {connection.ModifiedOn}");
+ }
+
+ return ExitCodes.Success;
+ }
+ catch (InvalidOperationException ex) when (ex.Message.Contains("Service principals"))
+ {
+ writer.WriteError(new StructuredError(
+ ErrorCodes.Auth.InsufficientPermissions,
+ ex.Message));
+ return ExitCodes.AuthError;
+ }
+ catch (Exception ex)
+ {
+ var error = ExceptionMapper.Map(ex, context: "getting connection", debug: globalOptions.Debug);
+ writer.WriteError(error);
+ return ExceptionMapper.ToExitCode(ex);
+ }
+ }
+
+ #region Output Models
+
+ private sealed class ConnectionDetail
+ {
+ [JsonPropertyName("connectionId")]
+ public string ConnectionId { get; set; } = string.Empty;
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("connectorId")]
+ public string ConnectorId { get; set; } = string.Empty;
+
+ [JsonPropertyName("connectorDisplayName")]
+ public string? ConnectorDisplayName { get; set; }
+
+ [JsonPropertyName("environmentId")]
+ public string? EnvironmentId { get; set; }
+
+ [JsonPropertyName("status")]
+ public string Status { get; set; } = string.Empty;
+
+ [JsonPropertyName("isShared")]
+ public bool IsShared { get; set; }
+
+ [JsonPropertyName("createdBy")]
+ public string? CreatedBy { get; set; }
+
+ [JsonPropertyName("createdOn")]
+ public DateTime? CreatedOn { get; set; }
+
+ [JsonPropertyName("modifiedOn")]
+ public DateTime? ModifiedOn { get; set; }
+ }
+
+ #endregion
+}
diff --git a/src/PPDS.Cli/Commands/Connections/ListCommand.cs b/src/PPDS.Cli/Commands/Connections/ListCommand.cs
new file mode 100644
index 00000000..fce1c166
--- /dev/null
+++ b/src/PPDS.Cli/Commands/Connections/ListCommand.cs
@@ -0,0 +1,172 @@
+using System.CommandLine;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.DependencyInjection;
+using PPDS.Cli.Infrastructure;
+using PPDS.Cli.Infrastructure.Errors;
+using PPDS.Cli.Infrastructure.Output;
+using PPDS.Cli.Services;
+
+namespace PPDS.Cli.Commands.Connections;
+
+///
+/// List Power Platform connections from the Power Apps Admin API.
+///
+public static class ListCommand
+{
+ public static Command Create()
+ {
+ var connectorOption = new Option("--connector")
+ {
+ Description = "Filter by connector ID (e.g., shared_commondataserviceforapps)"
+ };
+
+ var command = new Command("list", "List connections from Power Apps Admin API")
+ {
+ connectorOption,
+ ConnectionsCommandGroup.ProfileOption,
+ ConnectionsCommandGroup.EnvironmentOption
+ };
+
+ GlobalOptions.AddToCommand(command);
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var connector = parseResult.GetValue(connectorOption);
+ var profile = parseResult.GetValue(ConnectionsCommandGroup.ProfileOption);
+ var environment = parseResult.GetValue(ConnectionsCommandGroup.EnvironmentOption);
+ var globalOptions = GlobalOptions.GetValues(parseResult);
+
+ return await ExecuteAsync(connector, profile, environment, globalOptions, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static async Task ExecuteAsync(
+ string? connector,
+ string? profile,
+ string? environment,
+ GlobalOptionValues globalOptions,
+ CancellationToken cancellationToken)
+ {
+ var writer = ServiceFactory.CreateOutputWriter(globalOptions);
+
+ try
+ {
+ await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync(
+ profile,
+ environment,
+ globalOptions.Verbose,
+ globalOptions.Debug,
+ ProfileServiceFactory.DefaultDeviceCodeCallback,
+ cancellationToken);
+
+ var connectionService = serviceProvider.GetRequiredService();
+
+ if (!globalOptions.IsJsonMode)
+ {
+ var connectionInfo = serviceProvider.GetRequiredService();
+ ConsoleHeader.WriteConnectedAs(connectionInfo);
+ Console.Error.WriteLine();
+ }
+
+ var connections = await connectionService.ListAsync(connector, cancellationToken);
+
+ if (globalOptions.IsJsonMode)
+ {
+ var output = connections.Select(c => new ConnectionListItem
+ {
+ ConnectionId = c.ConnectionId,
+ DisplayName = c.DisplayName,
+ ConnectorId = c.ConnectorId,
+ ConnectorDisplayName = c.ConnectorDisplayName,
+ Status = c.Status.ToString(),
+ IsShared = c.IsShared,
+ CreatedBy = c.CreatedBy,
+ ModifiedOn = c.ModifiedOn
+ }).ToList();
+
+ writer.WriteSuccess(output);
+ }
+ else
+ {
+ if (connections.Count == 0)
+ {
+ Console.Error.WriteLine("No connections found.");
+ }
+ else
+ {
+ Console.Error.WriteLine($"Found {connections.Count} connection(s):");
+ Console.Error.WriteLine();
+
+ foreach (var c in connections)
+ {
+ var statusDisplay = c.Status switch
+ {
+ ConnectionStatus.Connected => "Connected",
+ ConnectionStatus.Error => "Error",
+ _ => "Unknown"
+ };
+
+ Console.WriteLine($" {c.DisplayName ?? c.ConnectionId}");
+ Console.WriteLine($" ID: {c.ConnectionId}");
+ Console.WriteLine($" Connector: {c.ConnectorDisplayName ?? c.ConnectorId}");
+ Console.WriteLine($" Status: {statusDisplay} Shared: {(c.IsShared ? "Yes" : "No")}");
+ if (c.CreatedBy != null)
+ {
+ Console.WriteLine($" Created by: {c.CreatedBy}");
+ }
+ Console.WriteLine();
+ }
+ }
+ }
+
+ return ExitCodes.Success;
+ }
+ catch (InvalidOperationException ex) when (ex.Message.Contains("Service principals"))
+ {
+ // Special handling for SPN limitation
+ writer.WriteError(new StructuredError(
+ ErrorCodes.Auth.InsufficientPermissions,
+ ex.Message));
+ return ExitCodes.AuthError;
+ }
+ catch (Exception ex)
+ {
+ var error = ExceptionMapper.Map(ex, context: "listing connections", debug: globalOptions.Debug);
+ writer.WriteError(error);
+ return ExceptionMapper.ToExitCode(ex);
+ }
+ }
+
+ #region Output Models
+
+ private sealed class ConnectionListItem
+ {
+ [JsonPropertyName("connectionId")]
+ public string ConnectionId { get; set; } = string.Empty;
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("connectorId")]
+ public string ConnectorId { get; set; } = string.Empty;
+
+ [JsonPropertyName("connectorDisplayName")]
+ public string? ConnectorDisplayName { get; set; }
+
+ [JsonPropertyName("status")]
+ public string Status { get; set; } = string.Empty;
+
+ [JsonPropertyName("isShared")]
+ public bool IsShared { get; set; }
+
+ [JsonPropertyName("createdBy")]
+ public string? CreatedBy { get; set; }
+
+ [JsonPropertyName("modifiedOn")]
+ public DateTime? ModifiedOn { get; set; }
+ }
+
+ #endregion
+}
diff --git a/src/PPDS.Cli/Commands/DeploymentSettings/DeploymentSettingsCommandGroup.cs b/src/PPDS.Cli/Commands/DeploymentSettings/DeploymentSettingsCommandGroup.cs
new file mode 100644
index 00000000..eff6e00f
--- /dev/null
+++ b/src/PPDS.Cli/Commands/DeploymentSettings/DeploymentSettingsCommandGroup.cs
@@ -0,0 +1,42 @@
+using System.CommandLine;
+
+namespace PPDS.Cli.Commands.DeploymentSettings;
+
+///
+/// Command group for deployment settings file operations.
+///
+public static class DeploymentSettingsCommandGroup
+{
+ /// Shared profile option.
+ public static readonly Option ProfileOption = new("--profile", "-p")
+ {
+ Description = "Authentication profile name"
+ };
+
+ /// Shared environment option.
+ public static readonly Option EnvironmentOption = new("--environment", "-e")
+ {
+ Description = "Environment URL override"
+ };
+
+ /// Shared solution option.
+ public static readonly Option SolutionOption = new("--solution", "-s")
+ {
+ Description = "Solution unique name",
+ Required = true
+ };
+
+ ///
+ /// Creates the deployment-settings command group.
+ ///
+ public static Command Create()
+ {
+ var command = new Command("deployment-settings", "Generate, sync, and validate deployment settings files");
+
+ command.Subcommands.Add(GenerateCommand.Create());
+ command.Subcommands.Add(SyncCommand.Create());
+ command.Subcommands.Add(ValidateCommand.Create());
+
+ return command;
+ }
+}
diff --git a/src/PPDS.Cli/Commands/DeploymentSettings/GenerateCommand.cs b/src/PPDS.Cli/Commands/DeploymentSettings/GenerateCommand.cs
new file mode 100644
index 00000000..461668cf
--- /dev/null
+++ b/src/PPDS.Cli/Commands/DeploymentSettings/GenerateCommand.cs
@@ -0,0 +1,121 @@
+using System.CommandLine;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using PPDS.Cli.Infrastructure;
+using PPDS.Cli.Infrastructure.Errors;
+using PPDS.Cli.Infrastructure.Output;
+using PPDS.Dataverse.Services;
+
+namespace PPDS.Cli.Commands.DeploymentSettings;
+
+///
+/// Generate a new deployment settings file from the current environment.
+///
+public static class GenerateCommand
+{
+ private static readonly JsonSerializerOptions JsonWriteOptions = new()
+ {
+ WriteIndented = true
+ };
+
+ public static Command Create()
+ {
+ var outputOption = new Option("--output", "-o")
+ {
+ Description = "Output file path",
+ Required = true
+ };
+
+ var command = new Command("generate", "Generate a new deployment settings file from current environment")
+ {
+ DeploymentSettingsCommandGroup.SolutionOption,
+ outputOption,
+ DeploymentSettingsCommandGroup.ProfileOption,
+ DeploymentSettingsCommandGroup.EnvironmentOption
+ };
+
+ GlobalOptions.AddToCommand(command);
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var solution = parseResult.GetValue(DeploymentSettingsCommandGroup.SolutionOption)!;
+ var output = parseResult.GetValue(outputOption)!;
+ var profile = parseResult.GetValue(DeploymentSettingsCommandGroup.ProfileOption);
+ var environment = parseResult.GetValue(DeploymentSettingsCommandGroup.EnvironmentOption);
+ var globalOptions = GlobalOptions.GetValues(parseResult);
+
+ return await ExecuteAsync(solution, output, profile, environment, globalOptions, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static async Task ExecuteAsync(
+ string solution,
+ string outputPath,
+ string? profile,
+ string? environment,
+ GlobalOptionValues globalOptions,
+ CancellationToken cancellationToken)
+ {
+ var writer = ServiceFactory.CreateOutputWriter(globalOptions);
+
+ try
+ {
+ await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync(
+ profile,
+ environment,
+ globalOptions.Verbose,
+ globalOptions.Debug,
+ ProfileServiceFactory.DefaultDeviceCodeCallback,
+ cancellationToken);
+
+ var settingsService = serviceProvider.GetRequiredService();
+
+ if (!globalOptions.IsJsonMode)
+ {
+ var connectionInfo = serviceProvider.GetRequiredService();
+ ConsoleHeader.WriteConnectedAs(connectionInfo);
+ Console.Error.WriteLine();
+ Console.Error.WriteLine($"Generating deployment settings for solution '{solution}'...");
+ }
+
+ var settings = await settingsService.GenerateAsync(solution, cancellationToken);
+
+ // Write to file
+ var json = JsonSerializer.Serialize(settings, JsonWriteOptions);
+ var fullPath = Path.GetFullPath(outputPath);
+ var directory = Path.GetDirectoryName(fullPath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+ await File.WriteAllTextAsync(fullPath, json, cancellationToken);
+
+ if (globalOptions.IsJsonMode)
+ {
+ writer.WriteSuccess(new
+ {
+ outputPath = fullPath,
+ environmentVariableCount = settings.EnvironmentVariables.Count,
+ connectionReferenceCount = settings.ConnectionReferences.Count
+ });
+ }
+ else
+ {
+ Console.Error.WriteLine();
+ Console.Error.WriteLine($"Generated deployment settings file: {fullPath}");
+ Console.Error.WriteLine($" Environment Variables: {settings.EnvironmentVariables.Count}");
+ Console.Error.WriteLine($" Connection References: {settings.ConnectionReferences.Count}");
+ }
+
+ return ExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ var error = ExceptionMapper.Map(ex, context: "generating deployment settings", debug: globalOptions.Debug);
+ writer.WriteError(error);
+ return ExceptionMapper.ToExitCode(ex);
+ }
+ }
+}
diff --git a/src/PPDS.Cli/Commands/DeploymentSettings/SyncCommand.cs b/src/PPDS.Cli/Commands/DeploymentSettings/SyncCommand.cs
new file mode 100644
index 00000000..f4d0bac0
--- /dev/null
+++ b/src/PPDS.Cli/Commands/DeploymentSettings/SyncCommand.cs
@@ -0,0 +1,215 @@
+using System.CommandLine;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.DependencyInjection;
+using PPDS.Cli.Infrastructure;
+using PPDS.Cli.Infrastructure.Errors;
+using PPDS.Cli.Infrastructure.Output;
+using PPDS.Dataverse.Services;
+
+namespace PPDS.Cli.Commands.DeploymentSettings;
+
+///
+/// Sync an existing deployment settings file with the current solution.
+///
+public static class SyncCommand
+{
+ private static readonly JsonSerializerOptions JsonReadOptions = new()
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ private static readonly JsonSerializerOptions JsonWriteOptions = new()
+ {
+ WriteIndented = true
+ };
+
+ public static Command Create()
+ {
+ var fileOption = new Option("--file", "-f")
+ {
+ Description = "Deployment settings file path",
+ Required = true
+ };
+
+ var dryRunOption = new Option("--dry-run")
+ {
+ Description = "Show what would change without modifying the file"
+ };
+
+ var command = new Command("sync", "Sync deployment settings file with solution (preserves existing values)")
+ {
+ DeploymentSettingsCommandGroup.SolutionOption,
+ fileOption,
+ dryRunOption,
+ DeploymentSettingsCommandGroup.ProfileOption,
+ DeploymentSettingsCommandGroup.EnvironmentOption
+ };
+
+ GlobalOptions.AddToCommand(command);
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var solution = parseResult.GetValue(DeploymentSettingsCommandGroup.SolutionOption)!;
+ var file = parseResult.GetValue(fileOption)!;
+ var dryRun = parseResult.GetValue(dryRunOption);
+ var profile = parseResult.GetValue(DeploymentSettingsCommandGroup.ProfileOption);
+ var environment = parseResult.GetValue(DeploymentSettingsCommandGroup.EnvironmentOption);
+ var globalOptions = GlobalOptions.GetValues(parseResult);
+
+ return await ExecuteAsync(solution, file, dryRun, profile, environment, globalOptions, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static async Task ExecuteAsync(
+ string solution,
+ string filePath,
+ bool dryRun,
+ string? profile,
+ string? environment,
+ GlobalOptionValues globalOptions,
+ CancellationToken cancellationToken)
+ {
+ var writer = ServiceFactory.CreateOutputWriter(globalOptions);
+
+ try
+ {
+ await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync(
+ profile,
+ environment,
+ globalOptions.Verbose,
+ globalOptions.Debug,
+ ProfileServiceFactory.DefaultDeviceCodeCallback,
+ cancellationToken);
+
+ var settingsService = serviceProvider.GetRequiredService();
+
+ if (!globalOptions.IsJsonMode)
+ {
+ var connectionInfo = serviceProvider.GetRequiredService();
+ ConsoleHeader.WriteConnectedAs(connectionInfo);
+ Console.Error.WriteLine();
+
+ if (dryRun)
+ {
+ Console.Error.WriteLine("[Dry-Run Mode] No changes will be applied.");
+ Console.Error.WriteLine();
+ }
+
+ Console.Error.WriteLine($"Syncing deployment settings for solution '{solution}'...");
+ }
+
+ // Load existing settings if file exists
+ DeploymentSettingsFile? existingSettings = null;
+ var fullPath = Path.GetFullPath(filePath);
+
+ if (File.Exists(fullPath))
+ {
+ var existingJson = await File.ReadAllTextAsync(fullPath, cancellationToken);
+ existingSettings = JsonSerializer.Deserialize(existingJson, JsonReadOptions);
+ }
+
+ var result = await settingsService.SyncAsync(solution, existingSettings, cancellationToken);
+
+ var evStats = result.EnvironmentVariables;
+ var crStats = result.ConnectionReferences;
+
+ if (!dryRun)
+ {
+ // Write updated file
+ var json = JsonSerializer.Serialize(result.Settings, JsonWriteOptions);
+ var directory = Path.GetDirectoryName(fullPath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+ await File.WriteAllTextAsync(fullPath, json, cancellationToken);
+ }
+
+ if (globalOptions.IsJsonMode)
+ {
+ var output = new SyncOutput
+ {
+ FilePath = fullPath,
+ DryRun = dryRun,
+ EnvironmentVariables = new SyncStatsOutput
+ {
+ Added = evStats.Added,
+ Removed = evStats.Removed,
+ Preserved = evStats.Preserved
+ },
+ ConnectionReferences = new SyncStatsOutput
+ {
+ Added = crStats.Added,
+ Removed = crStats.Removed,
+ Preserved = crStats.Preserved
+ }
+ };
+
+ writer.WriteSuccess(output);
+ }
+ else
+ {
+ Console.Error.WriteLine();
+ Console.Error.WriteLine($"Sync complete{(dryRun ? " (dry-run)" : "")}:");
+ Console.Error.WriteLine();
+ Console.Error.WriteLine(" Environment Variables:");
+ Console.Error.WriteLine($" Added: {evStats.Added}");
+ Console.Error.WriteLine($" Removed: {evStats.Removed}");
+ Console.Error.WriteLine($" Preserved: {evStats.Preserved}");
+ Console.Error.WriteLine();
+ Console.Error.WriteLine(" Connection References:");
+ Console.Error.WriteLine($" Added: {crStats.Added}");
+ Console.Error.WriteLine($" Removed: {crStats.Removed}");
+ Console.Error.WriteLine($" Preserved: {crStats.Preserved}");
+
+ if (!dryRun)
+ {
+ Console.Error.WriteLine();
+ Console.Error.WriteLine($"Updated: {fullPath}");
+ }
+ }
+
+ return ExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ var error = ExceptionMapper.Map(ex, context: "syncing deployment settings", debug: globalOptions.Debug);
+ writer.WriteError(error);
+ return ExceptionMapper.ToExitCode(ex);
+ }
+ }
+
+ #region Output Models
+
+ private sealed class SyncOutput
+ {
+ [JsonPropertyName("filePath")]
+ public string FilePath { get; set; } = string.Empty;
+
+ [JsonPropertyName("dryRun")]
+ public bool DryRun { get; set; }
+
+ [JsonPropertyName("environmentVariables")]
+ public SyncStatsOutput EnvironmentVariables { get; set; } = new();
+
+ [JsonPropertyName("connectionReferences")]
+ public SyncStatsOutput ConnectionReferences { get; set; } = new();
+ }
+
+ private sealed class SyncStatsOutput
+ {
+ [JsonPropertyName("added")]
+ public int Added { get; set; }
+
+ [JsonPropertyName("removed")]
+ public int Removed { get; set; }
+
+ [JsonPropertyName("preserved")]
+ public int Preserved { get; set; }
+ }
+
+ #endregion
+}
diff --git a/src/PPDS.Cli/Commands/DeploymentSettings/ValidateCommand.cs b/src/PPDS.Cli/Commands/DeploymentSettings/ValidateCommand.cs
new file mode 100644
index 00000000..a68742e3
--- /dev/null
+++ b/src/PPDS.Cli/Commands/DeploymentSettings/ValidateCommand.cs
@@ -0,0 +1,225 @@
+using System.CommandLine;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.DependencyInjection;
+using PPDS.Cli.Infrastructure;
+using PPDS.Cli.Infrastructure.Errors;
+using PPDS.Cli.Infrastructure.Output;
+using PPDS.Dataverse.Services;
+
+namespace PPDS.Cli.Commands.DeploymentSettings;
+
+///
+/// Validate a deployment settings file against the current solution.
+///
+public static class ValidateCommand
+{
+ private static readonly JsonSerializerOptions JsonReadOptions = new()
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ public static Command Create()
+ {
+ var fileOption = new Option("--file", "-f")
+ {
+ Description = "Deployment settings file path",
+ Required = true
+ };
+
+ var command = new Command("validate", "Validate deployment settings file against solution")
+ {
+ DeploymentSettingsCommandGroup.SolutionOption,
+ fileOption,
+ DeploymentSettingsCommandGroup.ProfileOption,
+ DeploymentSettingsCommandGroup.EnvironmentOption
+ };
+
+ GlobalOptions.AddToCommand(command);
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var solution = parseResult.GetValue(DeploymentSettingsCommandGroup.SolutionOption)!;
+ var file = parseResult.GetValue(fileOption)!;
+ var profile = parseResult.GetValue(DeploymentSettingsCommandGroup.ProfileOption);
+ var environment = parseResult.GetValue(DeploymentSettingsCommandGroup.EnvironmentOption);
+ var globalOptions = GlobalOptions.GetValues(parseResult);
+
+ return await ExecuteAsync(solution, file, profile, environment, globalOptions, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static async Task ExecuteAsync(
+ string solution,
+ string filePath,
+ string? profile,
+ string? environment,
+ GlobalOptionValues globalOptions,
+ CancellationToken cancellationToken)
+ {
+ var writer = ServiceFactory.CreateOutputWriter(globalOptions);
+
+ try
+ {
+ var fullPath = Path.GetFullPath(filePath);
+
+ if (!File.Exists(fullPath))
+ {
+ writer.WriteError(new StructuredError(
+ ErrorCodes.Validation.FileNotFound,
+ $"File not found: {fullPath}"));
+ return ExitCodes.InvalidArguments;
+ }
+
+ await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync(
+ profile,
+ environment,
+ globalOptions.Verbose,
+ globalOptions.Debug,
+ ProfileServiceFactory.DefaultDeviceCodeCallback,
+ cancellationToken);
+
+ var settingsService = serviceProvider.GetRequiredService();
+
+ if (!globalOptions.IsJsonMode)
+ {
+ var connectionInfo = serviceProvider.GetRequiredService();
+ ConsoleHeader.WriteConnectedAs(connectionInfo);
+ Console.Error.WriteLine();
+ Console.Error.WriteLine($"Validating deployment settings for solution '{solution}'...");
+ }
+
+ // Load settings file
+ var json = await File.ReadAllTextAsync(fullPath, cancellationToken);
+ var settings = JsonSerializer.Deserialize(json, JsonReadOptions);
+
+ if (settings == null)
+ {
+ writer.WriteError(new StructuredError(
+ ErrorCodes.Validation.InvalidValue,
+ "Failed to parse deployment settings file"));
+ return ExitCodes.InvalidArguments;
+ }
+
+ var result = await settingsService.ValidateAsync(solution, settings, cancellationToken);
+
+ if (globalOptions.IsJsonMode)
+ {
+ var output = new ValidationOutput
+ {
+ FilePath = fullPath,
+ IsValid = result.IsValid,
+ ErrorCount = result.Issues.Count(i => i.Severity == ValidationSeverity.Error),
+ WarningCount = result.Issues.Count(i => i.Severity == ValidationSeverity.Warning),
+ Issues = result.Issues.Select(i => new IssueOutput
+ {
+ Severity = i.Severity.ToString(),
+ EntryType = i.EntryType,
+ Name = i.Name,
+ Message = i.Message
+ }).ToList()
+ };
+
+ writer.WriteSuccess(output);
+ }
+ else
+ {
+ Console.Error.WriteLine();
+
+ if (result.IsValid)
+ {
+ Console.Error.WriteLine("Validation passed - no issues found.");
+ }
+ else
+ {
+ var errorCount = result.Issues.Count(i => i.Severity == ValidationSeverity.Error);
+ var warningCount = result.Issues.Count(i => i.Severity == ValidationSeverity.Warning);
+
+ Console.Error.WriteLine($"Validation complete: {errorCount} error(s), {warningCount} warning(s)");
+ Console.Error.WriteLine();
+
+ // Group by severity
+ var errors = result.Issues.Where(i => i.Severity == ValidationSeverity.Error).ToList();
+ var warnings = result.Issues.Where(i => i.Severity == ValidationSeverity.Warning).ToList();
+
+ if (errors.Count > 0)
+ {
+ Console.Error.WriteLine("ERRORS:");
+ foreach (var issue in errors)
+ {
+ Console.Error.WriteLine($" [{issue.EntryType}] {issue.Name}");
+ Console.Error.WriteLine($" {issue.Message}");
+ }
+ Console.Error.WriteLine();
+ }
+
+ if (warnings.Count > 0)
+ {
+ Console.Error.WriteLine("WARNINGS:");
+ foreach (var issue in warnings)
+ {
+ Console.Error.WriteLine($" [{issue.EntryType}] {issue.Name}");
+ Console.Error.WriteLine($" {issue.Message}");
+ }
+ }
+ }
+ }
+
+ // Return error exit code if there are errors (not just warnings)
+ var hasErrors = result.Issues.Any(i => i.Severity == ValidationSeverity.Error);
+ return hasErrors ? ExitCodes.Failure : ExitCodes.Success;
+ }
+ catch (JsonException ex)
+ {
+ writer.WriteError(new StructuredError(
+ ErrorCodes.Validation.InvalidValue,
+ $"Invalid JSON in deployment settings file: {ex.Message}"));
+ return ExitCodes.InvalidArguments;
+ }
+ catch (Exception ex)
+ {
+ var error = ExceptionMapper.Map(ex, context: "validating deployment settings", debug: globalOptions.Debug);
+ writer.WriteError(error);
+ return ExceptionMapper.ToExitCode(ex);
+ }
+ }
+
+ #region Output Models
+
+ private sealed class ValidationOutput
+ {
+ [JsonPropertyName("filePath")]
+ public string FilePath { get; set; } = string.Empty;
+
+ [JsonPropertyName("isValid")]
+ public bool IsValid { get; set; }
+
+ [JsonPropertyName("errorCount")]
+ public int ErrorCount { get; set; }
+
+ [JsonPropertyName("warningCount")]
+ public int WarningCount { get; set; }
+
+ [JsonPropertyName("issues")]
+ public List Issues { get; set; } = new();
+ }
+
+ private sealed class IssueOutput
+ {
+ [JsonPropertyName("severity")]
+ public string Severity { get; set; } = string.Empty;
+
+ [JsonPropertyName("entryType")]
+ public string EntryType { get; set; } = string.Empty;
+
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonPropertyName("message")]
+ public string Message { get; set; } = string.Empty;
+ }
+
+ #endregion
+}
diff --git a/src/PPDS.Cli/Commands/Flows/FlowsCommandGroup.cs b/src/PPDS.Cli/Commands/Flows/FlowsCommandGroup.cs
new file mode 100644
index 00000000..ae2c9e7f
--- /dev/null
+++ b/src/PPDS.Cli/Commands/Flows/FlowsCommandGroup.cs
@@ -0,0 +1,41 @@
+using System.CommandLine;
+
+namespace PPDS.Cli.Commands.Flows;
+
+///
+/// Command group for cloud flow operations.
+///
+public static class FlowsCommandGroup
+{
+ /// Shared profile option.
+ public static readonly Option ProfileOption = new("--profile", "-p")
+ {
+ Description = "Authentication profile name"
+ };
+
+ /// Shared environment option.
+ public static readonly Option EnvironmentOption = new("--environment", "-e")
+ {
+ Description = "Environment URL override"
+ };
+
+ /// Shared solution filter option.
+ public static readonly Option SolutionOption = new("--solution", "-s")
+ {
+ Description = "Filter by solution unique name"
+ };
+
+ ///
+ /// Creates the flows command group.
+ ///
+ public static Command Create()
+ {
+ var command = new Command("flows", "Manage cloud flows (Power Automate)");
+
+ command.Subcommands.Add(ListCommand.Create());
+ command.Subcommands.Add(GetCommand.Create());
+ command.Subcommands.Add(UrlCommand.Create());
+
+ return command;
+ }
+}
diff --git a/src/PPDS.Cli/Commands/Flows/GetCommand.cs b/src/PPDS.Cli/Commands/Flows/GetCommand.cs
new file mode 100644
index 00000000..3a9b0d6c
--- /dev/null
+++ b/src/PPDS.Cli/Commands/Flows/GetCommand.cs
@@ -0,0 +1,190 @@
+using System.CommandLine;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.DependencyInjection;
+using PPDS.Cli.Infrastructure;
+using PPDS.Cli.Infrastructure.Errors;
+using PPDS.Cli.Infrastructure.Output;
+using PPDS.Dataverse.Services;
+
+namespace PPDS.Cli.Commands.Flows;
+
+///
+/// Get a specific cloud flow by unique name.
+///
+public static class GetCommand
+{
+ public static Command Create()
+ {
+ var nameArgument = new Argument("name")
+ {
+ Description = "The unique name of the flow"
+ };
+
+ var command = new Command("get", "Get a cloud flow by unique name")
+ {
+ nameArgument,
+ FlowsCommandGroup.ProfileOption,
+ FlowsCommandGroup.EnvironmentOption
+ };
+
+ GlobalOptions.AddToCommand(command);
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var name = parseResult.GetValue(nameArgument)!;
+ var profile = parseResult.GetValue(FlowsCommandGroup.ProfileOption);
+ var environment = parseResult.GetValue(FlowsCommandGroup.EnvironmentOption);
+ var globalOptions = GlobalOptions.GetValues(parseResult);
+
+ return await ExecuteAsync(name, profile, environment, globalOptions, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static async Task ExecuteAsync(
+ string name,
+ string? profile,
+ string? environment,
+ GlobalOptionValues globalOptions,
+ CancellationToken cancellationToken)
+ {
+ var writer = ServiceFactory.CreateOutputWriter(globalOptions);
+
+ try
+ {
+ await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync(
+ profile,
+ environment,
+ globalOptions.Verbose,
+ globalOptions.Debug,
+ ProfileServiceFactory.DefaultDeviceCodeCallback,
+ cancellationToken);
+
+ var flowService = serviceProvider.GetRequiredService();
+
+ if (!globalOptions.IsJsonMode)
+ {
+ var connectionInfo = serviceProvider.GetRequiredService();
+ ConsoleHeader.WriteConnectedAs(connectionInfo);
+ Console.Error.WriteLine();
+ }
+
+ var flow = await flowService.GetAsync(name, cancellationToken);
+
+ if (flow == null)
+ {
+ writer.WriteError(new StructuredError(
+ ErrorCodes.Operation.NotFound,
+ $"Cloud flow '{name}' not found"));
+ return ExitCodes.NotFoundError;
+ }
+
+ if (globalOptions.IsJsonMode)
+ {
+ var output = new FlowDetail
+ {
+ Id = flow.Id,
+ UniqueName = flow.UniqueName,
+ DisplayName = flow.DisplayName,
+ Description = flow.Description,
+ State = flow.State.ToString(),
+ Category = flow.Category.ToString(),
+ IsManaged = flow.IsManaged,
+ ConnectionReferenceLogicalNames = flow.ConnectionReferenceLogicalNames,
+ OwnerId = flow.OwnerId,
+ OwnerName = flow.OwnerName,
+ CreatedOn = flow.CreatedOn,
+ ModifiedOn = flow.ModifiedOn
+ };
+
+ writer.WriteSuccess(output);
+ }
+ else
+ {
+ Console.WriteLine($"Flow: {flow.DisplayName ?? flow.UniqueName}");
+ Console.WriteLine($" Unique Name: {flow.UniqueName}");
+ Console.WriteLine($" ID: {flow.Id}");
+ Console.WriteLine($" State: {flow.State}");
+ Console.WriteLine($" Category: {flow.Category}");
+ Console.WriteLine($" Managed: {(flow.IsManaged ? "Yes" : "No")}");
+
+ if (!string.IsNullOrEmpty(flow.Description))
+ {
+ Console.WriteLine($" Description: {flow.Description}");
+ }
+
+ if (flow.ConnectionReferenceLogicalNames.Count > 0)
+ {
+ Console.WriteLine();
+ Console.WriteLine($" Connection References ({flow.ConnectionReferenceLogicalNames.Count}):");
+ foreach (var cr in flow.ConnectionReferenceLogicalNames)
+ {
+ Console.WriteLine($" - {cr}");
+ }
+ }
+
+ if (flow.OwnerName != null)
+ {
+ Console.WriteLine();
+ Console.WriteLine($" Owner: {flow.OwnerName}");
+ }
+
+ Console.WriteLine();
+ Console.WriteLine($" Created: {flow.CreatedOn}");
+ Console.WriteLine($" Modified: {flow.ModifiedOn}");
+ }
+
+ return ExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ var error = ExceptionMapper.Map(ex, context: "getting cloud flow", debug: globalOptions.Debug);
+ writer.WriteError(error);
+ return ExceptionMapper.ToExitCode(ex);
+ }
+ }
+
+ #region Output Models
+
+ private sealed class FlowDetail
+ {
+ [JsonPropertyName("id")]
+ public Guid Id { get; set; }
+
+ [JsonPropertyName("uniqueName")]
+ public string UniqueName { get; set; } = string.Empty;
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+
+ [JsonPropertyName("state")]
+ public string State { get; set; } = string.Empty;
+
+ [JsonPropertyName("category")]
+ public string Category { get; set; } = string.Empty;
+
+ [JsonPropertyName("isManaged")]
+ public bool IsManaged { get; set; }
+
+ [JsonPropertyName("connectionReferenceLogicalNames")]
+ public List ConnectionReferenceLogicalNames { get; set; } = new();
+
+ [JsonPropertyName("ownerId")]
+ public Guid? OwnerId { get; set; }
+
+ [JsonPropertyName("ownerName")]
+ public string? OwnerName { get; set; }
+
+ [JsonPropertyName("createdOn")]
+ public DateTime? CreatedOn { get; set; }
+
+ [JsonPropertyName("modifiedOn")]
+ public DateTime? ModifiedOn { get; set; }
+ }
+
+ #endregion
+}
diff --git a/src/PPDS.Cli/Commands/Flows/ListCommand.cs b/src/PPDS.Cli/Commands/Flows/ListCommand.cs
new file mode 100644
index 00000000..f81b2edc
--- /dev/null
+++ b/src/PPDS.Cli/Commands/Flows/ListCommand.cs
@@ -0,0 +1,183 @@
+using System.CommandLine;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.DependencyInjection;
+using PPDS.Cli.Infrastructure;
+using PPDS.Cli.Infrastructure.Errors;
+using PPDS.Cli.Infrastructure.Output;
+using PPDS.Dataverse.Services;
+
+namespace PPDS.Cli.Commands.Flows;
+
+///
+/// List cloud flows.
+///
+public static class ListCommand
+{
+ public static Command Create()
+ {
+ var stateOption = new Option("--state")
+ {
+ Description = "Filter by state (Draft, Activated, Suspended)"
+ };
+
+ var command = new Command("list", "List cloud flows")
+ {
+ FlowsCommandGroup.SolutionOption,
+ stateOption,
+ FlowsCommandGroup.ProfileOption,
+ FlowsCommandGroup.EnvironmentOption
+ };
+
+ GlobalOptions.AddToCommand(command);
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var solution = parseResult.GetValue(FlowsCommandGroup.SolutionOption);
+ var stateStr = parseResult.GetValue(stateOption);
+ var profile = parseResult.GetValue(FlowsCommandGroup.ProfileOption);
+ var environment = parseResult.GetValue(FlowsCommandGroup.EnvironmentOption);
+ var globalOptions = GlobalOptions.GetValues(parseResult);
+
+ return await ExecuteAsync(solution, stateStr, profile, environment, globalOptions, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static async Task ExecuteAsync(
+ string? solution,
+ string? stateStr,
+ string? profile,
+ string? environment,
+ GlobalOptionValues globalOptions,
+ CancellationToken cancellationToken)
+ {
+ var writer = ServiceFactory.CreateOutputWriter(globalOptions);
+
+ try
+ {
+ // Parse state filter if provided
+ FlowState? state = null;
+ if (!string.IsNullOrEmpty(stateStr))
+ {
+ if (!Enum.TryParse(stateStr, ignoreCase: true, out var parsedState))
+ {
+ writer.WriteError(new StructuredError(
+ ErrorCodes.Validation.InvalidValue,
+ $"Invalid state '{stateStr}'. Valid values: Draft, Activated, Suspended"));
+ return ExitCodes.InvalidArguments;
+ }
+ state = parsedState;
+ }
+
+ await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync(
+ profile,
+ environment,
+ globalOptions.Verbose,
+ globalOptions.Debug,
+ ProfileServiceFactory.DefaultDeviceCodeCallback,
+ cancellationToken);
+
+ var flowService = serviceProvider.GetRequiredService();
+
+ if (!globalOptions.IsJsonMode)
+ {
+ var connectionInfo = serviceProvider.GetRequiredService();
+ ConsoleHeader.WriteConnectedAs(connectionInfo);
+ Console.Error.WriteLine();
+ }
+
+ var flows = await flowService.ListAsync(solution, state, cancellationToken);
+
+ if (globalOptions.IsJsonMode)
+ {
+ var output = flows.Select(f => new FlowListItem
+ {
+ Id = f.Id,
+ UniqueName = f.UniqueName,
+ DisplayName = f.DisplayName,
+ State = f.State.ToString(),
+ Category = f.Category.ToString(),
+ IsManaged = f.IsManaged,
+ ConnectionReferenceCount = f.ConnectionReferenceLogicalNames.Count,
+ ModifiedOn = f.ModifiedOn
+ }).ToList();
+
+ writer.WriteSuccess(output);
+ }
+ else
+ {
+ if (flows.Count == 0)
+ {
+ Console.Error.WriteLine("No cloud flows found.");
+ }
+ else
+ {
+ Console.Error.WriteLine($"Found {flows.Count} cloud flow(s):");
+ Console.Error.WriteLine();
+
+ foreach (var f in flows)
+ {
+ var stateDisplay = f.State switch
+ {
+ FlowState.Activated => "On",
+ FlowState.Suspended => "Suspended",
+ _ => "Off"
+ };
+
+ Console.WriteLine($" {f.DisplayName ?? f.UniqueName}");
+ Console.WriteLine($" Name: {f.UniqueName} State: {stateDisplay} Type: {f.Category}");
+ if (f.ConnectionReferenceLogicalNames.Count > 0)
+ {
+ Console.WriteLine($" Connection References: {f.ConnectionReferenceLogicalNames.Count}");
+ }
+ if (f.IsManaged)
+ {
+ Console.WriteLine($" Managed: Yes");
+ }
+ Console.WriteLine();
+ }
+ }
+ }
+
+ return ExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ var error = ExceptionMapper.Map(ex, context: "listing cloud flows", debug: globalOptions.Debug);
+ writer.WriteError(error);
+ return ExceptionMapper.ToExitCode(ex);
+ }
+ }
+
+ #region Output Models
+
+ private sealed class FlowListItem
+ {
+ [JsonPropertyName("id")]
+ public Guid Id { get; set; }
+
+ [JsonPropertyName("uniqueName")]
+ public string UniqueName { get; set; } = string.Empty;
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("state")]
+ public string State { get; set; } = string.Empty;
+
+ [JsonPropertyName("category")]
+ public string Category { get; set; } = string.Empty;
+
+ [JsonPropertyName("isManaged")]
+ public bool IsManaged { get; set; }
+
+ [JsonPropertyName("connectionReferenceCount")]
+ public int ConnectionReferenceCount { get; set; }
+
+ [JsonPropertyName("modifiedOn")]
+ public DateTime? ModifiedOn { get; set; }
+ }
+
+ #endregion
+}
diff --git a/src/PPDS.Cli/Commands/Flows/UrlCommand.cs b/src/PPDS.Cli/Commands/Flows/UrlCommand.cs
new file mode 100644
index 00000000..04285699
--- /dev/null
+++ b/src/PPDS.Cli/Commands/Flows/UrlCommand.cs
@@ -0,0 +1,135 @@
+using System.CommandLine;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.DependencyInjection;
+using PPDS.Cli.Infrastructure;
+using PPDS.Cli.Infrastructure.Errors;
+using PPDS.Cli.Infrastructure.Output;
+using PPDS.Dataverse.Services;
+
+namespace PPDS.Cli.Commands.Flows;
+
+///
+/// Get the Power Automate maker URL for a cloud flow.
+///
+public static class UrlCommand
+{
+ public static Command Create()
+ {
+ var nameArgument = new Argument("name")
+ {
+ Description = "The unique name of the flow"
+ };
+
+ var command = new Command("url", "Get Power Automate maker URL for a flow")
+ {
+ nameArgument,
+ FlowsCommandGroup.ProfileOption,
+ FlowsCommandGroup.EnvironmentOption
+ };
+
+ GlobalOptions.AddToCommand(command);
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var name = parseResult.GetValue(nameArgument)!;
+ var profile = parseResult.GetValue(FlowsCommandGroup.ProfileOption);
+ var environment = parseResult.GetValue(FlowsCommandGroup.EnvironmentOption);
+ var globalOptions = GlobalOptions.GetValues(parseResult);
+
+ return await ExecuteAsync(name, profile, environment, globalOptions, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static async Task ExecuteAsync(
+ string name,
+ string? profile,
+ string? environment,
+ GlobalOptionValues globalOptions,
+ CancellationToken cancellationToken)
+ {
+ var writer = ServiceFactory.CreateOutputWriter(globalOptions);
+
+ try
+ {
+ await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync(
+ profile,
+ environment,
+ globalOptions.Verbose,
+ globalOptions.Debug,
+ ProfileServiceFactory.DefaultDeviceCodeCallback,
+ cancellationToken);
+
+ var flowService = serviceProvider.GetRequiredService();
+ var connectionInfo = serviceProvider.GetRequiredService();
+
+ var flow = await flowService.GetAsync(name, cancellationToken);
+
+ if (flow == null)
+ {
+ writer.WriteError(new StructuredError(
+ ErrorCodes.Operation.NotFound,
+ $"Cloud flow '{name}' not found"));
+ return ExitCodes.NotFoundError;
+ }
+
+ // Require environment ID for maker URL
+ if (string.IsNullOrEmpty(connectionInfo.EnvironmentId))
+ {
+ writer.WriteError(new StructuredError(
+ ErrorCodes.Validation.RequiredField,
+ "Environment ID is not available. Re-select the environment with 'ppds env select' to populate the environment ID."));
+ return ExitCodes.ValidationError;
+ }
+
+ // Build Power Automate URL using the Power Platform environment ID GUID
+ // Format: https://make.powerautomate.com/environments/{environmentId}/flows/{flowId}/details
+ var makerUrl = $"https://make.powerautomate.com/environments/{connectionInfo.EnvironmentId}/flows/{flow.Id}/details";
+
+ if (globalOptions.IsJsonMode)
+ {
+ var output = new UrlOutput
+ {
+ Id = flow.Id,
+ UniqueName = flow.UniqueName,
+ DisplayName = flow.DisplayName,
+ MakerUrl = makerUrl
+ };
+
+ writer.WriteSuccess(output);
+ }
+ else
+ {
+ Console.WriteLine(makerUrl);
+ }
+
+ return ExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ var error = ExceptionMapper.Map(ex, context: "getting flow URL", debug: globalOptions.Debug);
+ writer.WriteError(error);
+ return ExceptionMapper.ToExitCode(ex);
+ }
+ }
+
+ #region Output Models
+
+ private sealed class UrlOutput
+ {
+ [JsonPropertyName("id")]
+ public Guid Id { get; set; }
+
+ [JsonPropertyName("uniqueName")]
+ public string UniqueName { get; set; } = string.Empty;
+
+ [JsonPropertyName("displayName")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("makerUrl")]
+ public string MakerUrl { get; set; } = string.Empty;
+ }
+
+ #endregion
+}
diff --git a/src/PPDS.Cli/Infrastructure/EnvironmentResolverHelper.cs b/src/PPDS.Cli/Infrastructure/EnvironmentResolverHelper.cs
index 01695e7f..c283ffce 100644
--- a/src/PPDS.Cli/Infrastructure/EnvironmentResolverHelper.cs
+++ b/src/PPDS.Cli/Infrastructure/EnvironmentResolverHelper.cs
@@ -22,6 +22,12 @@ public sealed class ResolvedEnvironment
/// Gets the environment unique name.
///
public string? UniqueName { get; init; }
+
+ ///
+ /// Gets the Power Platform environment ID.
+ /// Required for Power Apps Admin API operations.
+ ///
+ public string? EnvironmentId { get; init; }
}
///
@@ -86,7 +92,8 @@ public static async Task ResolveAsync(
{
Url = resolved.ApiUrl,
DisplayName = resolved.FriendlyName,
- UniqueName = resolved.UniqueName
+ UniqueName = resolved.UniqueName,
+ EnvironmentId = resolved.EnvironmentId
};
}
@@ -183,7 +190,8 @@ private static ResolvedEnvironment ResolveFromList(
{
Url = resolved.ApiUrl,
DisplayName = resolved.FriendlyName,
- UniqueName = resolved.UniqueName
+ UniqueName = resolved.UniqueName,
+ EnvironmentId = resolved.EnvironmentId
};
}
}
diff --git a/src/PPDS.Cli/Infrastructure/ProfileServiceFactory.cs b/src/PPDS.Cli/Infrastructure/ProfileServiceFactory.cs
index bc900269..10604df0 100644
--- a/src/PPDS.Cli/Infrastructure/ProfileServiceFactory.cs
+++ b/src/PPDS.Cli/Infrastructure/ProfileServiceFactory.cs
@@ -32,6 +32,12 @@ public sealed class ResolvedConnectionInfo
/// Gets the environment display name if available.
///
public string? EnvironmentDisplayName { get; init; }
+
+ ///
+ /// Gets the Power Platform environment ID if available.
+ /// Required for Power Apps Admin API operations.
+ ///
+ public string? EnvironmentId { get; init; }
}
///
@@ -75,7 +81,7 @@ public static async Task CreateFromProfileAsync(
}
// Resolve environment - handles URL, name, ID, or uses profile's saved environment
- var (envUrl, envDisplayName) = await ResolveEnvironmentAsync(
+ var (envUrl, envDisplayName, envId) = await ResolveEnvironmentAsync(
profile, environmentOverride, cancellationToken).ConfigureAwait(false);
// Create credential store for secure secret lookups (registered in DI for disposal)
@@ -89,16 +95,17 @@ public static async Task CreateFromProfileAsync(
{
Profile = profile,
EnvironmentUrl = envUrl,
- EnvironmentDisplayName = envDisplayName
+ EnvironmentDisplayName = envDisplayName,
+ EnvironmentId = envId
};
return CreateProviderFromSources(new[] { adapter }, connectionInfo, credentialStore, verbose, debug);
}
///
- /// Resolves an environment identifier to a URL and display name.
+ /// Resolves an environment identifier to a URL, display name, and environment ID.
///
- private static async Task<(string Url, string? DisplayName)> ResolveEnvironmentAsync(
+ private static async Task<(string Url, string? DisplayName, string? EnvironmentId)> ResolveEnvironmentAsync(
AuthProfile profile,
string? environmentOverride,
CancellationToken cancellationToken)
@@ -114,21 +121,22 @@ public static async Task CreateFromProfileAsync(
" 1. Select an environment: ppds env select \n" +
" 2. Specify on command: --environment ");
}
- return (profile.Environment.Url, profile.Environment.DisplayName);
+ return (profile.Environment.Url, profile.Environment.DisplayName, profile.Environment.EnvironmentId);
}
// Check if it's already a URL
if (Uri.TryCreate(environmentOverride, UriKind.Absolute, out var uri) &&
(uri.Scheme == "https" || uri.Scheme == "http"))
{
- return (environmentOverride.TrimEnd('/'), uri.Host);
+ // URL override - we don't have the environment ID
+ return (environmentOverride.TrimEnd('/'), uri.Host, null);
}
// Resolve name/ID to URL using GlobalDiscoveryService
var resolved = await EnvironmentResolverHelper.ResolveAsync(
profile, environmentOverride, cancellationToken).ConfigureAwait(false);
- return (resolved.Url, resolved.DisplayName);
+ return (resolved.Url, resolved.DisplayName, resolved.EnvironmentId);
}
///
@@ -168,7 +176,7 @@ public static async Task CreateFromProfilesAsync(
var firstProfile = collection.GetByName(names[0])
?? throw new InvalidOperationException($"Profile '{names[0]}' not found.");
- var (envUrl, envDisplayName) = await ResolveEnvironmentAsync(
+ var (envUrl, envDisplayName, envId) = await ResolveEnvironmentAsync(
firstProfile, environmentOverride, cancellationToken).ConfigureAwait(false);
// Create credential store for secure secret lookups (registered in DI for disposal)
@@ -187,7 +195,8 @@ public static async Task CreateFromProfilesAsync(
{
Profile = firstProfile,
EnvironmentUrl = envUrl,
- EnvironmentDisplayName = envDisplayName
+ EnvironmentDisplayName = envDisplayName,
+ EnvironmentId = envId
};
return CreateProviderFromSources(adapters, connectionInfo, credentialStore, verbose, debug);
diff --git a/src/PPDS.Cli/Program.cs b/src/PPDS.Cli/Program.cs
index c12085a0..709ac5fc 100644
--- a/src/PPDS.Cli/Program.cs
+++ b/src/PPDS.Cli/Program.cs
@@ -1,18 +1,22 @@
using System.CommandLine;
using PPDS.Cli.Commands.Auth;
+using PPDS.Cli.Commands.Connections;
+using PPDS.Cli.Commands.ConnectionReferences;
using PPDS.Cli.Commands.Data;
+using PPDS.Cli.Commands.DeploymentSettings;
using PPDS.Cli.Commands.Env;
+using PPDS.Cli.Commands.EnvironmentVariables;
+using PPDS.Cli.Commands.Flows;
+using PPDS.Cli.Commands.ImportJobs;
+using PPDS.Cli.Commands.Internal;
using PPDS.Cli.Commands.Metadata;
using PPDS.Cli.Commands.Plugins;
using PPDS.Cli.Commands.Query;
-using PPDS.Cli.Commands.Internal;
+using PPDS.Cli.Commands.Roles;
using PPDS.Cli.Commands.Serve;
-using PPDS.Cli.Commands;
using PPDS.Cli.Commands.Solutions;
-using PPDS.Cli.Commands.ImportJobs;
-using PPDS.Cli.Commands.EnvironmentVariables;
using PPDS.Cli.Commands.Users;
-using PPDS.Cli.Commands.Roles;
+using PPDS.Cli.Commands;
using PPDS.Cli.Infrastructure;
using PPDS.Cli.Interactive;
@@ -62,6 +66,10 @@ public static async Task Main(string[] args)
rootCommand.Subcommands.Add(SolutionsCommandGroup.Create());
rootCommand.Subcommands.Add(ImportJobsCommandGroup.Create());
rootCommand.Subcommands.Add(EnvironmentVariablesCommandGroup.Create());
+ rootCommand.Subcommands.Add(FlowsCommandGroup.Create());
+ rootCommand.Subcommands.Add(ConnectionsCommandGroup.Create());
+ rootCommand.Subcommands.Add(ConnectionReferencesCommandGroup.Create());
+ rootCommand.Subcommands.Add(DeploymentSettingsCommandGroup.Create());
rootCommand.Subcommands.Add(UsersCommandGroup.Create());
rootCommand.Subcommands.Add(RolesCommandGroup.Create());
rootCommand.Subcommands.Add(ServeCommand.Create());
diff --git a/src/PPDS.Cli/Services/ConnectionService.cs b/src/PPDS.Cli/Services/ConnectionService.cs
new file mode 100644
index 00000000..8cb36e85
--- /dev/null
+++ b/src/PPDS.Cli/Services/ConnectionService.cs
@@ -0,0 +1,226 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using PPDS.Auth.Cloud;
+using PPDS.Auth.Credentials;
+
+namespace PPDS.Cli.Services;
+
+///
+/// Service for Power Platform connection operations via the Power Apps Admin API.
+///
+public class ConnectionService : IConnectionService
+{
+ private readonly IPowerPlatformTokenProvider _tokenProvider;
+ private readonly CloudEnvironment _cloud;
+ private readonly string _environmentId;
+ private readonly ILogger _logger;
+ private readonly HttpClient _httpClient;
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Power Platform token provider.
+ /// The cloud environment.
+ /// The environment ID.
+ /// The logger.
+ public ConnectionService(
+ IPowerPlatformTokenProvider tokenProvider,
+ CloudEnvironment cloud,
+ string environmentId,
+ ILogger logger)
+ {
+ _tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
+ _cloud = cloud;
+ _environmentId = environmentId ?? throw new ArgumentNullException(nameof(environmentId));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _httpClient = new HttpClient();
+ }
+
+ ///
+ public async Task> ListAsync(
+ string? connectorFilter = null,
+ CancellationToken cancellationToken = default)
+ {
+ // Use service.powerapps.com scope for Power Apps Admin API
+ var token = await _tokenProvider.GetFlowApiTokenAsync(cancellationToken);
+ var baseUrl = CloudEndpoints.GetPowerAppsApiUrl(_cloud);
+
+ // Power Apps Admin API connections endpoint
+ // Path: /providers/Microsoft.PowerApps/scopes/admin/environments/{environmentId}/connections
+ var url = $"{baseUrl}/providers/Microsoft.PowerApps/scopes/admin/environments/{_environmentId}/connections?api-version=2016-11-01";
+
+ if (!string.IsNullOrEmpty(connectorFilter))
+ {
+ // Filter by connector ID
+ url += $"&$filter=properties/apiId eq '{connectorFilter}'";
+ }
+
+ _logger.LogDebug("Querying connections from Power Apps Admin API: {Url}", url);
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);
+
+ using var response = await _httpClient.SendAsync(request, cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
+ _logger.LogError("Power Apps Admin API error: {StatusCode} - {Content}", response.StatusCode, errorContent);
+
+ // Check for SPN limitation
+ if (response.StatusCode == System.Net.HttpStatusCode.Forbidden ||
+ response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
+ {
+ throw new InvalidOperationException(
+ $"Cannot access connections API. Status: {response.StatusCode}. " +
+ "Service principals have limited access to the Connections API. " +
+ "Use interactive or device code authentication for full functionality.");
+ }
+
+ throw new HttpRequestException($"Power Apps Admin API error: {response.StatusCode} - {errorContent}");
+ }
+
+ var content = await response.Content.ReadAsStringAsync(cancellationToken);
+ var apiResponse = JsonSerializer.Deserialize(content, JsonOptions);
+
+ if (apiResponse?.Value == null)
+ {
+ return new List();
+ }
+
+ var connections = apiResponse.Value.Select(MapToConnectionInfo).ToList();
+ _logger.LogDebug("Found {Count} connections", connections.Count);
+
+ return connections;
+ }
+
+ ///
+ public async Task GetAsync(
+ string connectionId,
+ CancellationToken cancellationToken = default)
+ {
+ // Use service.powerapps.com scope for Power Apps Admin API
+ var token = await _tokenProvider.GetFlowApiTokenAsync(cancellationToken);
+ var baseUrl = CloudEndpoints.GetPowerAppsApiUrl(_cloud);
+
+ // Direct connection lookup via Power Apps Admin API
+ var url = $"{baseUrl}/providers/Microsoft.PowerApps/scopes/admin/environments/{_environmentId}/connections/{connectionId}?api-version=2016-11-01";
+
+ _logger.LogDebug("Getting connection from Power Apps Admin API: {Url}", url);
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);
+
+ using var response = await _httpClient.SendAsync(request, cancellationToken);
+
+ if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ return null;
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
+ _logger.LogError("Power Apps Admin API error: {StatusCode} - {Content}", response.StatusCode, errorContent);
+ throw new HttpRequestException($"Power Apps Admin API error: {response.StatusCode}");
+ }
+
+ var content = await response.Content.ReadAsStringAsync(cancellationToken);
+ var connectionData = JsonSerializer.Deserialize(content, JsonOptions);
+
+ return connectionData != null ? MapToConnectionInfo(connectionData) : null;
+ }
+
+ private static ConnectionInfo MapToConnectionInfo(PowerAppsConnectionData data)
+ {
+ var status = ConnectionStatus.Unknown;
+ if (data.Properties?.Statuses != null)
+ {
+ var hasError = data.Properties.Statuses.Any(s =>
+ string.Equals(s.Status, "Error", StringComparison.OrdinalIgnoreCase));
+ status = hasError ? ConnectionStatus.Error : ConnectionStatus.Connected;
+ }
+
+ return new ConnectionInfo
+ {
+ ConnectionId = ExtractConnectionId(data.Name ?? string.Empty),
+ DisplayName = data.Properties?.DisplayName,
+ ConnectorId = data.Properties?.ApiId ?? string.Empty,
+ ConnectorDisplayName = data.Properties?.ApiDisplayName,
+ EnvironmentId = data.Properties?.Environment?.Name,
+ Status = status,
+ IsShared = data.Properties?.IsShared ?? false,
+ CreatedOn = data.Properties?.CreatedTime,
+ ModifiedOn = data.Properties?.LastModifiedTime,
+ CreatedBy = data.Properties?.CreatedBy?.Email
+ };
+ }
+
+ private static string ExtractConnectionId(string name)
+ {
+ // Name format: /providers/Microsoft.PowerApps/connections/{connectionId}
+ var parts = name.Split('/');
+ return parts.Length > 0 ? parts[^1] : name;
+ }
+
+ #region API Response Models
+
+ private sealed class PowerAppsConnectionsResponse
+ {
+ public List? Value { get; set; }
+ }
+
+ private sealed class PowerAppsConnectionData
+ {
+ public string? Name { get; set; }
+ public string? Id { get; set; }
+ public string? Type { get; set; }
+ public ConnectionProperties? Properties { get; set; }
+ }
+
+ private sealed class ConnectionProperties
+ {
+ public string? DisplayName { get; set; }
+ public string? ApiId { get; set; }
+ public string? ApiDisplayName { get; set; }
+ public bool? IsShared { get; set; }
+ public DateTime? CreatedTime { get; set; }
+ public DateTime? LastModifiedTime { get; set; }
+ public EnvironmentRef? Environment { get; set; }
+ public List? Statuses { get; set; }
+ public UserInfo? CreatedBy { get; set; }
+ }
+
+ private sealed class EnvironmentRef
+ {
+ public string? Name { get; set; }
+ public string? Id { get; set; }
+ }
+
+ private sealed class ConnectionStatusItem
+ {
+ public string? Status { get; set; }
+ public string? Error { get; set; }
+ }
+
+ private sealed class UserInfo
+ {
+ public string? Id { get; set; }
+ public string? DisplayName { get; set; }
+ public string? Email { get; set; }
+ }
+
+ #endregion
+}
diff --git a/src/PPDS.Cli/Services/IConnectionService.cs b/src/PPDS.Cli/Services/IConnectionService.cs
new file mode 100644
index 00000000..0c65e8f8
--- /dev/null
+++ b/src/PPDS.Cli/Services/IConnectionService.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PPDS.Cli.Services;
+
+///
+/// Service for Power Platform connection operations via the Power Apps Admin API.
+///
+///
+///
+/// Connections are managed through the Power Apps Admin API, not Dataverse.
+/// Connection References (in Dataverse) reference Connections (in Power Apps API).
+///
+///
+/// SPN Limitations: Service principals have limited access to the Connections API.
+/// User-delegated authentication (interactive/device code) provides full functionality.
+///
+///
+/// Note: This service lives in PPDS.Cli rather than PPDS.Dataverse because
+/// it requires IPowerPlatformTokenProvider from PPDS.Auth, and PPDS.Dataverse does not
+/// reference PPDS.Auth (to avoid circular dependencies).
+///
+///
+public interface IConnectionService
+{
+ ///
+ /// Lists connections from the Power Apps Admin API.
+ ///
+ /// Optional filter by connector ID (e.g., "shared_commondataserviceforapps").
+ /// Cancellation token.
+ /// List of connections.
+ Task> ListAsync(
+ string? connectorFilter = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets a specific connection by ID.
+ ///
+ /// The connection ID.
+ /// Cancellation token.
+ /// The connection info, or null if not found.
+ Task GetAsync(
+ string connectionId,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Power Platform connection information from the Power Apps Admin API.
+///
+public sealed record ConnectionInfo
+{
+ /// Gets the connection ID (GUID format but stored as string).
+ public required string ConnectionId { get; init; }
+
+ /// Gets the display name of the connection.
+ public string? DisplayName { get; init; }
+
+ /// Gets the connector ID (e.g., "/providers/Microsoft.PowerApps/apis/shared_commondataserviceforapps").
+ public required string ConnectorId { get; init; }
+
+ /// Gets the connector display name.
+ public string? ConnectorDisplayName { get; init; }
+
+ /// Gets the environment ID where the connection exists.
+ public string? EnvironmentId { get; init; }
+
+ /// Gets the connection status.
+ public ConnectionStatus Status { get; init; }
+
+ /// Gets whether the connection is shared or personal.
+ public bool IsShared { get; init; }
+
+ /// Gets the creation date.
+ public DateTime? CreatedOn { get; init; }
+
+ /// Gets the last modified date.
+ public DateTime? ModifiedOn { get; init; }
+
+ /// Gets the creator's email address.
+ public string? CreatedBy { get; init; }
+}
+
+///
+/// Connection status.
+///
+public enum ConnectionStatus
+{
+ /// Connection is healthy and working.
+ Connected,
+
+ /// Connection has an error (credentials expired, etc.).
+ Error,
+
+ /// Connection status is unknown or not provided.
+ Unknown
+}
diff --git a/src/PPDS.Cli/Services/ServiceRegistration.cs b/src/PPDS.Cli/Services/ServiceRegistration.cs
index f511b72a..a5f46b84 100644
--- a/src/PPDS.Cli/Services/ServiceRegistration.cs
+++ b/src/PPDS.Cli/Services/ServiceRegistration.cs
@@ -1,4 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using PPDS.Auth.Credentials;
+using PPDS.Auth.Profiles;
+using PPDS.Cli.Infrastructure;
using PPDS.Cli.Services.Query;
namespace PPDS.Cli.Services;
@@ -23,9 +27,57 @@ public static IServiceCollection AddCliApplicationServices(this IServiceCollecti
// Query services
services.AddTransient();
- // Future services will be registered here:
- // services.AddTransient();
- // services.AddTransient();
+ // Connection service - requires profile-based token provider and environment ID
+ // Registered as factory because it needs runtime values from ResolvedConnectionInfo
+ services.AddTransient(sp =>
+ {
+ var connectionInfo = sp.GetRequiredService();
+ var credentialStore = sp.GetRequiredService();
+ var logger = sp.GetRequiredService>();
+
+ // Environment ID is required for Power Apps Admin API
+ if (string.IsNullOrEmpty(connectionInfo.EnvironmentId))
+ {
+ throw new InvalidOperationException(
+ "Environment ID is not available. Power Apps Admin API operations require an environment " +
+ "that was resolved through Global Discovery Service. Direct URL connections do not provide " +
+ "the environment ID needed for connection operations.");
+ }
+
+ // Create token provider from profile
+ var profile = connectionInfo.Profile;
+ IPowerPlatformTokenProvider tokenProvider;
+
+ if (profile.AuthMethod == AuthMethod.ClientSecret)
+ {
+ // SPN - need secret from credential store (keyed by ApplicationId)
+ if (string.IsNullOrEmpty(profile.ApplicationId))
+ {
+ throw new InvalidOperationException(
+ $"Profile '{profile.DisplayIdentifier}' is configured for ClientSecret auth but has no ApplicationId.");
+ }
+
+ var storedCredential = credentialStore.GetAsync(profile.ApplicationId).GetAwaiter().GetResult();
+ if (storedCredential?.ClientSecret == null)
+ {
+ throw new InvalidOperationException(
+ $"Client secret not found for application '{profile.ApplicationId}'. " +
+ "Run 'ppds auth create' to recreate the profile with credentials.");
+ }
+ tokenProvider = PowerPlatformTokenProvider.FromProfileWithSecret(profile, storedCredential.ClientSecret);
+ }
+ else
+ {
+ // User-delegated auth
+ tokenProvider = PowerPlatformTokenProvider.FromProfile(profile);
+ }
+
+ return new ConnectionService(
+ tokenProvider,
+ profile.Cloud,
+ connectionInfo.EnvironmentId,
+ logger);
+ });
return services;
}
diff --git a/src/PPDS.Dataverse/CHANGELOG.md b/src/PPDS.Dataverse/CHANGELOG.md
index 453b6010..ae13d066 100644
--- a/src/PPDS.Dataverse/CHANGELOG.md
+++ b/src/PPDS.Dataverse/CHANGELOG.md
@@ -11,6 +11,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **`IFlowService`** - Service for cloud flow operations ([#142](https://github.com/joshsmithxrm/ppds-sdk/issues/142)):
+ - `ListAsync` - List flows with optional solution and state filters
+ - `GetAsync` - Get flow by unique name
+ - `GetByIdAsync` - Get flow by ID
+ - Parses flow `clientdata` JSON to extract connection reference logical names
+- **`IConnectionReferenceService`** - Service for connection reference operations with orphan detection ([#143](https://github.com/joshsmithxrm/ppds-sdk/issues/143)):
+ - `ListAsync` - List connection references with solution and orphan filtering
+ - `GetAsync` - Get connection reference by logical name
+ - `GetFlowsUsingAsync` - Get flows that use a specific connection reference
+ - `AnalyzeAsync` - Full relationship analysis with orphan detection (flows referencing missing CRs, CRs not used by any flow)
+- **`IDeploymentSettingsService`** - Service for PAC-compatible deployment settings files ([#145](https://github.com/joshsmithxrm/ppds-sdk/issues/145)):
+ - `GenerateAsync` - Generate settings from current environment (captures current values)
+ - `SyncAsync` - Sync existing file with solution (preserves values, adds new entries, removes stale)
+ - `ValidateAsync` - Validate settings against solution (missing entries, stale entries, unbound CRs)
+- **`FlowClientDataParser`** - Utility for extracting connection reference logical names from flow clientdata JSON
- **`Workflow` early-bound entity** - Entity class for Power Automate flows (classic workflows). Supports flow management operations. ([#149](https://github.com/joshsmithxrm/ppds-sdk/issues/149))
- **`ConnectionReference` early-bound entity** - Entity class for connection references used by flows and canvas apps. Fixed naming from pac modelbuilder's inconsistent lowercase output. ([#149](https://github.com/joshsmithxrm/ppds-sdk/issues/149))
- **Field-level error context in bulk operation errors** - `BulkOperationError` now includes `FieldName` (extracted from error messages) and `FieldValueDescription` (sanitized value info for EntityReferences). Makes debugging lookup failures and required field errors easier. See [ADR-0022](../../docs/adr/0022_IMPORT_DIAGNOSTICS_ARCHITECTURE.md).
diff --git a/src/PPDS.Dataverse/DependencyInjection/ServiceCollectionExtensions.cs b/src/PPDS.Dataverse/DependencyInjection/ServiceCollectionExtensions.cs
index 577099c8..e6b4d4d1 100644
--- a/src/PPDS.Dataverse/DependencyInjection/ServiceCollectionExtensions.cs
+++ b/src/PPDS.Dataverse/DependencyInjection/ServiceCollectionExtensions.cs
@@ -257,6 +257,11 @@ public static IServiceCollection RegisterDataverseServices(this IServiceCollecti
services.AddTransient();
services.AddTransient();
+ // Phase 2 services
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+
return services;
}
}
diff --git a/src/PPDS.Dataverse/Services/ConnectionReferenceService.cs b/src/PPDS.Dataverse/Services/ConnectionReferenceService.cs
new file mode 100644
index 00000000..527d4c3a
--- /dev/null
+++ b/src/PPDS.Dataverse/Services/ConnectionReferenceService.cs
@@ -0,0 +1,268 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Xrm.Sdk;
+using Microsoft.Xrm.Sdk.Query;
+using PPDS.Dataverse.Generated;
+using PPDS.Dataverse.Pooling;
+using PPDS.Dataverse.Services.Utilities;
+
+namespace PPDS.Dataverse.Services;
+
+///
+/// Service for connection reference operations in Dataverse.
+///
+public class ConnectionReferenceService : IConnectionReferenceService
+{
+ private readonly IDataverseConnectionPool _pool;
+ private readonly IFlowService _flowService;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The connection pool.
+ /// The flow service for relationship analysis.
+ /// The logger.
+ public ConnectionReferenceService(
+ IDataverseConnectionPool pool,
+ IFlowService flowService,
+ ILogger logger)
+ {
+ _pool = pool ?? throw new ArgumentNullException(nameof(pool));
+ _flowService = flowService ?? throw new ArgumentNullException(nameof(flowService));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ public async Task> ListAsync(
+ string? solutionName = null,
+ bool unboundOnly = false,
+ CancellationToken cancellationToken = default)
+ {
+ await using var client = await _pool.GetClientAsync(cancellationToken: cancellationToken);
+
+ var query = new QueryExpression(ConnectionReference.EntityLogicalName)
+ {
+ ColumnSet = new ColumnSet(
+ ConnectionReference.Fields.ConnectionReferenceId,
+ ConnectionReference.Fields.ConnectionReferenceLogicalName,
+ ConnectionReference.Fields.ConnectionReferenceDisplayName,
+ ConnectionReference.Fields.Description,
+ ConnectionReference.Fields.ConnectionId,
+ ConnectionReference.Fields.ConnectorId,
+ ConnectionReference.Fields.IsManaged,
+ ConnectionReference.Fields.CreatedOn,
+ ConnectionReference.Fields.ModifiedOn),
+ Orders = { new OrderExpression(ConnectionReference.Fields.ConnectionReferenceLogicalName, OrderType.Ascending) }
+ };
+
+ // Only active connection references
+ query.Criteria.AddCondition(ConnectionReference.Fields.StateCode, ConditionOperator.Equal, 0);
+
+ // Filter to unbound only if specified
+ if (unboundOnly)
+ {
+ query.Criteria.AddCondition(ConnectionReference.Fields.ConnectionId, ConditionOperator.Null);
+ }
+
+ // Filter by solution if specified
+ if (!string.IsNullOrEmpty(solutionName))
+ {
+ var solutionLink = query.AddLink(
+ SolutionComponent.EntityLogicalName,
+ ConnectionReference.Fields.ConnectionReferenceId,
+ SolutionComponent.Fields.ObjectId);
+ solutionLink.EntityAlias = "sc";
+
+ var solutionLink2 = solutionLink.AddLink(
+ Solution.EntityLogicalName,
+ SolutionComponent.Fields.SolutionId,
+ Solution.Fields.SolutionId);
+ solutionLink2.EntityAlias = "sol";
+ solutionLink2.LinkCriteria.AddCondition(
+ Solution.Fields.UniqueName, ConditionOperator.Equal, solutionName);
+ }
+
+ _logger.LogDebug("Querying connection references");
+ var results = await client.RetrieveMultipleAsync(query, cancellationToken);
+
+ var connectionRefs = results.Entities.Select(MapToInfo).ToList();
+ _logger.LogDebug("Found {Count} connection references", connectionRefs.Count);
+
+ return connectionRefs;
+ }
+
+ ///
+ public async Task GetAsync(
+ string logicalName,
+ CancellationToken cancellationToken = default)
+ {
+ await using var client = await _pool.GetClientAsync(cancellationToken: cancellationToken);
+
+ var query = new QueryExpression(ConnectionReference.EntityLogicalName)
+ {
+ ColumnSet = new ColumnSet(true),
+ TopCount = 1
+ };
+ query.Criteria.AddCondition(
+ ConnectionReference.Fields.ConnectionReferenceLogicalName,
+ ConditionOperator.Equal,
+ logicalName);
+ query.Criteria.AddCondition(ConnectionReference.Fields.StateCode, ConditionOperator.Equal, 0);
+
+ var results = await client.RetrieveMultipleAsync(query, cancellationToken);
+
+ if (results.Entities.Count == 0)
+ {
+ return null;
+ }
+
+ return MapToInfo(results.Entities[0]);
+ }
+
+ ///
+ public async Task GetByIdAsync(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ await using var client = await _pool.GetClientAsync(cancellationToken: cancellationToken);
+
+ try
+ {
+ var entity = await client.RetrieveAsync(
+ ConnectionReference.EntityLogicalName,
+ id,
+ new ColumnSet(true),
+ cancellationToken);
+
+ return MapToInfo(entity);
+ }
+ catch (Exception ex) when (ex.Message.Contains("does not exist"))
+ {
+ return null;
+ }
+ }
+
+ ///
+ public async Task> GetFlowsUsingAsync(
+ string logicalName,
+ CancellationToken cancellationToken = default)
+ {
+ // Get all flows and filter by those referencing this connection reference
+ var allFlows = await _flowService.ListAsync(cancellationToken: cancellationToken);
+
+ // Case-insensitive comparison for connection reference names
+ return allFlows
+ .Where(f => f.ConnectionReferenceLogicalNames
+ .Any(cr => string.Equals(cr, logicalName, StringComparison.OrdinalIgnoreCase)))
+ .ToList();
+ }
+
+ ///
+ public async Task AnalyzeAsync(
+ string? solutionName = null,
+ CancellationToken cancellationToken = default)
+ {
+ _logger.LogDebug("Analyzing flow-connection reference relationships");
+
+ // Get all connection references and flows in the solution
+ var connectionRefs = await ListAsync(solutionName, cancellationToken: cancellationToken);
+ var flows = await _flowService.ListAsync(solutionName, cancellationToken: cancellationToken);
+
+ // Build case-insensitive lookup for connection references
+ var crLookup = connectionRefs.ToDictionary(
+ cr => cr.LogicalName,
+ cr => cr,
+ StringComparer.OrdinalIgnoreCase);
+
+ // Track which CRs are used by at least one flow
+ var usedCrLogicalNames = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ var relationships = new List();
+
+ // Process each flow's connection reference dependencies
+ foreach (var flow in flows)
+ {
+ foreach (var crName in flow.ConnectionReferenceLogicalNames)
+ {
+ if (crLookup.TryGetValue(crName, out var cr))
+ {
+ // Valid relationship: flow uses existing CR
+ relationships.Add(new FlowConnectionRelationship
+ {
+ Type = RelationshipType.FlowToConnectionReference,
+ FlowUniqueName = flow.UniqueName,
+ FlowDisplayName = flow.DisplayName,
+ ConnectionReferenceLogicalName = cr.LogicalName,
+ ConnectionReferenceDisplayName = cr.DisplayName,
+ ConnectorId = cr.ConnectorId,
+ IsBound = cr.IsBound
+ });
+ usedCrLogicalNames.Add(cr.LogicalName);
+ }
+ else
+ {
+ // Orphaned flow: references CR that doesn't exist
+ relationships.Add(new FlowConnectionRelationship
+ {
+ Type = RelationshipType.OrphanedFlow,
+ FlowUniqueName = flow.UniqueName,
+ FlowDisplayName = flow.DisplayName,
+ ConnectionReferenceLogicalName = crName, // The missing CR name
+ ConnectionReferenceDisplayName = null,
+ ConnectorId = null,
+ IsBound = null
+ });
+ }
+ }
+ }
+
+ // Find orphaned connection references (exist but not used by any flow)
+ foreach (var cr in connectionRefs)
+ {
+ if (!usedCrLogicalNames.Contains(cr.LogicalName))
+ {
+ relationships.Add(new FlowConnectionRelationship
+ {
+ Type = RelationshipType.OrphanedConnectionReference,
+ FlowUniqueName = null,
+ FlowDisplayName = null,
+ ConnectionReferenceLogicalName = cr.LogicalName,
+ ConnectionReferenceDisplayName = cr.DisplayName,
+ ConnectorId = cr.ConnectorId,
+ IsBound = cr.IsBound
+ });
+ }
+ }
+
+ var analysis = new FlowConnectionAnalysis { Relationships = relationships };
+
+ _logger.LogDebug(
+ "Analysis complete: {Valid} valid, {OrphanedFlows} orphaned flows, {OrphanedCRs} orphaned CRs",
+ analysis.ValidCount,
+ analysis.OrphanedFlowCount,
+ analysis.OrphanedConnectionReferenceCount);
+
+ return analysis;
+ }
+
+ private static ConnectionReferenceInfo MapToInfo(Entity entity)
+ {
+ return new ConnectionReferenceInfo
+ {
+ Id = entity.Id,
+ LogicalName = entity.GetAttributeValue(ConnectionReference.Fields.ConnectionReferenceLogicalName) ?? string.Empty,
+ DisplayName = entity.GetAttributeValue(ConnectionReference.Fields.ConnectionReferenceDisplayName),
+ Description = entity.GetAttributeValue(ConnectionReference.Fields.Description),
+ ConnectionId = entity.GetAttributeValue(ConnectionReference.Fields.ConnectionId),
+ ConnectorId = entity.GetAttributeValue(ConnectionReference.Fields.ConnectorId),
+ IsManaged = entity.GetAttributeValue(ConnectionReference.Fields.IsManaged),
+ CreatedOn = entity.GetAttributeValue(ConnectionReference.Fields.CreatedOn),
+ ModifiedOn = entity.GetAttributeValue(ConnectionReference.Fields.ModifiedOn)
+ };
+ }
+}
diff --git a/src/PPDS.Dataverse/Services/DeploymentSettingsService.cs b/src/PPDS.Dataverse/Services/DeploymentSettingsService.cs
new file mode 100644
index 00000000..a92d87f5
--- /dev/null
+++ b/src/PPDS.Dataverse/Services/DeploymentSettingsService.cs
@@ -0,0 +1,314 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace PPDS.Dataverse.Services;
+
+///
+/// Service for deployment settings file operations.
+///
+public class DeploymentSettingsService : IDeploymentSettingsService
+{
+ private readonly IEnvironmentVariableService _envVarService;
+ private readonly IConnectionReferenceService _connectionRefService;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public DeploymentSettingsService(
+ IEnvironmentVariableService envVarService,
+ IConnectionReferenceService connectionRefService,
+ ILogger logger)
+ {
+ _envVarService = envVarService ?? throw new ArgumentNullException(nameof(envVarService));
+ _connectionRefService = connectionRefService ?? throw new ArgumentNullException(nameof(connectionRefService));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ public async Task GenerateAsync(
+ string solutionName,
+ CancellationToken cancellationToken = default)
+ {
+ _logger.LogDebug("Generating deployment settings for solution '{Solution}'", solutionName);
+
+ // Get current environment variables and connection references from the solution
+ var envVars = await _envVarService.ListAsync(solutionName, cancellationToken);
+ var connectionRefs = await _connectionRefService.ListAsync(solutionName, cancellationToken: cancellationToken);
+
+ var settings = new DeploymentSettingsFile
+ {
+ EnvironmentVariables = envVars
+ .Where(ev => ev.Type != "Secret") // Don't include secrets in deployment settings
+ .Select(ev => new EnvironmentVariableEntry
+ {
+ SchemaName = ev.SchemaName,
+ Value = ev.CurrentValue ?? ev.DefaultValue ?? string.Empty
+ })
+ .OrderBy(ev => ev.SchemaName, StringComparer.Ordinal)
+ .ToList(),
+
+ ConnectionReferences = connectionRefs
+ .Select(cr => new ConnectionReferenceEntry
+ {
+ LogicalName = cr.LogicalName,
+ ConnectionId = cr.ConnectionId ?? string.Empty,
+ ConnectorId = cr.ConnectorId ?? string.Empty
+ })
+ .OrderBy(cr => cr.LogicalName, StringComparer.Ordinal)
+ .ToList()
+ };
+
+ _logger.LogDebug(
+ "Generated settings: {EnvVarCount} environment variables, {CRCount} connection references",
+ settings.EnvironmentVariables.Count,
+ settings.ConnectionReferences.Count);
+
+ return settings;
+ }
+
+ ///
+ public async Task SyncAsync(
+ string solutionName,
+ DeploymentSettingsFile? existingSettings,
+ CancellationToken cancellationToken = default)
+ {
+ _logger.LogDebug("Syncing deployment settings for solution '{Solution}'", solutionName);
+
+ // Get current state from environment
+ var currentEnvVars = await _envVarService.ListAsync(solutionName, cancellationToken);
+ var currentConnectionRefs = await _connectionRefService.ListAsync(solutionName, cancellationToken: cancellationToken);
+
+ // Build current schema names sets
+ var currentEnvVarNames = currentEnvVars
+ .Where(ev => ev.Type != "Secret")
+ .Select(ev => ev.SchemaName)
+ .ToHashSet(StringComparer.Ordinal);
+
+ var currentCRNames = currentConnectionRefs
+ .Select(cr => cr.LogicalName)
+ .ToHashSet(StringComparer.Ordinal);
+
+ // Process environment variables
+ var existingEnvVars = existingSettings?.EnvironmentVariables
+ .ToDictionary(ev => ev.SchemaName, ev => ev, StringComparer.Ordinal)
+ ?? new Dictionary(StringComparer.Ordinal);
+
+ var syncedEnvVars = new List();
+ var evStats = new SyncStatisticsBuilder();
+
+ foreach (var ev in currentEnvVars.Where(ev => ev.Type != "Secret"))
+ {
+ if (existingEnvVars.TryGetValue(ev.SchemaName, out var existing))
+ {
+ // Preserve existing value
+ syncedEnvVars.Add(new EnvironmentVariableEntry
+ {
+ SchemaName = ev.SchemaName,
+ Value = existing.Value
+ });
+ evStats.Preserved++;
+ }
+ else
+ {
+ // New entry - use current environment value
+ syncedEnvVars.Add(new EnvironmentVariableEntry
+ {
+ SchemaName = ev.SchemaName,
+ Value = ev.CurrentValue ?? ev.DefaultValue ?? string.Empty
+ });
+ evStats.Added++;
+ }
+ }
+
+ // Count removed entries
+ evStats.Removed = existingEnvVars.Keys.Count(name => !currentEnvVarNames.Contains(name));
+
+ // Process connection references
+ var existingCRs = existingSettings?.ConnectionReferences
+ .ToDictionary(cr => cr.LogicalName, cr => cr, StringComparer.Ordinal)
+ ?? new Dictionary(StringComparer.Ordinal);
+
+ var syncedCRs = new List();
+ var crStats = new SyncStatisticsBuilder();
+
+ foreach (var cr in currentConnectionRefs)
+ {
+ if (existingCRs.TryGetValue(cr.LogicalName, out var existing))
+ {
+ // Preserve existing values
+ syncedCRs.Add(new ConnectionReferenceEntry
+ {
+ LogicalName = cr.LogicalName,
+ ConnectionId = existing.ConnectionId,
+ ConnectorId = existing.ConnectorId
+ });
+ crStats.Preserved++;
+ }
+ else
+ {
+ // New entry - use current environment value
+ syncedCRs.Add(new ConnectionReferenceEntry
+ {
+ LogicalName = cr.LogicalName,
+ ConnectionId = cr.ConnectionId ?? string.Empty,
+ ConnectorId = cr.ConnectorId ?? string.Empty
+ });
+ crStats.Added++;
+ }
+ }
+
+ // Count removed entries
+ crStats.Removed = existingCRs.Keys.Count(name => !currentCRNames.Contains(name));
+
+ // Sort for deterministic output
+ var settings = new DeploymentSettingsFile
+ {
+ EnvironmentVariables = syncedEnvVars
+ .OrderBy(ev => ev.SchemaName, StringComparer.Ordinal)
+ .ToList(),
+ ConnectionReferences = syncedCRs
+ .OrderBy(cr => cr.LogicalName, StringComparer.Ordinal)
+ .ToList()
+ };
+
+ _logger.LogDebug(
+ "Sync complete - EnvVars: +{EVAdded} -{EVRemoved} ={EVPreserved}, CRs: +{CRAdded} -{CRRemoved} ={CRPreserved}",
+ evStats.Added, evStats.Removed, evStats.Preserved,
+ crStats.Added, crStats.Removed, crStats.Preserved);
+
+ return new DeploymentSettingsSyncResult
+ {
+ Settings = settings,
+ EnvironmentVariables = evStats.Build(),
+ ConnectionReferences = crStats.Build()
+ };
+ }
+
+ ///
+ public async Task ValidateAsync(
+ string solutionName,
+ DeploymentSettingsFile settings,
+ CancellationToken cancellationToken = default)
+ {
+ _logger.LogDebug("Validating deployment settings for solution '{Solution}'", solutionName);
+
+ var issues = new List();
+
+ // Get current state from environment
+ var currentEnvVars = await _envVarService.ListAsync(solutionName, cancellationToken);
+ var currentConnectionRefs = await _connectionRefService.ListAsync(solutionName, cancellationToken: cancellationToken);
+
+ var currentEnvVarNames = currentEnvVars
+ .ToDictionary(ev => ev.SchemaName, ev => ev, StringComparer.Ordinal);
+ var currentCRNames = currentConnectionRefs
+ .ToDictionary(cr => cr.LogicalName, cr => cr, StringComparer.Ordinal);
+
+ // Validate environment variables
+ foreach (var ev in settings.EnvironmentVariables)
+ {
+ if (!currentEnvVarNames.TryGetValue(ev.SchemaName, out var current))
+ {
+ issues.Add(new ValidationIssue
+ {
+ Severity = ValidationSeverity.Warning,
+ EntryType = "EnvironmentVariable",
+ Name = ev.SchemaName,
+ Message = "Not found in solution - entry will be ignored during import"
+ });
+ }
+ else if (string.IsNullOrEmpty(ev.Value) && current.IsRequired)
+ {
+ issues.Add(new ValidationIssue
+ {
+ Severity = ValidationSeverity.Error,
+ EntryType = "EnvironmentVariable",
+ Name = ev.SchemaName,
+ Message = "Required environment variable has empty value"
+ });
+ }
+ }
+
+ // Check for missing required environment variables
+ foreach (var ev in currentEnvVars.Where(ev => ev.IsRequired && ev.Type != "Secret"))
+ {
+ if (!settings.EnvironmentVariables.Any(e =>
+ string.Equals(e.SchemaName, ev.SchemaName, StringComparison.Ordinal)))
+ {
+ issues.Add(new ValidationIssue
+ {
+ Severity = ValidationSeverity.Error,
+ EntryType = "EnvironmentVariable",
+ Name = ev.SchemaName,
+ Message = "Required environment variable missing from settings file"
+ });
+ }
+ }
+
+ // Validate connection references
+ foreach (var cr in settings.ConnectionReferences)
+ {
+ if (!currentCRNames.ContainsKey(cr.LogicalName))
+ {
+ issues.Add(new ValidationIssue
+ {
+ Severity = ValidationSeverity.Warning,
+ EntryType = "ConnectionReference",
+ Name = cr.LogicalName,
+ Message = "Not found in solution - entry will be ignored during import"
+ });
+ }
+ else if (string.IsNullOrEmpty(cr.ConnectionId))
+ {
+ issues.Add(new ValidationIssue
+ {
+ Severity = ValidationSeverity.Warning,
+ EntryType = "ConnectionReference",
+ Name = cr.LogicalName,
+ Message = "Missing ConnectionId - will prompt during import"
+ });
+ }
+ }
+
+ // Check for missing connection references
+ foreach (var cr in currentConnectionRefs)
+ {
+ if (!settings.ConnectionReferences.Any(c =>
+ string.Equals(c.LogicalName, cr.LogicalName, StringComparison.Ordinal)))
+ {
+ issues.Add(new ValidationIssue
+ {
+ Severity = ValidationSeverity.Warning,
+ EntryType = "ConnectionReference",
+ Name = cr.LogicalName,
+ Message = "Connection reference missing from settings file - will prompt during import"
+ });
+ }
+ }
+
+ _logger.LogDebug("Validation complete: {IssueCount} issues found", issues.Count);
+
+ return new DeploymentSettingsValidation { Issues = issues };
+ }
+
+ ///
+ /// Helper to build sync statistics.
+ ///
+ private sealed class SyncStatisticsBuilder
+ {
+ public int Added { get; set; }
+ public int Removed { get; set; }
+ public int Preserved { get; set; }
+
+ public SyncStatistics Build() => new()
+ {
+ Added = Added,
+ Removed = Removed,
+ Preserved = Preserved
+ };
+ }
+}
diff --git a/src/PPDS.Dataverse/Services/FlowService.cs b/src/PPDS.Dataverse/Services/FlowService.cs
new file mode 100644
index 00000000..6d73538e
--- /dev/null
+++ b/src/PPDS.Dataverse/Services/FlowService.cs
@@ -0,0 +1,189 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Xrm.Sdk;
+using Microsoft.Xrm.Sdk.Query;
+using PPDS.Dataverse.Generated;
+using PPDS.Dataverse.Pooling;
+using PPDS.Dataverse.Services.Utilities;
+
+namespace PPDS.Dataverse.Services;
+
+///
+/// Service for cloud flow (Power Automate) operations.
+///
+public class FlowService : IFlowService
+{
+ private readonly IDataverseConnectionPool _pool;
+ private readonly ILogger _logger;
+
+ // Category values for cloud flows
+ private const int ModernFlowCategory = 5;
+ private const int DesktopFlowCategory = 6;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The connection pool.
+ /// The logger.
+ public FlowService(
+ IDataverseConnectionPool pool,
+ ILogger logger)
+ {
+ _pool = pool ?? throw new ArgumentNullException(nameof(pool));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ public async Task> ListAsync(
+ string? solutionName = null,
+ FlowState? state = null,
+ CancellationToken cancellationToken = default)
+ {
+ await using var client = await _pool.GetClientAsync(cancellationToken: cancellationToken);
+
+ var query = new QueryExpression(Workflow.EntityLogicalName)
+ {
+ ColumnSet = new ColumnSet(
+ Workflow.Fields.WorkflowId,
+ Workflow.Fields.UniqueName,
+ Workflow.Fields.Name,
+ Workflow.Fields.Description,
+ Workflow.Fields.StateCode,
+ Workflow.Fields.Category,
+ Workflow.Fields.IsManaged,
+ Workflow.Fields.ClientData,
+ Workflow.Fields.CreatedOn,
+ Workflow.Fields.ModifiedOn,
+ Workflow.Fields.OwnerId),
+ Orders = { new OrderExpression(Workflow.Fields.Name, OrderType.Ascending) }
+ };
+
+ // Filter to only cloud flows (ModernFlow=5 or DesktopFlow=6)
+ var categoryFilter = new FilterExpression(LogicalOperator.Or);
+ categoryFilter.AddCondition(Workflow.Fields.Category, ConditionOperator.Equal, ModernFlowCategory);
+ categoryFilter.AddCondition(Workflow.Fields.Category, ConditionOperator.Equal, DesktopFlowCategory);
+ query.Criteria.AddFilter(categoryFilter);
+
+ // Filter by state if specified
+ if (state.HasValue)
+ {
+ query.Criteria.AddCondition(Workflow.Fields.StateCode, ConditionOperator.Equal, (int)state.Value);
+ }
+
+ // Filter by solution if specified
+ if (!string.IsNullOrEmpty(solutionName))
+ {
+ var solutionLink = query.AddLink(
+ SolutionComponent.EntityLogicalName,
+ Workflow.Fields.WorkflowId,
+ SolutionComponent.Fields.ObjectId);
+ solutionLink.EntityAlias = "sc";
+
+ var solutionLink2 = solutionLink.AddLink(
+ Solution.EntityLogicalName,
+ SolutionComponent.Fields.SolutionId,
+ Solution.Fields.SolutionId);
+ solutionLink2.EntityAlias = "sol";
+ solutionLink2.LinkCriteria.AddCondition(
+ Solution.Fields.UniqueName, ConditionOperator.Equal, solutionName);
+ }
+
+ _logger.LogDebug("Querying cloud flows");
+ var results = await client.RetrieveMultipleAsync(query, cancellationToken);
+
+ var flows = results.Entities.Select(MapToFlowInfo).ToList();
+ _logger.LogDebug("Found {Count} cloud flows", flows.Count);
+
+ return flows;
+ }
+
+ ///
+ public async Task GetAsync(
+ string uniqueName,
+ CancellationToken cancellationToken = default)
+ {
+ await using var client = await _pool.GetClientAsync(cancellationToken: cancellationToken);
+
+ var query = new QueryExpression(Workflow.EntityLogicalName)
+ {
+ ColumnSet = new ColumnSet(true),
+ TopCount = 1
+ };
+
+ // Filter by unique name
+ query.Criteria.AddCondition(Workflow.Fields.UniqueName, ConditionOperator.Equal, uniqueName);
+
+ // Filter to only cloud flows
+ var categoryFilter = new FilterExpression(LogicalOperator.Or);
+ categoryFilter.AddCondition(Workflow.Fields.Category, ConditionOperator.Equal, ModernFlowCategory);
+ categoryFilter.AddCondition(Workflow.Fields.Category, ConditionOperator.Equal, DesktopFlowCategory);
+ query.Criteria.AddFilter(categoryFilter);
+
+ var results = await client.RetrieveMultipleAsync(query, cancellationToken);
+
+ if (results.Entities.Count == 0)
+ {
+ return null;
+ }
+
+ return MapToFlowInfo(results.Entities[0]);
+ }
+
+ ///
+ public async Task GetByIdAsync(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ await using var client = await _pool.GetClientAsync(cancellationToken: cancellationToken);
+
+ try
+ {
+ var entity = await client.RetrieveAsync(
+ Workflow.EntityLogicalName,
+ id,
+ new ColumnSet(true),
+ cancellationToken);
+
+ // Verify it's a cloud flow
+ var category = entity.GetAttributeValue(Workflow.Fields.Category)?.Value;
+ if (category != ModernFlowCategory && category != DesktopFlowCategory)
+ {
+ return null;
+ }
+
+ return MapToFlowInfo(entity);
+ }
+ catch (Exception ex) when (ex.Message.Contains("does not exist"))
+ {
+ return null;
+ }
+ }
+
+ private static FlowInfo MapToFlowInfo(Entity entity)
+ {
+ var stateValue = entity.GetAttributeValue(Workflow.Fields.StateCode)?.Value ?? 0;
+ var categoryValue = entity.GetAttributeValue(Workflow.Fields.Category)?.Value ?? ModernFlowCategory;
+ var clientData = entity.GetAttributeValue(Workflow.Fields.ClientData);
+ var ownerRef = entity.GetAttributeValue(Workflow.Fields.OwnerId);
+
+ return new FlowInfo
+ {
+ Id = entity.Id,
+ UniqueName = entity.GetAttributeValue(Workflow.Fields.UniqueName) ?? string.Empty,
+ DisplayName = entity.GetAttributeValue(Workflow.Fields.Name),
+ Description = entity.GetAttributeValue(Workflow.Fields.Description),
+ State = (FlowState)stateValue,
+ Category = (FlowCategory)categoryValue,
+ IsManaged = entity.GetAttributeValue(Workflow.Fields.IsManaged),
+ ConnectionReferenceLogicalNames = FlowClientDataParser.ExtractConnectionReferenceLogicalNames(clientData),
+ CreatedOn = entity.GetAttributeValue(Workflow.Fields.CreatedOn),
+ ModifiedOn = entity.GetAttributeValue(Workflow.Fields.ModifiedOn),
+ OwnerId = ownerRef?.Id,
+ OwnerName = ownerRef?.Name
+ };
+ }
+}
diff --git a/src/PPDS.Dataverse/Services/IConnectionReferenceService.cs b/src/PPDS.Dataverse/Services/IConnectionReferenceService.cs
new file mode 100644
index 00000000..063cf88f
--- /dev/null
+++ b/src/PPDS.Dataverse/Services/IConnectionReferenceService.cs
@@ -0,0 +1,169 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PPDS.Dataverse.Services;
+
+///
+/// Service for connection reference operations in Dataverse.
+///
+///
+/// Connection references are Dataverse entities that reference Power Platform connections.
+/// This service provides CRUD operations and relationship analysis with flows.
+///
+public interface IConnectionReferenceService
+{
+ ///
+ /// Lists connection references.
+ ///
+ /// Optional solution filter (unique name).
+ /// If true, only return connection references without a bound connection.
+ /// Cancellation token.
+ /// List of connection references.
+ Task> ListAsync(
+ string? solutionName = null,
+ bool unboundOnly = false,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets a specific connection reference by logical name.
+ ///
+ /// The connection reference logical name.
+ /// Cancellation token.
+ /// The connection reference info, or null if not found.
+ Task GetAsync(
+ string logicalName,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets a specific connection reference by ID.
+ ///
+ /// The connection reference ID.
+ /// Cancellation token.
+ /// The connection reference info, or null if not found.
+ Task GetByIdAsync(
+ Guid id,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets all flows that use a specific connection reference.
+ ///
+ /// The connection reference logical name.
+ /// Cancellation token.
+ /// List of flows that reference this connection reference.
+ Task> GetFlowsUsingAsync(
+ string logicalName,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Analyzes flow-connection reference relationships within a solution.
+ /// Detects orphaned flows (referencing missing CRs) and orphaned CRs (not used by any flow).
+ ///
+ /// Optional solution filter (unique name).
+ /// Cancellation token.
+ /// List of relationships including orphan detection.
+ Task AnalyzeAsync(
+ string? solutionName = null,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Connection reference information from Dataverse.
+///
+public sealed record ConnectionReferenceInfo
+{
+ /// Gets the connection reference ID.
+ public required Guid Id { get; init; }
+
+ /// Gets the logical name (schema name).
+ public required string LogicalName { get; init; }
+
+ /// Gets the display name.
+ public string? DisplayName { get; init; }
+
+ /// Gets the description.
+ public string? Description { get; init; }
+
+ /// Gets the bound connection ID (from Power Apps API).
+ public string? ConnectionId { get; init; }
+
+ /// Gets the connector ID.
+ public string? ConnectorId { get; init; }
+
+ /// Gets whether this is a managed component.
+ public bool IsManaged { get; init; }
+
+ /// Gets whether a connection is bound (ConnectionId is set).
+ public bool IsBound => !string.IsNullOrEmpty(ConnectionId);
+
+ /// Gets the created date.
+ public DateTime? CreatedOn { get; init; }
+
+ /// Gets the modified date.
+ public DateTime? ModifiedOn { get; init; }
+}
+
+///
+/// Result of analyzing flow-connection reference relationships.
+///
+public sealed record FlowConnectionAnalysis
+{
+ /// Gets all relationship entries.
+ public required List Relationships { get; init; }
+
+ /// Gets the count of valid flow-to-CR relationships.
+ public int ValidCount => Relationships.Count(r => r.Type == RelationshipType.FlowToConnectionReference);
+
+ /// Gets the count of orphaned flows (referencing missing CRs).
+ public int OrphanedFlowCount => Relationships.Count(r => r.Type == RelationshipType.OrphanedFlow);
+
+ /// Gets the count of orphaned CRs (not used by any flow).
+ public int OrphanedConnectionReferenceCount => Relationships.Count(r => r.Type == RelationshipType.OrphanedConnectionReference);
+
+ /// Gets whether any orphans were detected.
+ public bool HasOrphans => OrphanedFlowCount > 0 || OrphanedConnectionReferenceCount > 0;
+}
+
+///
+/// Represents a relationship between a flow and a connection reference.
+///
+public sealed record FlowConnectionRelationship
+{
+ /// Gets the relationship type.
+ public required RelationshipType Type { get; init; }
+
+ /// Gets the flow unique name (null for OrphanedConnectionReference).
+ public string? FlowUniqueName { get; init; }
+
+ /// Gets the flow display name.
+ public string? FlowDisplayName { get; init; }
+
+ /// Gets the connection reference logical name (null for OrphanedFlow).
+ public string? ConnectionReferenceLogicalName { get; init; }
+
+ /// Gets the connection reference display name.
+ public string? ConnectionReferenceDisplayName { get; init; }
+
+ /// Gets the connector ID.
+ public string? ConnectorId { get; init; }
+
+ /// Gets whether the connection reference is bound to a connection.
+ public bool? IsBound { get; init; }
+}
+
+///
+/// Types of flow-connection reference relationships.
+///
+public enum RelationshipType
+{
+ /// Valid relationship: flow uses an existing connection reference.
+ FlowToConnectionReference,
+
+ /// Orphaned flow: references a connection reference that doesn't exist.
+ OrphanedFlow,
+
+ /// Orphaned connection reference: exists but not used by any flow.
+ OrphanedConnectionReference
+}
diff --git a/src/PPDS.Dataverse/Services/IDeploymentSettingsService.cs b/src/PPDS.Dataverse/Services/IDeploymentSettingsService.cs
new file mode 100644
index 00000000..80d277ce
--- /dev/null
+++ b/src/PPDS.Dataverse/Services/IDeploymentSettingsService.cs
@@ -0,0 +1,175 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PPDS.Dataverse.Services;
+
+///
+/// Service for deployment settings file operations.
+///
+///
+/// Deployment settings files configure environment-specific values (environment variables,
+/// connection references) for solution deployment. This service generates, syncs, and validates
+/// these files in the PAC-compatible format.
+///
+public interface IDeploymentSettingsService
+{
+ ///
+ /// Generates a new deployment settings file from the current environment.
+ ///
+ /// The solution unique name.
+ /// Cancellation token.
+ /// The generated deployment settings.
+ Task GenerateAsync(
+ string solutionName,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Syncs an existing deployment settings file with the current solution.
+ /// Preserves existing values, adds new entries, removes obsolete entries.
+ ///
+ /// The solution unique name.
+ /// The existing settings to sync (null if new file).
+ /// Cancellation token.
+ /// The synced settings and statistics.
+ Task SyncAsync(
+ string solutionName,
+ DeploymentSettingsFile? existingSettings,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Validates a deployment settings file against the current solution.
+ ///
+ /// The solution unique name.
+ /// The settings file to validate.
+ /// Cancellation token.
+ /// Validation result with any issues found.
+ Task ValidateAsync(
+ string solutionName,
+ DeploymentSettingsFile settings,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// PAC-compatible deployment settings file format.
+///
+///
+/// This format is compatible with `pac solution import --settings-file`.
+/// Entries are sorted by schema name (StringComparison.Ordinal) for deterministic output.
+///
+public sealed class DeploymentSettingsFile
+{
+ /// Gets or sets the environment variables.
+ [JsonPropertyName("EnvironmentVariables")]
+ public List EnvironmentVariables { get; set; } = new();
+
+ /// Gets or sets the connection references.
+ [JsonPropertyName("ConnectionReferences")]
+ public List ConnectionReferences { get; set; } = new();
+}
+
+///
+/// Environment variable entry for deployment settings.
+///
+public sealed class EnvironmentVariableEntry
+{
+ /// Gets or sets the schema name.
+ [JsonPropertyName("SchemaName")]
+ public string SchemaName { get; set; } = string.Empty;
+
+ /// Gets or sets the value.
+ [JsonPropertyName("Value")]
+ public string Value { get; set; } = string.Empty;
+}
+
+///
+/// Connection reference entry for deployment settings.
+///
+public sealed class ConnectionReferenceEntry
+{
+ /// Gets or sets the logical name.
+ [JsonPropertyName("LogicalName")]
+ public string LogicalName { get; set; } = string.Empty;
+
+ /// Gets or sets the connection ID.
+ [JsonPropertyName("ConnectionId")]
+ public string ConnectionId { get; set; } = string.Empty;
+
+ /// Gets or sets the connector ID.
+ [JsonPropertyName("ConnectorId")]
+ public string ConnectorId { get; set; } = string.Empty;
+}
+
+///
+/// Result of syncing deployment settings.
+///
+public sealed class DeploymentSettingsSyncResult
+{
+ /// Gets the synced settings file.
+ public required DeploymentSettingsFile Settings { get; init; }
+
+ /// Gets the environment variable sync statistics.
+ public required SyncStatistics EnvironmentVariables { get; init; }
+
+ /// Gets the connection reference sync statistics.
+ public required SyncStatistics ConnectionReferences { get; init; }
+}
+
+///
+/// Statistics for a sync operation.
+///
+public sealed class SyncStatistics
+{
+ /// Gets the number of entries added.
+ public int Added { get; init; }
+
+ /// Gets the number of entries removed.
+ public int Removed { get; init; }
+
+ /// Gets the number of entries preserved (values kept from existing file).
+ public int Preserved { get; init; }
+}
+
+///
+/// Result of validating deployment settings.
+///
+public sealed class DeploymentSettingsValidation
+{
+ /// Gets whether the settings are valid.
+ public bool IsValid => Issues.Count == 0;
+
+ /// Gets the validation issues.
+ public List Issues { get; init; } = new();
+}
+
+///
+/// A single validation issue.
+///
+public sealed class ValidationIssue
+{
+ /// Gets the issue severity.
+ public required ValidationSeverity Severity { get; init; }
+
+ /// Gets the entry type (EnvironmentVariable or ConnectionReference).
+ public required string EntryType { get; init; }
+
+ /// Gets the schema/logical name of the entry.
+ public required string Name { get; init; }
+
+ /// Gets the issue message.
+ public required string Message { get; init; }
+}
+
+///
+/// Validation issue severity.
+///
+public enum ValidationSeverity
+{
+ /// Warning - deployment may work but review recommended.
+ Warning,
+
+ /// Error - deployment will likely fail.
+ Error
+}
diff --git a/src/PPDS.Dataverse/Services/IFlowService.cs b/src/PPDS.Dataverse/Services/IFlowService.cs
new file mode 100644
index 00000000..d7c3e508
--- /dev/null
+++ b/src/PPDS.Dataverse/Services/IFlowService.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PPDS.Dataverse.Services;
+
+///
+/// Service for cloud flow (Power Automate) operations.
+///
+public interface IFlowService
+{
+ ///
+ /// Lists cloud flows (Modern Flows and Desktop Flows).
+ ///
+ /// Optional solution filter (unique name).
+ /// Optional state filter.
+ /// Cancellation token.
+ /// List of cloud flows.
+ Task> ListAsync(
+ string? solutionName = null,
+ FlowState? state = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets a specific flow by unique name.
+ ///
+ /// The unique name of the flow.
+ /// Cancellation token.
+ /// The flow info, or null if not found.
+ Task GetAsync(
+ string uniqueName,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets a specific flow by ID.
+ ///
+ /// The workflow ID.
+ /// Cancellation token.
+ /// The flow info, or null if not found.
+ Task GetByIdAsync(
+ Guid id,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Cloud flow information.
+///
+public sealed record FlowInfo
+{
+ /// Gets the workflow ID.
+ public required Guid Id { get; init; }
+
+ /// Gets the unique name (schema name).
+ public required string UniqueName { get; init; }
+
+ /// Gets the display name.
+ public string? DisplayName { get; init; }
+
+ /// Gets the description.
+ public string? Description { get; init; }
+
+ /// Gets the flow state (Draft, Activated, Suspended).
+ public required FlowState State { get; init; }
+
+ /// Gets the flow category.
+ public required FlowCategory Category { get; init; }
+
+ /// Gets whether the flow is managed.
+ public bool IsManaged { get; init; }
+
+ ///
+ /// Gets the connection reference logical names extracted from client data.
+ /// These are the connection references that the flow depends on.
+ ///
+ public required List ConnectionReferenceLogicalNames { get; init; }
+
+ /// Gets the created date.
+ public DateTime? CreatedOn { get; init; }
+
+ /// Gets the modified date.
+ public DateTime? ModifiedOn { get; init; }
+
+ /// Gets the owner ID.
+ public Guid? OwnerId { get; init; }
+
+ /// Gets the owner name.
+ public string? OwnerName { get; init; }
+}
+
+///
+/// Cloud flow state.
+///
+public enum FlowState
+{
+ /// Flow is in draft/design mode.
+ Draft = 0,
+
+ /// Flow is active and running.
+ Activated = 1,
+
+ /// Flow has been suspended.
+ Suspended = 2
+}
+
+///
+/// Cloud flow category. Only cloud flow types are exposed.
+///
+public enum FlowCategory
+{
+ /// Power Automate cloud flow.
+ ModernFlow = 5,
+
+ /// Power Automate desktop flow.
+ DesktopFlow = 6
+}
diff --git a/src/PPDS.Dataverse/Services/Utilities/FlowClientDataParser.cs b/src/PPDS.Dataverse/Services/Utilities/FlowClientDataParser.cs
new file mode 100644
index 00000000..f5f8ebfa
--- /dev/null
+++ b/src/PPDS.Dataverse/Services/Utilities/FlowClientDataParser.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+
+namespace PPDS.Dataverse.Services.Utilities;
+
+///
+/// Parses flow client data to extract connection reference logical names.
+///
+public static class FlowClientDataParser
+{
+ ///
+ /// Extracts connection reference logical names from flow client data JSON.
+ ///
+ /// The raw client data JSON from the workflow entity.
+ /// List of connection reference logical names (case-preserved).
+ ///
+ /// Client data structure varies but connection references typically appear in:
+ /// - properties.connectionReferences (object with CR logical names as keys)
+ /// - definition.connectionReferences (alternative location)
+ ///
+ public static List ExtractConnectionReferenceLogicalNames(string? clientData)
+ {
+ var result = new List();
+
+ if (string.IsNullOrWhiteSpace(clientData))
+ {
+ return result;
+ }
+
+ try
+ {
+ using var doc = JsonDocument.Parse(clientData);
+ var root = doc.RootElement;
+
+ // Try properties.connectionReferences first
+ if (root.TryGetProperty("properties", out var properties) &&
+ properties.TryGetProperty("connectionReferences", out var connectionRefs))
+ {
+ ExtractKeysFromObject(connectionRefs, result);
+ }
+ // Try top-level connectionReferences
+ else if (root.TryGetProperty("connectionReferences", out var topLevelRefs))
+ {
+ ExtractKeysFromObject(topLevelRefs, result);
+ }
+ // Try definition.connectionReferences
+ else if (root.TryGetProperty("definition", out var definition) &&
+ definition.TryGetProperty("connectionReferences", out var defRefs))
+ {
+ ExtractKeysFromObject(defRefs, result);
+ }
+ }
+ catch (JsonException)
+ {
+ // Invalid JSON - return empty list
+ }
+
+ return result;
+ }
+
+ ///
+ /// Extracts all keys from a JSON object (connection reference logical names).
+ ///
+ private static void ExtractKeysFromObject(JsonElement element, List keys)
+ {
+ if (element.ValueKind != JsonValueKind.Object)
+ {
+ return;
+ }
+
+ foreach (var property in element.EnumerateObject())
+ {
+ if (!string.IsNullOrEmpty(property.Name))
+ {
+ keys.Add(property.Name);
+ }
+ }
+ }
+}
diff --git a/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/ConnectionReferencesCommandGroupTests.cs b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/ConnectionReferencesCommandGroupTests.cs
new file mode 100644
index 00000000..267aeac2
--- /dev/null
+++ b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/ConnectionReferencesCommandGroupTests.cs
@@ -0,0 +1,168 @@
+using System.CommandLine;
+using PPDS.Cli.Commands.ConnectionReferences;
+using Xunit;
+
+namespace PPDS.Cli.Tests.Commands.ConnectionReferences;
+
+public class ConnectionReferencesCommandGroupTests
+{
+ private readonly Command _command;
+
+ public ConnectionReferencesCommandGroupTests()
+ {
+ _command = ConnectionReferencesCommandGroup.Create();
+ }
+
+ #region Command Structure Tests
+
+ [Fact]
+ public void Create_ReturnsCommandWithCorrectName()
+ {
+ Assert.Equal("connectionreferences", _command.Name);
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithDescription()
+ {
+ Assert.Contains("connection reference", _command.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasListSubcommand()
+ {
+ var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "list");
+ Assert.NotNull(subcommand);
+ }
+
+ [Fact]
+ public void Create_HasGetSubcommand()
+ {
+ var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "get");
+ Assert.NotNull(subcommand);
+ }
+
+ [Fact]
+ public void Create_HasFlowsSubcommand()
+ {
+ var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "flows");
+ Assert.NotNull(subcommand);
+ }
+
+ [Fact]
+ public void Create_HasConnectionsSubcommand()
+ {
+ var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "connections");
+ Assert.NotNull(subcommand);
+ }
+
+ [Fact]
+ public void Create_HasAnalyzeSubcommand()
+ {
+ var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "analyze");
+ Assert.NotNull(subcommand);
+ }
+
+ [Fact]
+ public void Create_HasFiveSubcommands()
+ {
+ Assert.Equal(5, _command.Subcommands.Count);
+ }
+
+ #endregion
+
+ #region List Subcommand Tests
+
+ [Fact]
+ public void ListSubcommand_HasSolutionOption()
+ {
+ var listCommand = _command.Subcommands.First(c => c.Name == "list");
+ var option = listCommand.Options.FirstOrDefault(o => o.Name == "--solution");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void ListSubcommand_HasUnboundOption()
+ {
+ var listCommand = _command.Subcommands.First(c => c.Name == "list");
+ var option = listCommand.Options.FirstOrDefault(o => o.Name == "--unbound");
+ Assert.NotNull(option);
+ }
+
+ #endregion
+
+ #region Get Subcommand Tests
+
+ [Fact]
+ public void GetSubcommand_HasNameArgument()
+ {
+ var getCommand = _command.Subcommands.First(c => c.Name == "get");
+ Assert.Single(getCommand.Arguments);
+ Assert.Equal("name", getCommand.Arguments[0].Name);
+ }
+
+ #endregion
+
+ #region Flows Subcommand Tests
+
+ [Fact]
+ public void FlowsSubcommand_HasNameArgument()
+ {
+ var flowsCommand = _command.Subcommands.First(c => c.Name == "flows");
+ Assert.Single(flowsCommand.Arguments);
+ Assert.Equal("name", flowsCommand.Arguments[0].Name);
+ }
+
+ #endregion
+
+ #region Connections Subcommand Tests
+
+ [Fact]
+ public void ConnectionsSubcommand_HasNameArgument()
+ {
+ var connectionsCommand = _command.Subcommands.First(c => c.Name == "connections");
+ Assert.Single(connectionsCommand.Arguments);
+ Assert.Equal("name", connectionsCommand.Arguments[0].Name);
+ }
+
+ #endregion
+
+ #region Analyze Subcommand Tests
+
+ [Fact]
+ public void AnalyzeSubcommand_HasSolutionOption()
+ {
+ var analyzeCommand = _command.Subcommands.First(c => c.Name == "analyze");
+ var option = analyzeCommand.Options.FirstOrDefault(o => o.Name == "--solution");
+ Assert.NotNull(option);
+ }
+
+ #endregion
+
+ #region Shared Options Tests
+
+ [Fact]
+ public void ProfileOption_HasCorrectName()
+ {
+ Assert.Equal("--profile", ConnectionReferencesCommandGroup.ProfileOption.Name);
+ }
+
+ [Fact]
+ public void ProfileOption_HasShortAlias()
+ {
+ Assert.Contains("-p", ConnectionReferencesCommandGroup.ProfileOption.Aliases);
+ }
+
+ [Fact]
+ public void EnvironmentOption_HasCorrectName()
+ {
+ Assert.Equal("--environment", ConnectionReferencesCommandGroup.EnvironmentOption.Name);
+ }
+
+ [Fact]
+ public void EnvironmentOption_HasShortAlias()
+ {
+ Assert.Contains("-e", ConnectionReferencesCommandGroup.EnvironmentOption.Aliases);
+ }
+
+ #endregion
+}
diff --git a/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/ConnectionsCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/ConnectionsCommandTests.cs
new file mode 100644
index 00000000..f13cc12c
--- /dev/null
+++ b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/ConnectionsCommandTests.cs
@@ -0,0 +1,64 @@
+using System.CommandLine;
+using PPDS.Cli.Commands.ConnectionReferences;
+using Xunit;
+
+namespace PPDS.Cli.Tests.Commands.ConnectionReferences;
+
+public class ConnectionsCommandTests
+{
+ private readonly Command _command;
+
+ public ConnectionsCommandTests()
+ {
+ _command = ConnectionsCommand.Create();
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithCorrectName()
+ {
+ Assert.Equal("connections", _command.Name);
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithDescription()
+ {
+ Assert.Contains("connection", _command.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasNameArgument()
+ {
+ Assert.Single(_command.Arguments);
+ Assert.Equal("name", _command.Arguments[0].Name);
+ }
+
+ [Fact]
+ public void Create_NameArgumentHasDescription()
+ {
+ var arg = _command.Arguments[0];
+ Assert.NotNull(arg.Description);
+ Assert.Contains("logical name", arg.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasProfileOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--profile");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasEnvironmentOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--environment");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasGlobalOptions()
+ {
+ // Global options include --output-format, --quiet, --verbose, etc.
+ var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format");
+ Assert.NotNull(formatOption);
+ }
+}
diff --git a/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/FlowsCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/FlowsCommandTests.cs
new file mode 100644
index 00000000..5da10e79
--- /dev/null
+++ b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/FlowsCommandTests.cs
@@ -0,0 +1,63 @@
+using System.CommandLine;
+using PPDS.Cli.Commands.ConnectionReferences;
+using Xunit;
+
+namespace PPDS.Cli.Tests.Commands.ConnectionReferences;
+
+public class FlowsCommandTests
+{
+ private readonly Command _command;
+
+ public FlowsCommandTests()
+ {
+ _command = FlowsCommand.Create();
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithCorrectName()
+ {
+ Assert.Equal("flows", _command.Name);
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithDescription()
+ {
+ Assert.Contains("flow", _command.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasNameArgument()
+ {
+ Assert.Single(_command.Arguments);
+ Assert.Equal("name", _command.Arguments[0].Name);
+ }
+
+ [Fact]
+ public void Create_NameArgumentHasDescription()
+ {
+ var arg = _command.Arguments[0];
+ Assert.NotNull(arg.Description);
+ Assert.Contains("logical name", arg.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasProfileOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--profile");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasEnvironmentOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--environment");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasGlobalOptions()
+ {
+ var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format");
+ Assert.NotNull(formatOption);
+ }
+}
diff --git a/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/GetCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/GetCommandTests.cs
new file mode 100644
index 00000000..613cd4b5
--- /dev/null
+++ b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/GetCommandTests.cs
@@ -0,0 +1,63 @@
+using System.CommandLine;
+using PPDS.Cli.Commands.ConnectionReferences;
+using Xunit;
+
+namespace PPDS.Cli.Tests.Commands.ConnectionReferences;
+
+public class GetCommandTests
+{
+ private readonly Command _command;
+
+ public GetCommandTests()
+ {
+ _command = GetCommand.Create();
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithCorrectName()
+ {
+ Assert.Equal("get", _command.Name);
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithDescription()
+ {
+ Assert.Contains("connection reference", _command.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasNameArgument()
+ {
+ Assert.Single(_command.Arguments);
+ Assert.Equal("name", _command.Arguments[0].Name);
+ }
+
+ [Fact]
+ public void Create_NameArgumentHasDescription()
+ {
+ var arg = _command.Arguments[0];
+ Assert.NotNull(arg.Description);
+ Assert.Contains("logical name", arg.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasProfileOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--profile");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasEnvironmentOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--environment");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasGlobalOptions()
+ {
+ var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format");
+ Assert.NotNull(formatOption);
+ }
+}
diff --git a/tests/PPDS.Cli.Tests/Commands/Connections/ConnectionsCommandGroupTests.cs b/tests/PPDS.Cli.Tests/Commands/Connections/ConnectionsCommandGroupTests.cs
new file mode 100644
index 00000000..d4bf3695
--- /dev/null
+++ b/tests/PPDS.Cli.Tests/Commands/Connections/ConnectionsCommandGroupTests.cs
@@ -0,0 +1,103 @@
+using System.CommandLine;
+using PPDS.Cli.Commands.Connections;
+using Xunit;
+
+namespace PPDS.Cli.Tests.Commands.Connections;
+
+public class ConnectionsCommandGroupTests
+{
+ private readonly Command _command;
+
+ public ConnectionsCommandGroupTests()
+ {
+ _command = ConnectionsCommandGroup.Create();
+ }
+
+ #region Command Structure Tests
+
+ [Fact]
+ public void Create_ReturnsCommandWithCorrectName()
+ {
+ Assert.Equal("connections", _command.Name);
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithDescription()
+ {
+ Assert.Contains("connection", _command.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasListSubcommand()
+ {
+ var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "list");
+ Assert.NotNull(subcommand);
+ }
+
+ [Fact]
+ public void Create_HasGetSubcommand()
+ {
+ var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "get");
+ Assert.NotNull(subcommand);
+ }
+
+ [Fact]
+ public void Create_HasTwoSubcommands()
+ {
+ Assert.Equal(2, _command.Subcommands.Count);
+ }
+
+ #endregion
+
+ #region List Subcommand Tests
+
+ [Fact]
+ public void ListSubcommand_HasConnectorOption()
+ {
+ var listCommand = _command.Subcommands.First(c => c.Name == "list");
+ var option = listCommand.Options.FirstOrDefault(o => o.Name == "--connector");
+ Assert.NotNull(option);
+ }
+
+ #endregion
+
+ #region Get Subcommand Tests
+
+ [Fact]
+ public void GetSubcommand_HasIdArgument()
+ {
+ var getCommand = _command.Subcommands.First(c => c.Name == "get");
+ Assert.Single(getCommand.Arguments);
+ Assert.Equal("id", getCommand.Arguments[0].Name);
+ }
+
+ #endregion
+
+ #region Shared Options Tests
+
+ [Fact]
+ public void ProfileOption_HasCorrectName()
+ {
+ Assert.Equal("--profile", ConnectionsCommandGroup.ProfileOption.Name);
+ }
+
+ [Fact]
+ public void ProfileOption_HasShortAlias()
+ {
+ Assert.Contains("-p", ConnectionsCommandGroup.ProfileOption.Aliases);
+ }
+
+ [Fact]
+ public void EnvironmentOption_HasCorrectName()
+ {
+ Assert.Equal("--environment", ConnectionsCommandGroup.EnvironmentOption.Name);
+ }
+
+ [Fact]
+ public void EnvironmentOption_HasShortAlias()
+ {
+ Assert.Contains("-e", ConnectionsCommandGroup.EnvironmentOption.Aliases);
+ }
+
+ #endregion
+}
diff --git a/tests/PPDS.Cli.Tests/Commands/Connections/GetCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/Connections/GetCommandTests.cs
new file mode 100644
index 00000000..8aa3ae28
--- /dev/null
+++ b/tests/PPDS.Cli.Tests/Commands/Connections/GetCommandTests.cs
@@ -0,0 +1,63 @@
+using System.CommandLine;
+using PPDS.Cli.Commands.Connections;
+using Xunit;
+
+namespace PPDS.Cli.Tests.Commands.Connections;
+
+public class GetCommandTests
+{
+ private readonly Command _command;
+
+ public GetCommandTests()
+ {
+ _command = GetCommand.Create();
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithCorrectName()
+ {
+ Assert.Equal("get", _command.Name);
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithDescription()
+ {
+ Assert.Contains("connection", _command.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasIdArgument()
+ {
+ Assert.Single(_command.Arguments);
+ Assert.Equal("id", _command.Arguments[0].Name);
+ }
+
+ [Fact]
+ public void Create_IdArgumentHasDescription()
+ {
+ var arg = _command.Arguments[0];
+ Assert.NotNull(arg.Description);
+ Assert.Contains("connection", arg.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasProfileOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--profile");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasEnvironmentOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--environment");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasGlobalOptions()
+ {
+ var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format");
+ Assert.NotNull(formatOption);
+ }
+}
diff --git a/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/DeploymentSettingsCommandGroupTests.cs b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/DeploymentSettingsCommandGroupTests.cs
new file mode 100644
index 00000000..1c9fdb43
--- /dev/null
+++ b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/DeploymentSettingsCommandGroupTests.cs
@@ -0,0 +1,146 @@
+using System.CommandLine;
+using PPDS.Cli.Commands.DeploymentSettings;
+using Xunit;
+
+namespace PPDS.Cli.Tests.Commands.DeploymentSettings;
+
+public class DeploymentSettingsCommandGroupTests
+{
+ private readonly Command _command;
+
+ public DeploymentSettingsCommandGroupTests()
+ {
+ _command = DeploymentSettingsCommandGroup.Create();
+ }
+
+ #region Command Structure Tests
+
+ [Fact]
+ public void Create_ReturnsCommandWithCorrectName()
+ {
+ Assert.Equal("deployment-settings", _command.Name);
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithDescription()
+ {
+ Assert.Contains("deployment settings", _command.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasGenerateSubcommand()
+ {
+ var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "generate");
+ Assert.NotNull(subcommand);
+ }
+
+ [Fact]
+ public void Create_HasSyncSubcommand()
+ {
+ var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "sync");
+ Assert.NotNull(subcommand);
+ }
+
+ [Fact]
+ public void Create_HasValidateSubcommand()
+ {
+ var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "validate");
+ Assert.NotNull(subcommand);
+ }
+
+ [Fact]
+ public void Create_HasThreeSubcommands()
+ {
+ Assert.Equal(3, _command.Subcommands.Count);
+ }
+
+ #endregion
+
+ #region Generate Subcommand Tests
+
+ [Fact]
+ public void GenerateSubcommand_HasSolutionOption()
+ {
+ var generateCommand = _command.Subcommands.First(c => c.Name == "generate");
+ var option = generateCommand.Options.FirstOrDefault(o => o.Name == "--solution");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void GenerateSubcommand_HasOutputOption()
+ {
+ var generateCommand = _command.Subcommands.First(c => c.Name == "generate");
+ var option = generateCommand.Options.FirstOrDefault(o => o.Name == "--output");
+ Assert.NotNull(option);
+ }
+
+ #endregion
+
+ #region Sync Subcommand Tests
+
+ [Fact]
+ public void SyncSubcommand_HasSolutionOption()
+ {
+ var syncCommand = _command.Subcommands.First(c => c.Name == "sync");
+ var option = syncCommand.Options.FirstOrDefault(o => o.Name == "--solution");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void SyncSubcommand_HasFileOption()
+ {
+ var syncCommand = _command.Subcommands.First(c => c.Name == "sync");
+ var option = syncCommand.Options.FirstOrDefault(o => o.Name == "--file");
+ Assert.NotNull(option);
+ }
+
+ #endregion
+
+ #region Validate Subcommand Tests
+
+ [Fact]
+ public void ValidateSubcommand_HasSolutionOption()
+ {
+ var validateCommand = _command.Subcommands.First(c => c.Name == "validate");
+ var option = validateCommand.Options.FirstOrDefault(o => o.Name == "--solution");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void ValidateSubcommand_HasFileOption()
+ {
+ var validateCommand = _command.Subcommands.First(c => c.Name == "validate");
+ var option = validateCommand.Options.FirstOrDefault(o => o.Name == "--file");
+ Assert.NotNull(option);
+ }
+
+ #endregion
+
+ #region Shared Options Tests
+
+ [Fact]
+ public void ProfileOption_HasCorrectName()
+ {
+ Assert.Equal("--profile", DeploymentSettingsCommandGroup.ProfileOption.Name);
+ }
+
+ [Fact]
+ public void ProfileOption_HasShortAlias()
+ {
+ Assert.Contains("-p", DeploymentSettingsCommandGroup.ProfileOption.Aliases);
+ }
+
+ [Fact]
+ public void EnvironmentOption_HasCorrectName()
+ {
+ Assert.Equal("--environment", DeploymentSettingsCommandGroup.EnvironmentOption.Name);
+ }
+
+ [Fact]
+ public void EnvironmentOption_HasShortAlias()
+ {
+ Assert.Contains("-e", DeploymentSettingsCommandGroup.EnvironmentOption.Aliases);
+ }
+
+ #endregion
+}
diff --git a/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/GenerateCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/GenerateCommandTests.cs
new file mode 100644
index 00000000..ec497a23
--- /dev/null
+++ b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/GenerateCommandTests.cs
@@ -0,0 +1,75 @@
+using System.CommandLine;
+using PPDS.Cli.Commands.DeploymentSettings;
+using Xunit;
+
+namespace PPDS.Cli.Tests.Commands.DeploymentSettings;
+
+public class GenerateCommandTests
+{
+ private readonly Command _command;
+
+ public GenerateCommandTests()
+ {
+ _command = GenerateCommand.Create();
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithCorrectName()
+ {
+ Assert.Equal("generate", _command.Name);
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithDescription()
+ {
+ Assert.Contains("deployment settings", _command.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasNoArguments()
+ {
+ Assert.Empty(_command.Arguments);
+ }
+
+ [Fact]
+ public void Create_HasSolutionOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--solution");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasOutputOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--output");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_OutputOptionHasShortAlias()
+ {
+ var option = _command.Options.First(o => o.Name == "--output");
+ Assert.Contains("-o", option.Aliases);
+ }
+
+ [Fact]
+ public void Create_HasProfileOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--profile");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasEnvironmentOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--environment");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasGlobalOptions()
+ {
+ var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format");
+ Assert.NotNull(formatOption);
+ }
+}
diff --git a/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/SyncCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/SyncCommandTests.cs
new file mode 100644
index 00000000..95bd665a
--- /dev/null
+++ b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/SyncCommandTests.cs
@@ -0,0 +1,82 @@
+using System.CommandLine;
+using PPDS.Cli.Commands.DeploymentSettings;
+using Xunit;
+
+namespace PPDS.Cli.Tests.Commands.DeploymentSettings;
+
+public class SyncCommandTests
+{
+ private readonly Command _command;
+
+ public SyncCommandTests()
+ {
+ _command = SyncCommand.Create();
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithCorrectName()
+ {
+ Assert.Equal("sync", _command.Name);
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithDescription()
+ {
+ Assert.Contains("deployment settings", _command.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasNoArguments()
+ {
+ Assert.Empty(_command.Arguments);
+ }
+
+ [Fact]
+ public void Create_HasSolutionOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--solution");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasFileOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--file");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_FileOptionHasShortAlias()
+ {
+ var option = _command.Options.First(o => o.Name == "--file");
+ Assert.Contains("-f", option.Aliases);
+ }
+
+ [Fact]
+ public void Create_HasDryRunOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--dry-run");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasProfileOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--profile");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasEnvironmentOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--environment");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasGlobalOptions()
+ {
+ var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format");
+ Assert.NotNull(formatOption);
+ }
+}
diff --git a/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/ValidateCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/ValidateCommandTests.cs
new file mode 100644
index 00000000..83605653
--- /dev/null
+++ b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/ValidateCommandTests.cs
@@ -0,0 +1,75 @@
+using System.CommandLine;
+using PPDS.Cli.Commands.DeploymentSettings;
+using Xunit;
+
+namespace PPDS.Cli.Tests.Commands.DeploymentSettings;
+
+public class ValidateCommandTests
+{
+ private readonly Command _command;
+
+ public ValidateCommandTests()
+ {
+ _command = ValidateCommand.Create();
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithCorrectName()
+ {
+ Assert.Equal("validate", _command.Name);
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithDescription()
+ {
+ Assert.Contains("deployment settings", _command.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasNoArguments()
+ {
+ Assert.Empty(_command.Arguments);
+ }
+
+ [Fact]
+ public void Create_HasSolutionOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--solution");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasFileOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--file");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_FileOptionHasShortAlias()
+ {
+ var option = _command.Options.First(o => o.Name == "--file");
+ Assert.Contains("-f", option.Aliases);
+ }
+
+ [Fact]
+ public void Create_HasProfileOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--profile");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasEnvironmentOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--environment");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasGlobalOptions()
+ {
+ var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format");
+ Assert.NotNull(formatOption);
+ }
+}
diff --git a/tests/PPDS.Cli.Tests/Commands/Flows/FlowsCommandGroupTests.cs b/tests/PPDS.Cli.Tests/Commands/Flows/FlowsCommandGroupTests.cs
new file mode 100644
index 00000000..340ea372
--- /dev/null
+++ b/tests/PPDS.Cli.Tests/Commands/Flows/FlowsCommandGroupTests.cs
@@ -0,0 +1,130 @@
+using System.CommandLine;
+using PPDS.Cli.Commands.Flows;
+using Xunit;
+
+namespace PPDS.Cli.Tests.Commands.Flows;
+
+public class FlowsCommandGroupTests
+{
+ private readonly Command _command;
+
+ public FlowsCommandGroupTests()
+ {
+ _command = FlowsCommandGroup.Create();
+ }
+
+ #region Command Structure Tests
+
+ [Fact]
+ public void Create_ReturnsCommandWithCorrectName()
+ {
+ Assert.Equal("flows", _command.Name);
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithDescription()
+ {
+ Assert.Contains("flow", _command.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasListSubcommand()
+ {
+ var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "list");
+ Assert.NotNull(subcommand);
+ }
+
+ [Fact]
+ public void Create_HasGetSubcommand()
+ {
+ var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "get");
+ Assert.NotNull(subcommand);
+ }
+
+ [Fact]
+ public void Create_HasUrlSubcommand()
+ {
+ var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "url");
+ Assert.NotNull(subcommand);
+ }
+
+ [Fact]
+ public void Create_HasThreeSubcommands()
+ {
+ Assert.Equal(3, _command.Subcommands.Count);
+ }
+
+ #endregion
+
+ #region List Subcommand Tests
+
+ [Fact]
+ public void ListSubcommand_HasSolutionOption()
+ {
+ var listCommand = _command.Subcommands.First(c => c.Name == "list");
+ var option = listCommand.Options.FirstOrDefault(o => o.Name == "--solution");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void ListSubcommand_HasStateOption()
+ {
+ var listCommand = _command.Subcommands.First(c => c.Name == "list");
+ var option = listCommand.Options.FirstOrDefault(o => o.Name == "--state");
+ Assert.NotNull(option);
+ }
+
+ #endregion
+
+ #region Get Subcommand Tests
+
+ [Fact]
+ public void GetSubcommand_HasNameArgument()
+ {
+ var getCommand = _command.Subcommands.First(c => c.Name == "get");
+ Assert.Single(getCommand.Arguments);
+ Assert.Equal("name", getCommand.Arguments[0].Name);
+ }
+
+ #endregion
+
+ #region Url Subcommand Tests
+
+ [Fact]
+ public void UrlSubcommand_HasNameArgument()
+ {
+ var urlCommand = _command.Subcommands.First(c => c.Name == "url");
+ Assert.Single(urlCommand.Arguments);
+ Assert.Equal("name", urlCommand.Arguments[0].Name);
+ }
+
+ #endregion
+
+ #region Shared Options Tests
+
+ [Fact]
+ public void ProfileOption_HasCorrectName()
+ {
+ Assert.Equal("--profile", FlowsCommandGroup.ProfileOption.Name);
+ }
+
+ [Fact]
+ public void ProfileOption_HasShortAlias()
+ {
+ Assert.Contains("-p", FlowsCommandGroup.ProfileOption.Aliases);
+ }
+
+ [Fact]
+ public void EnvironmentOption_HasCorrectName()
+ {
+ Assert.Equal("--environment", FlowsCommandGroup.EnvironmentOption.Name);
+ }
+
+ [Fact]
+ public void EnvironmentOption_HasShortAlias()
+ {
+ Assert.Contains("-e", FlowsCommandGroup.EnvironmentOption.Aliases);
+ }
+
+ #endregion
+}
diff --git a/tests/PPDS.Cli.Tests/Commands/Flows/GetCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/Flows/GetCommandTests.cs
new file mode 100644
index 00000000..79118efc
--- /dev/null
+++ b/tests/PPDS.Cli.Tests/Commands/Flows/GetCommandTests.cs
@@ -0,0 +1,63 @@
+using System.CommandLine;
+using PPDS.Cli.Commands.Flows;
+using Xunit;
+
+namespace PPDS.Cli.Tests.Commands.Flows;
+
+public class GetCommandTests
+{
+ private readonly Command _command;
+
+ public GetCommandTests()
+ {
+ _command = GetCommand.Create();
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithCorrectName()
+ {
+ Assert.Equal("get", _command.Name);
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithDescription()
+ {
+ Assert.Contains("flow", _command.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasNameArgument()
+ {
+ Assert.Single(_command.Arguments);
+ Assert.Equal("name", _command.Arguments[0].Name);
+ }
+
+ [Fact]
+ public void Create_NameArgumentHasDescription()
+ {
+ var arg = _command.Arguments[0];
+ Assert.NotNull(arg.Description);
+ Assert.Contains("unique name", arg.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasProfileOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--profile");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasEnvironmentOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--environment");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasGlobalOptions()
+ {
+ var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format");
+ Assert.NotNull(formatOption);
+ }
+}
diff --git a/tests/PPDS.Cli.Tests/Commands/Flows/UrlCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/Flows/UrlCommandTests.cs
new file mode 100644
index 00000000..d94fc2a1
--- /dev/null
+++ b/tests/PPDS.Cli.Tests/Commands/Flows/UrlCommandTests.cs
@@ -0,0 +1,63 @@
+using System.CommandLine;
+using PPDS.Cli.Commands.Flows;
+using Xunit;
+
+namespace PPDS.Cli.Tests.Commands.Flows;
+
+public class UrlCommandTests
+{
+ private readonly Command _command;
+
+ public UrlCommandTests()
+ {
+ _command = UrlCommand.Create();
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithCorrectName()
+ {
+ Assert.Equal("url", _command.Name);
+ }
+
+ [Fact]
+ public void Create_ReturnsCommandWithDescription()
+ {
+ Assert.Contains("url", _command.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasNameArgument()
+ {
+ Assert.Single(_command.Arguments);
+ Assert.Equal("name", _command.Arguments[0].Name);
+ }
+
+ [Fact]
+ public void Create_NameArgumentHasDescription()
+ {
+ var arg = _command.Arguments[0];
+ Assert.NotNull(arg.Description);
+ Assert.Contains("unique name", arg.Description, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_HasProfileOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--profile");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasEnvironmentOption()
+ {
+ var option = _command.Options.FirstOrDefault(o => o.Name == "--environment");
+ Assert.NotNull(option);
+ }
+
+ [Fact]
+ public void Create_HasGlobalOptions()
+ {
+ var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format");
+ Assert.NotNull(formatOption);
+ }
+}
diff --git a/tests/PPDS.Cli.Tests/Services/ConnectionServiceTests.cs b/tests/PPDS.Cli.Tests/Services/ConnectionServiceTests.cs
new file mode 100644
index 00000000..3226995b
--- /dev/null
+++ b/tests/PPDS.Cli.Tests/Services/ConnectionServiceTests.cs
@@ -0,0 +1,163 @@
+using Microsoft.Extensions.Logging;
+using Moq;
+using PPDS.Auth.Cloud;
+using PPDS.Auth.Credentials;
+using PPDS.Cli.Services;
+using Xunit;
+
+namespace PPDS.Cli.Tests.Services;
+
+public class ConnectionServiceTests
+{
+ private readonly Mock _mockTokenProvider;
+ private readonly Mock> _mockLogger;
+ private readonly string _environmentId;
+ private readonly CloudEnvironment _cloud;
+
+ public ConnectionServiceTests()
+ {
+ _mockTokenProvider = new Mock();
+ _mockLogger = new Mock>();
+ _environmentId = "test-environment-id";
+ _cloud = CloudEnvironment.Public;
+ }
+
+ #region Constructor Tests
+
+ [Fact]
+ public void Constructor_WithValidParameters_CreatesInstance()
+ {
+ var service = new ConnectionService(
+ _mockTokenProvider.Object,
+ _cloud,
+ _environmentId,
+ _mockLogger.Object);
+
+ Assert.NotNull(service);
+ }
+
+ [Fact]
+ public void Constructor_WithNullTokenProvider_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => new ConnectionService(
+ null!,
+ _cloud,
+ _environmentId,
+ _mockLogger.Object));
+ }
+
+ [Fact]
+ public void Constructor_WithNullEnvironmentId_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => new ConnectionService(
+ _mockTokenProvider.Object,
+ _cloud,
+ null!,
+ _mockLogger.Object));
+ }
+
+ [Fact]
+ public void Constructor_WithNullLogger_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => new ConnectionService(
+ _mockTokenProvider.Object,
+ _cloud,
+ _environmentId,
+ null!));
+ }
+
+ #endregion
+
+ #region ListAsync Tests
+
+ [Fact]
+ public async Task ListAsync_CallsGetFlowApiTokenAsync()
+ {
+ // Arrange
+ _mockTokenProvider
+ .Setup(x => x.GetFlowApiTokenAsync(It.IsAny()))
+ .ReturnsAsync(new PowerPlatformToken
+ {
+ AccessToken = "test-token",
+ ExpiresOn = DateTimeOffset.UtcNow.AddHours(1),
+ Resource = "https://service.powerapps.com"
+ });
+
+ var service = new ConnectionService(
+ _mockTokenProvider.Object,
+ _cloud,
+ _environmentId,
+ _mockLogger.Object);
+
+ // Act & Assert (will fail due to HTTP call, but we verify the token was requested)
+ try
+ {
+ await service.ListAsync();
+ }
+ catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException)
+ {
+ // Expected - no mock HTTP client (may throw HttpRequestException or InvalidOperationException for auth)
+ }
+
+ _mockTokenProvider.Verify(x => x.GetFlowApiTokenAsync(It.IsAny()), Times.Once);
+ }
+
+ #endregion
+
+ #region GetAsync Tests
+
+ [Fact]
+ public async Task GetAsync_CallsGetFlowApiTokenAsync()
+ {
+ // Arrange
+ _mockTokenProvider
+ .Setup(x => x.GetFlowApiTokenAsync(It.IsAny()))
+ .ReturnsAsync(new PowerPlatformToken
+ {
+ AccessToken = "test-token",
+ ExpiresOn = DateTimeOffset.UtcNow.AddHours(1),
+ Resource = "https://service.powerapps.com"
+ });
+
+ var service = new ConnectionService(
+ _mockTokenProvider.Object,
+ _cloud,
+ _environmentId,
+ _mockLogger.Object);
+
+ // Act & Assert (will fail due to HTTP call, but we verify the token was requested)
+ try
+ {
+ await service.GetAsync("test-connection-id");
+ }
+ catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException)
+ {
+ // Expected - no mock HTTP client (may throw HttpRequestException or InvalidOperationException for auth)
+ }
+
+ _mockTokenProvider.Verify(x => x.GetFlowApiTokenAsync(It.IsAny()), Times.Once);
+ }
+
+ #endregion
+
+ #region Cloud Environment Tests
+
+ [Theory]
+ [InlineData(CloudEnvironment.Public)]
+ [InlineData(CloudEnvironment.UsGov)]
+ [InlineData(CloudEnvironment.UsGovHigh)]
+ [InlineData(CloudEnvironment.UsGovDod)]
+ [InlineData(CloudEnvironment.China)]
+ public void Constructor_AcceptsAllCloudEnvironments(CloudEnvironment cloud)
+ {
+ var service = new ConnectionService(
+ _mockTokenProvider.Object,
+ cloud,
+ _environmentId,
+ _mockLogger.Object);
+
+ Assert.NotNull(service);
+ }
+
+ #endregion
+}
diff --git a/tests/PPDS.Dataverse.Tests/Services/ConnectionReferenceServiceTests.cs b/tests/PPDS.Dataverse.Tests/Services/ConnectionReferenceServiceTests.cs
new file mode 100644
index 00000000..b9608c62
--- /dev/null
+++ b/tests/PPDS.Dataverse.Tests/Services/ConnectionReferenceServiceTests.cs
@@ -0,0 +1,126 @@
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using PPDS.Dataverse.Configuration;
+using PPDS.Dataverse.DependencyInjection;
+using PPDS.Dataverse.Pooling;
+using PPDS.Dataverse.Services;
+using Xunit;
+
+namespace PPDS.Dataverse.Tests.Services;
+
+public class ConnectionReferenceServiceTests
+{
+ [Fact]
+ public void Constructor_ThrowsOnNullConnectionPool()
+ {
+ // Arrange
+ var flowService = new Mock().Object;
+ var logger = new NullLogger();
+
+ // Act
+ var act = () => new ConnectionReferenceService(null!, flowService, logger);
+
+ // Assert
+ act.Should().Throw()
+ .And.ParamName.Should().Be("pool");
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullFlowService()
+ {
+ // Arrange
+ var pool = new Mock().Object;
+ var logger = new NullLogger();
+
+ // Act
+ var act = () => new ConnectionReferenceService(pool, null!, logger);
+
+ // Assert
+ act.Should().Throw()
+ .And.ParamName.Should().Be("flowService");
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullLogger()
+ {
+ // Arrange
+ var pool = new Mock().Object;
+ var flowService = new Mock().Object;
+
+ // Act
+ var act = () => new ConnectionReferenceService(pool, flowService, null!);
+
+ // Assert
+ act.Should().Throw()
+ .And.ParamName.Should().Be("logger");
+ }
+
+ [Fact]
+ public void Constructor_CreatesInstance()
+ {
+ // Arrange
+ var pool = new Mock().Object;
+ var flowService = new Mock().Object;
+ var logger = new NullLogger();
+
+ // Act
+ var service = new ConnectionReferenceService(pool, flowService, logger);
+
+ // Assert
+ service.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void AddDataverseConnectionPool_RegistersIConnectionReferenceService()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddDataverseConnectionPool(options =>
+ {
+ options.Connections.Add(new DataverseConnection("Primary")
+ {
+ Url = "https://test.crm.dynamics.com",
+ ClientId = "test-client-id",
+ ClientSecret = "test-secret",
+ AuthType = DataverseAuthType.ClientSecret
+ });
+ });
+
+ // Act
+ var provider = services.BuildServiceProvider();
+ var crService = provider.GetService();
+
+ // Assert
+ crService.Should().NotBeNull();
+ crService.Should().BeOfType();
+ }
+
+ [Fact]
+ public void AddDataverseConnectionPool_ConnectionReferenceServiceIsTransient()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddDataverseConnectionPool(options =>
+ {
+ options.Connections.Add(new DataverseConnection("Primary")
+ {
+ Url = "https://test.crm.dynamics.com",
+ ClientId = "test-client-id",
+ ClientSecret = "test-secret",
+ AuthType = DataverseAuthType.ClientSecret
+ });
+ });
+
+ // Act
+ var provider = services.BuildServiceProvider();
+ var service1 = provider.GetService();
+ var service2 = provider.GetService();
+
+ // Assert
+ service1.Should().NotBeSameAs(service2);
+ }
+}
diff --git a/tests/PPDS.Dataverse.Tests/Services/DeploymentSettingsServiceTests.cs b/tests/PPDS.Dataverse.Tests/Services/DeploymentSettingsServiceTests.cs
new file mode 100644
index 00000000..8f95ffa8
--- /dev/null
+++ b/tests/PPDS.Dataverse.Tests/Services/DeploymentSettingsServiceTests.cs
@@ -0,0 +1,507 @@
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using PPDS.Dataverse.Configuration;
+using PPDS.Dataverse.DependencyInjection;
+using PPDS.Dataverse.Pooling;
+using PPDS.Dataverse.Services;
+using Xunit;
+
+namespace PPDS.Dataverse.Tests.Services;
+
+public class DeploymentSettingsServiceTests
+{
+ [Fact]
+ public void Constructor_ThrowsOnNullEnvVarService()
+ {
+ // Arrange
+ var connectionRefService = new Mock().Object;
+ var logger = new NullLogger();
+
+ // Act
+ var act = () => new DeploymentSettingsService(null!, connectionRefService, logger);
+
+ // Assert
+ act.Should().Throw()
+ .And.ParamName.Should().Be("envVarService");
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullConnectionRefService()
+ {
+ // Arrange
+ var envVarService = new Mock().Object;
+ var logger = new NullLogger();
+
+ // Act
+ var act = () => new DeploymentSettingsService(envVarService, null!, logger);
+
+ // Assert
+ act.Should().Throw()
+ .And.ParamName.Should().Be("connectionRefService");
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNullLogger()
+ {
+ // Arrange
+ var envVarService = new Mock().Object;
+ var connectionRefService = new Mock().Object;
+
+ // Act
+ var act = () => new DeploymentSettingsService(envVarService, connectionRefService, null!);
+
+ // Assert
+ act.Should().Throw()
+ .And.ParamName.Should().Be("logger");
+ }
+
+ [Fact]
+ public void Constructor_CreatesInstance()
+ {
+ // Arrange
+ var envVarService = new Mock().Object;
+ var connectionRefService = new Mock().Object;
+ var logger = new NullLogger();
+
+ // Act
+ var service = new DeploymentSettingsService(envVarService, connectionRefService, logger);
+
+ // Assert
+ service.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void AddDataverseConnectionPool_RegistersIDeploymentSettingsService()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddDataverseConnectionPool(options =>
+ {
+ options.Connections.Add(new DataverseConnection("Primary")
+ {
+ Url = "https://test.crm.dynamics.com",
+ ClientId = "test-client-id",
+ ClientSecret = "test-secret",
+ AuthType = DataverseAuthType.ClientSecret
+ });
+ });
+
+ // Act
+ var provider = services.BuildServiceProvider();
+ var dsService = provider.GetService();
+
+ // Assert
+ dsService.Should().NotBeNull();
+ dsService.Should().BeOfType();
+ }
+
+ [Fact]
+ public void AddDataverseConnectionPool_DeploymentSettingsServiceIsTransient()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddDataverseConnectionPool(options =>
+ {
+ options.Connections.Add(new DataverseConnection("Primary")
+ {
+ Url = "https://test.crm.dynamics.com",
+ ClientId = "test-client-id",
+ ClientSecret = "test-secret",
+ AuthType = DataverseAuthType.ClientSecret
+ });
+ });
+
+ // Act
+ var provider = services.BuildServiceProvider();
+ var service1 = provider.GetService();
+ var service2 = provider.GetService();
+
+ // Assert
+ service1.Should().NotBeSameAs(service2);
+ }
+
+ [Fact]
+ public async Task GenerateAsync_ReturnsSettingsWithEnvVarsAndConnectionRefs()
+ {
+ // Arrange
+ var envVarService = new Mock();
+ envVarService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new List
+ {
+ new()
+ {
+ Id = Guid.NewGuid(),
+ SchemaName = "cr_TestVar1",
+ DisplayName = "Test Variable 1",
+ Type = "String",
+ CurrentValue = "value1"
+ },
+ new()
+ {
+ Id = Guid.NewGuid(),
+ SchemaName = "cr_TestVar2",
+ DisplayName = "Test Variable 2",
+ Type = "String",
+ DefaultValue = "default2"
+ }
+ });
+
+ var connectionRefService = new Mock();
+ connectionRefService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new List
+ {
+ new()
+ {
+ Id = Guid.NewGuid(),
+ LogicalName = "cr_dataverse",
+ ConnectionId = "conn-123",
+ ConnectorId = "/providers/Microsoft.PowerApps/apis/shared_commondataserviceforapps"
+ }
+ });
+
+ var logger = new NullLogger();
+ var service = new DeploymentSettingsService(envVarService.Object, connectionRefService.Object, logger);
+
+ // Act
+ var result = await service.GenerateAsync("TestSolution");
+
+ // Assert
+ result.EnvironmentVariables.Should().HaveCount(2);
+ result.EnvironmentVariables[0].SchemaName.Should().Be("cr_TestVar1");
+ result.EnvironmentVariables[0].Value.Should().Be("value1");
+ result.EnvironmentVariables[1].SchemaName.Should().Be("cr_TestVar2");
+ result.EnvironmentVariables[1].Value.Should().Be("default2");
+
+ result.ConnectionReferences.Should().HaveCount(1);
+ result.ConnectionReferences[0].LogicalName.Should().Be("cr_dataverse");
+ result.ConnectionReferences[0].ConnectionId.Should().Be("conn-123");
+ }
+
+ [Fact]
+ public async Task GenerateAsync_ExcludesSecretEnvironmentVariables()
+ {
+ // Arrange
+ var envVarService = new Mock();
+ envVarService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new List
+ {
+ new() { Id = Guid.NewGuid(), SchemaName = "cr_NormalVar", Type = "String", CurrentValue = "normal" },
+ new() { Id = Guid.NewGuid(), SchemaName = "cr_SecretVar", Type = "Secret", CurrentValue = "secret-value" }
+ });
+
+ var connectionRefService = new Mock();
+ connectionRefService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new List());
+
+ var logger = new NullLogger();
+ var service = new DeploymentSettingsService(envVarService.Object, connectionRefService.Object, logger);
+
+ // Act
+ var result = await service.GenerateAsync("TestSolution");
+
+ // Assert
+ result.EnvironmentVariables.Should().HaveCount(1);
+ result.EnvironmentVariables[0].SchemaName.Should().Be("cr_NormalVar");
+ }
+
+ [Fact]
+ public async Task GenerateAsync_SortsEntriesByName()
+ {
+ // Arrange
+ var envVarService = new Mock();
+ envVarService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new List
+ {
+ new() { Id = Guid.NewGuid(), SchemaName = "cr_Zebra", Type = "String" },
+ new() { Id = Guid.NewGuid(), SchemaName = "cr_Apple", Type = "String" },
+ new() { Id = Guid.NewGuid(), SchemaName = "cr_Mango", Type = "String" }
+ });
+
+ var connectionRefService = new Mock();
+ connectionRefService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new List
+ {
+ new() { Id = Guid.NewGuid(), LogicalName = "cr_zebra_conn" },
+ new() { Id = Guid.NewGuid(), LogicalName = "cr_apple_conn" }
+ });
+
+ var logger = new NullLogger();
+ var service = new DeploymentSettingsService(envVarService.Object, connectionRefService.Object, logger);
+
+ // Act
+ var result = await service.GenerateAsync("TestSolution");
+
+ // Assert
+ result.EnvironmentVariables.Select(ev => ev.SchemaName)
+ .Should().BeEquivalentTo(new[] { "cr_Apple", "cr_Mango", "cr_Zebra" },
+ options => options.WithStrictOrdering());
+
+ result.ConnectionReferences.Select(cr => cr.LogicalName)
+ .Should().BeEquivalentTo(new[] { "cr_apple_conn", "cr_zebra_conn" },
+ options => options.WithStrictOrdering());
+ }
+
+ [Fact]
+ public async Task SyncAsync_PreservesExistingValues()
+ {
+ // Arrange
+ var envVarService = new Mock();
+ envVarService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny