diff --git a/src/TALXIS.CLI.Features.Workspace/ComponentApplyScaffoldCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentApplyScaffoldCliCommand.cs
new file mode 100644
index 00000000..8eca8a92
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Workspace/ComponentApplyScaffoldCliCommand.cs
@@ -0,0 +1,64 @@
+using DotMake.CommandLine;
+using Microsoft.Extensions.Logging;
+using TALXIS.CLI.Core;
+using TALXIS.CLI.Logging;
+using TALXIS.Platform.Metadata.Serialization.Xml.Scaffolding;
+
+namespace TALXIS.CLI.Features.Workspace;
+
+///
+/// Applies a rendered component scaffold to the solution in one in-process transaction.
+/// Component-agnostic: the component type selects the applier inside the platform metadata
+/// library, and payload is passed as role=path files and name=value parameters. Replaces
+/// template PowerShell post-action scripts during the template-to-metadata migration.
+///
+[CliIdempotent]
+[CliCommand(
+ Description = "Apply a rendered component scaffold to the solution (used by template post-actions)",
+ Name = "apply-scaffold")]
+public class ComponentApplyScaffoldCliCommand : TxcLeafCommand
+{
+ protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ComponentApplyScaffoldCliCommand));
+
+ [CliOption(Name = "--component-type", Description = "Component type matching the template's componentType tag (e.g. Attribute)", Required = true)]
+ public string ComponentType { get; set; } = null!;
+
+ [CliOption(Name = "--solution-root", Description = "Folder containing the unpacked solution files (Other/, Entities/, OptionSets/)", Required = true)]
+ public string SolutionRoot { get; set; } = null!;
+
+ [CliOption(Name = "--file", Description = "Rendered file in role=path format (e.g. attribute=.template.temp/attribute.xml). Can be specified multiple times.")]
+ public List File { get; set; } = new();
+
+ [CliOption(Name = "--param", Description = "Scalar parameter in name=value format (e.g. entity=udpp_warehouseitem). Can be specified multiple times.")]
+ public List Param { get; set; } = new();
+
+ protected override Task ExecuteAsync()
+ {
+ var result = ComponentScaffold.Apply(new ComponentScaffoldRequest
+ {
+ ComponentType = ComponentType,
+ SolutionRootPath = Path.GetFullPath(SolutionRoot),
+ Files = ParsePairs(File, "--file").ToDictionary(p => p.Key, p => Path.GetFullPath(p.Value), StringComparer.OrdinalIgnoreCase),
+ Parameters = ParsePairs(Param, "--param").ToDictionary(p => p.Key, p => p.Value, StringComparer.OrdinalIgnoreCase),
+ });
+
+ foreach (var warning in result.Warnings)
+ {
+ Logger.LogWarning("{Warning}", warning);
+ }
+
+ OutputFormatter.WriteResult("succeeded", $"{ComponentType} scaffold applied");
+ return Task.FromResult(ExitSuccess);
+ }
+
+ private static IEnumerable> ParsePairs(IEnumerable pairs, string optionName)
+ {
+ foreach (var pair in pairs)
+ {
+ var idx = pair.IndexOf('=');
+ if (idx <= 0 || idx == pair.Length - 1)
+ throw new ArgumentException($"Invalid {optionName} format: '{pair}'. Use key=value.");
+ yield return new KeyValuePair(pair.Substring(0, idx), pair.Substring(idx + 1));
+ }
+ }
+}
diff --git a/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs
index 80c09d58..efacfdd1 100644
--- a/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs
+++ b/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs
@@ -13,6 +13,7 @@ namespace TALXIS.CLI.Features.Workspace;
Children = new[]
{
typeof(ComponentCreateCliCommand),
+ typeof(ComponentApplyScaffoldCliCommand),
},
ShortFormAutoGenerate = CliNameAutoGenerate.None)]
public class ComponentCliCommand
diff --git a/src/TALXIS.CLI.Features.Workspace/Controls/ControlAttachCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Controls/ControlBindCliCommand.cs
similarity index 50%
rename from src/TALXIS.CLI.Features.Workspace/Controls/ControlAttachCliCommand.cs
rename to src/TALXIS.CLI.Features.Workspace/Controls/ControlBindCliCommand.cs
index 65feb63e..3a706019 100644
--- a/src/TALXIS.CLI.Features.Workspace/Controls/ControlAttachCliCommand.cs
+++ b/src/TALXIS.CLI.Features.Workspace/Controls/ControlBindCliCommand.cs
@@ -1,47 +1,52 @@
+using System.Xml.Linq;
using DotMake.CommandLine;
using Microsoft.Extensions.Logging;
using TALXIS.CLI.Core;
using TALXIS.CLI.Core.Contracts.Packaging;
using TALXIS.CLI.Logging;
+using TALXIS.Platform.Metadata.Components;
+using TALXIS.Platform.Metadata.Controls;
+using TALXIS.Platform.Metadata.Serialization.Xml;
+using TALXIS.Platform.Metadata.Serialization.Xml.Controls;
using TALXIS.Platform.Metadata.Validation;
namespace TALXIS.CLI.Features.Workspace.Controls;
///
-/// Overlays a PCF custom control on an existing subgrid of a form. The control's
+/// Binds a PCF custom control to an existing subgrid of a form. The control's
/// ControlManifest.xml supplies the parameter schema — resolved from a NuGet
-/// package name (downloaded automatically, like env pkg import) or from a local
-/// file (bare manifest, solution zip, pdpkg.zip, or nupkg); dataset binding is copied
-/// from the host subgrid; the modified form is re-validated with the platform metadata
-/// schema validator.
+/// package name (downloaded automatically, like env pkg import), from a local
+/// file (bare manifest, solution zip, pdpkg.zip, or nupkg), or from a control project
+/// folder / .csproj; dataset binding is copied from the host subgrid; the
+/// modified form is re-validated with the platform metadata schema validator.
///
[CliIdempotent]
[CliCommand(
- Description = "Attach a custom control to a subgrid on a form, driven by the control's manifest",
- Name = "attach")]
-public class ControlAttachCliCommand : TxcLeafCommand
+ Description = "Bind a custom control to a subgrid on a form, driven by the control's manifest",
+ Name = "bind")]
+public class ControlBindCliCommand : TxcLeafCommand
{
- protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ControlAttachCliCommand));
+ protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ControlBindCliCommand));
[CliOption(Name = "--output", Aliases = ["-o"], Description = "Solution project root containing the Entities folder", Required = true)]
public string OutputPath { get; set; } = null!;
- [CliOption(Name = "--entity", Description = "Logical name of the entity that owns the form (e.g. almlab_warehouselocation)", Required = true)]
- public string EntityLogicalName { get; set; } = null!;
+ [CliOption(Name = "--entity", Description = "Logical name of the entity that owns the form (e.g. almlab_warehouselocation). Optional when the form can be resolved unambiguously without it.", Required = false)]
+ public string? EntityLogicalName { get; set; }
[CliOption(Name = "--form-type", Description = "Form type folder name", Required = false)]
public string FormType { get; set; } = "main";
- [CliOption(Name = "--form-id", Description = "Form GUID (without braces). Optional when the entity has exactly one form of the given type.", Required = false)]
+ [CliOption(Name = "--form-id", Description = "Form GUID (without braces). Optional when exactly one form of the given type is in scope.", Required = false)]
public string? FormId { get; set; }
- [CliOption(Name = "--target-control", Description = "FormXml id of the subgrid to overlay (e.g. subgrid)", Required = true)]
+ [CliOption(Name = "--target-control", Description = "FormXml id of the subgrid to bind to (e.g. subgrid)", Required = true)]
public string TargetControlId { get; set; } = null!;
- [CliOption(Name = "--package", Description = "NuGet package name of the control (downloaded automatically), or local path to its ControlManifest.xml / solution .zip / .pdpkg.zip / .nupkg", Required = true)]
- public string Package { get; set; } = null!;
+ [CliOption(Name = "--source", Description = "NuGet package name of the control (downloaded automatically), local path to its ControlManifest.xml / solution .zip / .pdpkg.zip / .nupkg, or the control project's folder / .csproj", Required = true)]
+ public string Source { get; set; } = null!;
- [CliOption(Name = "--version", Description = "NuGet package version (only when '--package' is a NuGet name).", Required = false)]
+ [CliOption(Name = "--version", Description = "NuGet package version (only when '--source' is a NuGet name).", Required = false)]
public string PackageVersion { get; set; } = "latest";
[CliOption(Name = "--control-name", Description = "Publisher-prefixed control name for FormXml (e.g. talxis_TALXIS.PCF.Grid). Required only when it cannot be resolved from the manifest source.", Required = false)]
@@ -50,7 +55,7 @@ public class ControlAttachCliCommand : TxcLeafCommand
[CliOption(Description = "Control parameters in key=value format (validated against the manifest). Can be specified multiple times.")]
public List Param { get; set; } = new();
- [CliOption(Description = "Replace an existing custom control attachment on the same subgrid.")]
+ [CliOption(Description = "Replace an existing custom control binding on the same subgrid.")]
public bool Force { get; set; }
private readonly NuGetPackageInstallerService _packageInstaller = new();
@@ -59,19 +64,19 @@ protected override async Task ExecuteAsync()
{
string manifestSource;
string? tempWorkingDirectory = null;
- if (File.Exists(Package))
+ if (File.Exists(Source) || Directory.Exists(Source))
{
- manifestSource = Path.GetFullPath(Package);
+ manifestSource = Path.GetFullPath(Source);
}
- else if (Package.IndexOfAny(['\\', '/']) >= 0 || HasManifestFileExtension(Package))
+ else if (Source.IndexOfAny(['\\', '/']) >= 0 || HasManifestFileExtension(Source))
{
// Looks like a file path (NuGet ids contain dots but never these extensions).
- Logger.LogError("Manifest source not found: {Package}", Package);
+ Logger.LogError("Manifest source not found: {Source}", Source);
return ExitValidationError;
}
else
{
- var install = await _packageInstaller.InstallAsync(new NuGetPackageInstallOptions(Package, PackageVersion, null));
+ var install = await _packageInstaller.InstallAsync(new NuGetPackageInstallOptions(Source, PackageVersion, null));
Logger.LogInformation("Resolved {PackageName} version {Version}", install.PackageName, install.ResolvedVersion);
manifestSource = install.DownloadedPackagePath;
if (install.UsesTemporaryWorkingDirectory)
@@ -80,7 +85,7 @@ protected override async Task ExecuteAsync()
try
{
- return AttachFromManifest(manifestSource);
+ return BindFromManifest(manifestSource);
}
finally
{
@@ -89,11 +94,12 @@ protected override async Task ExecuteAsync()
}
}
- private int AttachFromManifest(string manifestSource)
+ private int BindFromManifest(string manifestSource)
{
- var manifest = ControlManifestReader.Read(manifestSource);
+ var control = CustomControlReader.Read(manifestSource);
+ var manifest = control.Manifest;
- var controlName = ControlName ?? manifest.PrefixedName;
+ var controlName = ControlName ?? control.Name;
if (string.IsNullOrEmpty(controlName))
{
Logger.LogError("The publisher-prefixed control name could not be resolved from '{Manifest}'. Pass it explicitly with --control-name (e.g. talxis_{Qualified}).", manifestSource, manifest.QualifiedName);
@@ -112,52 +118,104 @@ private int AttachFromManifest(string manifestSource)
var formFile = ResolveFormFile();
var preErrors = CountSchemaErrors(formFile);
- var result = FormControlAttachmentService.Attach(new ControlAttachmentRequest
+ ControlBindingResult result;
+ try
+ {
+ result = BindToFormFile(formFile, new ControlBindingRequest
+ {
+ TargetControlId = TargetControlId,
+ Manifest = manifest,
+ ControlName = controlName,
+ Parameters = parameters,
+ Force = Force,
+ });
+ }
+ catch (InvalidOperationException ex) when (ex.Message.Contains("already bound"))
{
- FormFilePath = formFile,
- TargetControlId = TargetControlId,
- Manifest = manifest,
- ControlName = controlName,
- Parameters = parameters,
- Force = Force,
- });
+ Logger.LogError("{Message} Use --force to replace it.", ex.Message);
+ return ExitValidationError;
+ }
var postErrors = CountSchemaErrors(formFile);
if (postErrors > preErrors)
Logger.LogWarning("Schema validation reports {New} new issue(s) on {File} after the change — run 'txc workspace validate' for details.", postErrors - preErrors, formFile);
- var action = result.ReplacedExisting ? "replaced on" : "attached to";
+ var action = result.ReplacedExisting ? "replaced on" : "bound to";
OutputFormatter.WriteResult("succeeded", $"{controlName} {action} '{TargetControlId}' in {formFile}");
return ExitSuccess;
}
+ // File-level adapter over the in-memory operation: load the form body into the
+ // metadata model, bind, and write the modified body back into the same document.
+ private static ControlBindingResult BindToFormFile(string formFile, ControlBindingRequest request)
+ {
+ var doc = XDocument.Load(formFile);
+ var formElement = doc.Descendants("form").FirstOrDefault()
+ ?? throw new InvalidOperationException($"No