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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ txc env pkg uninstall TALXIS.Controls.FileExplorer.Package --yes
# Import from a folder or .cdsproj project — auto-packs via SolutionPackager
txc env sln import ./src/MySolution/

# Import and pre-populate connection references + environment variables for the
# target environment from a pac-style deployment settings file
txc env sln import ./out/MySolution_managed.zip --settings-file ./DEV-deployment-settings.json

# Publish customizations after import (makes forms, views, sitemaps visible)
txc env sln publish

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using TALXIS.CLI.Core.Deployment;

namespace TALXIS.CLI.Core.Contracts.Dataverse;

/// <summary>
Expand All @@ -22,7 +24,8 @@ public sealed record SolutionImportOptions(
bool PublishWorkflows,
bool SkipDependencyCheck,
bool SkipLowerVersion,
bool Async);
bool Async,
DeploymentSettings? Settings = null);

public sealed record SolutionImportResult(
SolutionImportPath Path,
Expand Down
43 changes: 43 additions & 0 deletions src/TALXIS.CLI.Core/Deployment/DeploymentSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Text.Json.Serialization;

namespace TALXIS.CLI.Core.Deployment;

/// <summary>
/// Deployment settings in the pac / Power Platform Build Tools JSON format, used to
/// pre-populate connection references and environment variable values during a
/// solution import. Mirrors the file produced by <c>pac solution create-settings</c>.
/// </summary>
public sealed record DeploymentSettings
{
[JsonPropertyName("ConnectionReferences")]
public IReadOnlyList<ConnectionReferenceSetting> ConnectionReferences { get; init; } = [];

[JsonPropertyName("EnvironmentVariables")]
public IReadOnlyList<EnvironmentVariableSetting> EnvironmentVariables { get; init; } = [];

/// <summary>True when the file carries neither a connection reference nor an environment variable.</summary>
public bool IsEmpty => ConnectionReferences.Count == 0 && EnvironmentVariables.Count == 0;
}

/// <summary>A single connection reference entry from a deployment settings file.</summary>
public sealed record ConnectionReferenceSetting
{
[JsonPropertyName("LogicalName")]
public string? LogicalName { get; init; }

[JsonPropertyName("ConnectionId")]
public string? ConnectionId { get; init; }

[JsonPropertyName("ConnectorId")]
public string? ConnectorId { get; init; }
}

/// <summary>A single environment variable entry from a deployment settings file.</summary>
public sealed record EnvironmentVariableSetting
{
[JsonPropertyName("SchemaName")]
public string? SchemaName { get; init; }

[JsonPropertyName("Value")]
public string? Value { get; init; }
}
89 changes: 89 additions & 0 deletions src/TALXIS.CLI.Core/Deployment/DeploymentSettingsFile.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using System.Text.Json;

namespace TALXIS.CLI.Core.Deployment;

/// <summary>
/// Loads and parses deployment settings JSON files.
/// </summary>
public static class DeploymentSettingsFile
{
// The deployment settings format is fixed PascalCase (pac CLI), so the shared
// camelCase TxcJsonOptions don't apply here - a dedicated case-insensitive reader
// is intentional and accepts both PascalCase and camelCase files.
#pragma warning disable RS0030 // Bespoke options required: case-insensitive, not camelCase
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
#pragma warning restore RS0030

/// <summary>Reads and parses a file; returns false with an error message instead of throwing.</summary>
public static bool TryLoad(string path, out DeploymentSettings? settings, out string? error)
{
try
{
settings = Load(path);
error = null;

return true;
}
catch (Exception ex) when (ex is FileNotFoundException or InvalidDataException or ArgumentException)
{
settings = null;
error = ex.Message;

return false;
}
}

/// <summary>
/// Reads and parses a deployment settings file from disk.
/// </summary>
public static DeploymentSettings Load(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
if (!File.Exists(path))
throw new FileNotFoundException($"Deployment settings file not found: {path}", path);

return Parse(File.ReadAllText(path));
}

/// <summary>
/// Parses deployment settings from a JSON string.
/// </summary>
public static DeploymentSettings Parse(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);

DeploymentSettings? settings;
try
{
settings = JsonSerializer.Deserialize<DeploymentSettings>(json, Options);
}
catch (JsonException ex)
{
throw new InvalidDataException($"Deployment settings file is not valid JSON: {ex.Message}", ex);
}

if (settings is null)
throw new InvalidDataException("Deployment settings file is empty or contains only 'null'.");

// Reject entries missing their key identifier early — these are almost always a
// malformed file and otherwise surface as a cryptic failure at import time.
foreach (var connection in settings.ConnectionReferences)
{
if (string.IsNullOrWhiteSpace(connection.LogicalName))
throw new InvalidDataException("A 'ConnectionReferences' entry is missing its 'LogicalName'.");
}

foreach (var variable in settings.EnvironmentVariables)
{
if (string.IsNullOrWhiteSpace(variable.SchemaName))
throw new InvalidDataException("An 'EnvironmentVariables' entry is missing its 'SchemaName'.");
}

return settings;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using TALXIS.CLI.Core.Deployment;

namespace TALXIS.CLI.Core.Platforms.PowerPlatform;

/// <summary>
/// Pre-flight result. <see cref="Validated"/> is false when the check couldn't run (caller should proceed);
/// <see cref="MissingConnections"/> lists connection ids not found in the target environment.
/// </summary>
public sealed record ConnectionValidationResult(
bool Validated,
IReadOnlyList<string> MissingConnections);

/// <summary>Checks the deployment settings' connection references exist in the target environment before import.</summary>
public interface IConnectionValidator
{
Task<ConnectionValidationResult> ValidateAsync(
string? profileName,
DeploymentSettings settings,
CancellationToken ct);
}
2 changes: 2 additions & 0 deletions src/TALXIS.CLI.Features.Docs/Skills/deployment-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Tool: environment_solution_import
```
Uploads the solution package to the target Dataverse environment. By default returns immediately with an `asyncOperationId`. **Do NOT use `--wait`** — solution imports take minutes and will time out the MCP request. Instead, monitor progress with `environment_deployment_show --solution-name <name>` until status is `Completed` or `Failed`.

For solutions with connection references or environment variables, pass `--settings-file <path>` (a pac / Power Platform Build Tools deployment settings JSON file) to pre-populate them on import. Entries are reconciled against the staged solution — anything not present in the solution, or missing a value, is reported as a warning and skipped. Before importing, the referenced connections are checked against the target environment via the Power Platform connections API; a connection id that doesn't exist there fails the command with a clear message. Use `--skip-settings-validation` to bypass that pre-flight check.

### 5. Publish
```
Tool: environment_solution_publish
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
using Microsoft.Extensions.Logging;
using TALXIS.CLI.Core;
using TALXIS.CLI.Core.Contracts.Dataverse;
using TALXIS.CLI.Core.Deployment;
using TALXIS.CLI.Core.DependencyInjection;
using TALXIS.CLI.Core.Platforms.PowerPlatform;
using TALXIS.CLI.Core.Resolution;
using TALXIS.CLI.Logging;
using TALXIS.Platform.Metadata.Packaging;
Expand Down Expand Up @@ -47,8 +49,46 @@ public class SolutionImportCliCommand : ProfiledCliCommand
[CliOption(Name = "--managed", Description = "When importing from a folder, pack as managed solution.", Required = false)]
public bool Managed { get; set; }

[CliOption(Name = "--settings-file", Description = "Path to a deployment settings JSON file (pac / Power Platform Build Tools format) used to pre-populate connection references and environment variable values during import.", Required = false)]
public string? SettingsFile { get; set; }

[CliOption(Name = "--skip-settings-validation", Description = "Skip the pre-flight check that connections referenced by --settings-file exist in the target environment.", Required = false)]
public bool SkipSettingsValidation { get; set; }

protected override async Task<int> ExecuteAsync()
{
DeploymentSettings? deploymentSettings = null;
if (!string.IsNullOrWhiteSpace(SettingsFile))
{
var settingsPath = Path.GetFullPath(SettingsFile);

if (!DeploymentSettingsFile.TryLoad(settingsPath, out deploymentSettings, out var settingsError))
{
Logger.LogError("Could not read deployment settings file: {Message}", settingsError);

return ExitValidationError;
}

Logger.LogInformation(
"Loaded deployment settings: {ConnectionCount} connection reference(s), {VariableCount} environment variable(s).",
deploymentSettings!.ConnectionReferences.Count,
deploymentSettings.EnvironmentVariables.Count);

if (!SkipSettingsValidation && deploymentSettings.ConnectionReferences.Count > 0)
{
var validator = TxcServices.Get<IConnectionValidator>();
var validation = await validator.ValidateAsync(Profile, deploymentSettings, CancellationToken.None).ConfigureAwait(false);
if (validation.MissingConnections.Count > 0)
{
Logger.LogError(
"Deployment settings reference connections that do not exist in the target environment: {Missing}. Create or share these connections first, or re-run with --skip-settings-validation.",
string.Join("; ", validation.MissingConnections));

return ExitValidationError;
}
}
}

string solutionPath = Path.GetFullPath(SolutionZip);
string? tempZipPath = null;

Expand Down Expand Up @@ -122,7 +162,8 @@ protected override async Task<int> ExecuteAsync()
PublishWorkflows: PublishWorkflows,
SkipDependencyCheck: SkipDependencyCheck,
SkipLowerVersion: SkipLowerVersion,
Async: !Wait);
Async: !Wait,
Settings: deploymentSettings);

var service = TxcServices.Get<ISolutionImportService>();
var result = await service.ImportAsync(Profile, solutionPath, options, CancellationToken.None).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using TALXIS.CLI.Core.Deployment;

namespace TALXIS.CLI.Platform.Dataverse.Application.Sdk;

/// <summary>A connection reference declared by the solution being imported.</summary>
public sealed record SolutionConnectionReference(string LogicalName, string? ConnectorId);

/// <summary>An environment variable (definition or value) declared by the solution being imported.</summary>
public sealed record SolutionEnvironmentVariable(string SchemaName, string? ValueId);

/// <summary>Connection reference for ComponentParameters. ConnectionId is a string (the column isn't a GUID).</summary>
public sealed record PlannedConnectionReference(string LogicalName, string ConnectionId, string? ConnectorId);

/// <summary>Environment variable value for ComponentParameters.</summary>
public sealed record PlannedEnvironmentVariable(string SchemaName, string Value, string? ValueId);

/// <summary>Values to apply, plus warnings about skipped entries.</summary>
public sealed record ComponentParameterPlan(
IReadOnlyList<PlannedConnectionReference> ConnectionReferences,
IReadOnlyList<PlannedEnvironmentVariable> EnvironmentVariables,
IReadOnlyList<string> Warnings)
{
public bool IsEmpty => ConnectionReferences.Count == 0 && EnvironmentVariables.Count == 0;
}

/// <summary>Reconciles a deployment settings file with the solution's declared components. Pure, no Dataverse calls.</summary>
public static class ComponentParameterPlanner
{
public static ComponentParameterPlan Plan(
DeploymentSettings settings,
IReadOnlyList<SolutionConnectionReference> solutionConnectionReferences,
IReadOnlyList<SolutionEnvironmentVariable> solutionEnvironmentVariables)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(solutionConnectionReferences);
ArgumentNullException.ThrowIfNull(solutionEnvironmentVariables);

var warnings = new List<string>();
var connectionReferences = new List<PlannedConnectionReference>();
var environmentVariables = new List<PlannedEnvironmentVariable>();

var declaredReferences = solutionConnectionReferences
.GroupBy(r => r.LogicalName, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);

// A schema name can appear both as a definition and a value component — prefer
// the value component so we carry its id and update in place rather than duplicate.
var declaredVariables = solutionEnvironmentVariables
.GroupBy(v => v.SchemaName, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.OrderByDescending(v => v.ValueId is not null).First(),
StringComparer.OrdinalIgnoreCase);

foreach (var setting in settings.ConnectionReferences)
{
// LogicalName is guaranteed non-empty by DeploymentSettingsFile.
var logicalName = setting.LogicalName!;

if (!declaredReferences.TryGetValue(logicalName, out var declared))
{
warnings.Add($"Connection reference '{logicalName}' from the settings file is not part of the solution; skipping.");
continue;
}

if (string.IsNullOrWhiteSpace(setting.ConnectionId))
{
warnings.Add($"Connection reference '{logicalName}' has no ConnectionId in the settings file; skipping.");
continue;
}

var connectorId = !string.IsNullOrWhiteSpace(setting.ConnectorId) ? setting.ConnectorId : declared.ConnectorId;
connectionReferences.Add(new PlannedConnectionReference(logicalName, setting.ConnectionId.Trim(), connectorId));
}

foreach (var setting in settings.EnvironmentVariables)
{
// SchemaName is guaranteed non-empty by DeploymentSettingsFile.
var schemaName = setting.SchemaName!;

if (!declaredVariables.TryGetValue(schemaName, out var declared))
{
warnings.Add($"Environment variable '{schemaName}' from the settings file is not part of the solution; skipping.");
continue;
}

// pac create-settings emits empty placeholders for unfilled values; treat
// those as "not provided" rather than overwriting with a blank value.
if (string.IsNullOrWhiteSpace(setting.Value))
{
warnings.Add($"Environment variable '{schemaName}' has no value in the settings file; skipping.");
continue;
}

environmentVariables.Add(new PlannedEnvironmentVariable(schemaName, setting.Value, declared.ValueId));
}

return new ComponentParameterPlan(connectionReferences, environmentVariables, warnings);
}
}
Loading