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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/PPDS.Auth/Cloud/CloudEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,29 @@ public static string GetPowerAutomateApiUrl(CloudEnvironment cloud)
};
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="cloud">The cloud environment.</param>
/// <returns>The Power Apps Service scope URL (without /.default suffix).</returns>
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")
};
}

/// <summary>
/// Parses a cloud environment from a string value.
/// </summary>
Expand Down
10 changes: 10 additions & 0 deletions src/PPDS.Auth/Credentials/IPowerPlatformTokenProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ public interface IPowerPlatformTokenProvider : IDisposable
/// <exception cref="AuthenticationException">If authentication fails.</exception>
Task<PowerPlatformToken> GetPowerAutomateTokenAsync(CancellationToken cancellationToken = default);

/// <summary>
/// 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.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A valid access token with service.powerapps.com audience.</returns>
/// <exception cref="AuthenticationException">If authentication fails.</exception>
Task<PowerPlatformToken> GetFlowApiTokenAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Acquires an access token for the specified Power Platform resource.
/// </summary>
Expand Down
9 changes: 9 additions & 0 deletions src/PPDS.Auth/Credentials/PowerPlatformTokenProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ public Task<PowerPlatformToken> GetPowerAutomateTokenAsync(CancellationToken can
return GetTokenForResourceAsync(resource, cancellationToken);
}

/// <inheritdoc />
public Task<PowerPlatformToken> 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);
}

/// <inheritdoc />
public async Task<PowerPlatformToken> GetTokenForResourceAsync(string resource, CancellationToken cancellationToken = default)
{
Expand Down
17 changes: 17 additions & 0 deletions src/PPDS.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` - Get flow details by unique name
- `ppds flows url <name>` - 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 <id>` - 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 <name>` - Get connection reference details by logical name
- `ppds connectionreferences flows <name>` - List flows using a connection reference
- `ppds connectionreferences connections <name>` - 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 <name>` - Get solution details by unique name
Expand Down
241 changes: 241 additions & 0 deletions src/PPDS.Cli/Commands/ConnectionReferences/AnalyzeCommand.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Analyze flow-connection reference relationships and detect orphans.
/// </summary>
public static class AnalyzeCommand
{
public static Command Create()
{
var orphansOnlyOption = new Option<bool>("--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<int> 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<IConnectionReferenceService>();

if (!globalOptions.IsJsonMode)
{
var connectionInfo = serviceProvider.GetRequiredService<ResolvedConnectionInfo>();
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<RelationshipOutput> 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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.CommandLine;

namespace PPDS.Cli.Commands.ConnectionReferences;

/// <summary>
/// Command group for connection reference operations.
/// </summary>
public static class ConnectionReferencesCommandGroup
{
/// <summary>Shared profile option.</summary>
public static readonly Option<string?> ProfileOption = new("--profile", "-p")
{
Description = "Authentication profile name"
};

/// <summary>Shared environment option.</summary>
public static readonly Option<string?> EnvironmentOption = new("--environment", "-e")
{
Description = "Environment URL override"
};

/// <summary>Shared solution filter option.</summary>
public static readonly Option<string?> SolutionOption = new("--solution", "-s")
{
Description = "Filter by solution unique name"
};

/// <summary>
/// Creates the connectionreferences command group.
/// </summary>
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;
}
}
Loading
Loading