From 06d48886e65a58a8940871830b5ecc256c2b7db3 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 11 May 2026 18:25:57 +0200 Subject: [PATCH 01/10] chore: add TALXIS.Platform.Metadata 0.5.0-preview.1 to Core First step in consolidating component type resolution. Platform-metadata provides the authoritative ComponentType enum, ComponentDefinitionRegistry, and the new GetByName() lookup with alias support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj b/src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj index c63cc97..49d08f7 100644 --- a/src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj +++ b/src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj @@ -14,6 +14,7 @@ + From 3eb3bb381c7983c64ec7f8889663771148ee5ad3 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 11 May 2026 18:28:05 +0200 Subject: [PATCH 02/10] feat: add top-level txc component type list/explain commands New metamodel introspection commands backed by ComponentDefinitionRegistry: - txc component type list: shows all registered types with aliases and identity - txc component type explain : detailed ComponentDefinition metadata Accepts canonical names, aliases, enum names, and integer type codes via GetByName() cascading lookup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Component/ComponentCliCommand.cs | 22 ++++ .../Component/ComponentTypeCliCommand.cs | 20 ++++ .../ComponentTypeExplainCliCommand.cs | 109 ++++++++++++++++++ .../Component/ComponentTypeListCliCommand.cs | 84 ++++++++++++++ src/TALXIS.CLI/TxcCliCommand.cs | 2 +- 5 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 src/TALXIS.CLI/Component/ComponentCliCommand.cs create mode 100644 src/TALXIS.CLI/Component/ComponentTypeCliCommand.cs create mode 100644 src/TALXIS.CLI/Component/ComponentTypeExplainCliCommand.cs create mode 100644 src/TALXIS.CLI/Component/ComponentTypeListCliCommand.cs diff --git a/src/TALXIS.CLI/Component/ComponentCliCommand.cs b/src/TALXIS.CLI/Component/ComponentCliCommand.cs new file mode 100644 index 0000000..96aa3cb --- /dev/null +++ b/src/TALXIS.CLI/Component/ComponentCliCommand.cs @@ -0,0 +1,22 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Component; + +/// +/// Top-level component group — metamodel introspection. +/// Platform knowledge about component types, independent of any workspace or environment. +/// +[CliCommand( + Name = "component", + Alias = "comp", + Description = "Inspect component type definitions, aliases, and metadata.", + Children = new[] { typeof(ComponentTypeCliCommand) }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class ComponentCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI/Component/ComponentTypeCliCommand.cs b/src/TALXIS.CLI/Component/ComponentTypeCliCommand.cs new file mode 100644 index 0000000..9dcb184 --- /dev/null +++ b/src/TALXIS.CLI/Component/ComponentTypeCliCommand.cs @@ -0,0 +1,20 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Component; + +/// +/// Component type subgroup — list and explain registered component types. +/// +[CliCommand( + Name = "type", + Description = "Discover available component types, their aliases, and metadata.", + Children = new[] { typeof(ComponentTypeListCliCommand), typeof(ComponentTypeExplainCliCommand) }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class ComponentTypeCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI/Component/ComponentTypeExplainCliCommand.cs b/src/TALXIS.CLI/Component/ComponentTypeExplainCliCommand.cs new file mode 100644 index 0000000..8982ae2 --- /dev/null +++ b/src/TALXIS.CLI/Component/ComponentTypeExplainCliCommand.cs @@ -0,0 +1,109 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Logging; +using TALXIS.Platform.Metadata; + +namespace TALXIS.CLI.Component; + +/// +/// Explains a specific component type — shows full details. +/// Accepts canonical name, alias, enum name, or integer type code. +/// +[CliReadOnly] +[CliCommand( + Name = "explain", + Description = "Show detailed information about a component type." +)] +public class ComponentTypeExplainCliCommand : TxcLeafCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(); + + [CliArgument(Description = "Component type name, alias, or integer code (e.g. 'Entity', 'Table', '1').")] + public string Type { get; set; } = null!; + + protected override Task ExecuteAsync() + { + if (string.IsNullOrWhiteSpace(Type)) + { + Logger.LogError("Component type is required. Run 'txc component type list' to see available types."); + return Task.FromResult(ExitValidationError); + } + + var def = ComponentDefinitionRegistry.GetByName(Type); + if (def is null) + { + Logger.LogError("Unknown component type '{Type}'. Run 'txc component type list' to see available types.", Type); + return Task.FromResult(ExitValidationError); + } + + var data = new + { + typeCode = (int)def.TypeCode, + name = def.Name, + aliases = def.Aliases ?? (IReadOnlyList)Array.Empty(), + serializedName = def.SerializedName, + directory = def.Directory, + filePattern = def.FilePattern, + identity = def.Identity.ToString(), + supportsMerge = def.SupportsMerge, + isMergeable = def.IsMergeable, + isFileBacked = def.IsFileBacked, + hasSubfolders = def.HasSubfolders, + hasParent = def.HasParent, + rootComponent = def.RootComponent, + isCustomizable = def.IsCustomizable, + canBeDeleted = def.CanBeDeleted, + primaryKeyName = def.PrimaryKeyName, + exportKeyAttributes = def.ExportKeyAttributes + }; + + OutputFormatter.WriteData(data, _ => PrintExplanation(def)); + + return Task.FromResult(ExitSuccess); + } + +#pragma warning disable TXC003 + private static void PrintExplanation(ComponentDefinition d) + { + const int labelWidth = -28; + + OutputWriter.WriteLine($"{"Name:",labelWidth}{d.Name}"); + OutputWriter.WriteLine($"{"Type Code:",labelWidth}{(int)d.TypeCode}"); + if (d.Aliases is { Count: > 0 }) + OutputWriter.WriteLine($"{"Aliases:",labelWidth}{string.Join(", ", d.Aliases)}"); + OutputWriter.WriteLine($"{"Serialized Name:",labelWidth}{d.SerializedName}"); + OutputWriter.WriteLine($"{"Directory:",labelWidth}{d.Directory}"); + OutputWriter.WriteLine($"{"File Pattern:",labelWidth}{d.FilePattern}"); + OutputWriter.WriteLine($"{"Identity Strategy:",labelWidth}{d.Identity}"); + + OutputWriter.WriteLine(); + OutputWriter.WriteLine("Behavioral Flags:"); + OutputWriter.WriteLine($" {"Supports Merge:",labelWidth}{BoolStr(d.SupportsMerge)}"); + OutputWriter.WriteLine($" {"Is Mergeable:",labelWidth}{BoolStr(d.IsMergeable)}"); + OutputWriter.WriteLine($" {"Is File-Backed:",labelWidth}{BoolStr(d.IsFileBacked)}"); + OutputWriter.WriteLine($" {"Has Subfolders:",labelWidth}{BoolStr(d.HasSubfolders)}"); + OutputWriter.WriteLine($" {"Is Customizable:",labelWidth}{BoolStr(d.IsCustomizable)}"); + OutputWriter.WriteLine($" {"Can Be Deleted:",labelWidth}{BoolStr(d.CanBeDeleted)}"); + + if (d.HasParent) + { + OutputWriter.WriteLine(); + OutputWriter.WriteLine("Parent-Child:"); + OutputWriter.WriteLine($" {"Has Parent:",labelWidth}true"); + OutputWriter.WriteLine($" {"Root Component:",labelWidth}{d.RootComponent}"); + if (d.GroupParentComponentType.HasValue) + OutputWriter.WriteLine($" {"Group Parent Type:",labelWidth}{d.GroupParentComponentType}"); + if (!string.IsNullOrWhiteSpace(d.GroupParentComponentAttributeName)) + OutputWriter.WriteLine($" {"Group Parent Attr:",labelWidth}{d.GroupParentComponentAttributeName}"); + } + + if (!string.IsNullOrWhiteSpace(d.PrimaryKeyName)) + OutputWriter.WriteLine($"\n{"Primary Key:",labelWidth}{d.PrimaryKeyName}"); + if (!string.IsNullOrWhiteSpace(d.ExportKeyAttributes)) + OutputWriter.WriteLine($"{"Export Key Attrs:",labelWidth}{d.ExportKeyAttributes}"); + } +#pragma warning restore TXC003 + + private static string BoolStr(bool value) => value ? "true" : "false"; +} diff --git a/src/TALXIS.CLI/Component/ComponentTypeListCliCommand.cs b/src/TALXIS.CLI/Component/ComponentTypeListCliCommand.cs new file mode 100644 index 0000000..588c7ee --- /dev/null +++ b/src/TALXIS.CLI/Component/ComponentTypeListCliCommand.cs @@ -0,0 +1,84 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Logging; +using TALXIS.Platform.Metadata; + +namespace TALXIS.CLI.Component; + +/// +/// Lists all known component types from the . +/// Shows type code, canonical name, aliases, identity strategy, and directory. +/// +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List all known component types with their aliases and metadata." +)] +public class ComponentTypeListCliCommand : TxcLeafCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(); + + [CliOption(Name = "--search", Description = "Filter types by substring match on name or alias.", Required = false)] + public string? Search { get; set; } + + protected override Task ExecuteAsync() + { + var allDefs = ComponentDefinitionRegistry.GetAll() + .OrderBy(d => (int)d.TypeCode) + .ToList(); + + if (!string.IsNullOrWhiteSpace(Search)) + { + allDefs = allDefs.Where(d => + d.Name.Contains(Search, StringComparison.OrdinalIgnoreCase) || + (d.Aliases?.Any(a => a.Contains(Search, StringComparison.OrdinalIgnoreCase)) == true)) + .ToList(); + } + + var projected = allDefs.Select(d => new + { + typeCode = (int)d.TypeCode, + name = d.Name, + aliases = d.Aliases != null ? string.Join(", ", d.Aliases) : "", + identity = d.Identity.ToString(), + directory = d.Directory + }).ToList(); + + OutputFormatter.WriteList(projected, items => PrintTypeTable(items)); + + return Task.FromResult(ExitSuccess); + } + +#pragma warning disable TXC003 + private static void PrintTypeTable(IReadOnlyList items) where T : notnull + { + // Use dynamic to access anonymous type properties + var rows = items.Cast().ToList(); + if (rows.Count == 0) + { + OutputWriter.WriteLine("No component types found."); + return; + } + + int codeWidth = 6; + int nameWidth = Math.Clamp(rows.Max(r => ((string)r.name).Length), 10, 35); + int aliasWidth = Math.Clamp(rows.Max(r => ((string)r.aliases).Length), 7, 30); + int identityWidth = 10; + + string header = $"{"Code".PadRight(codeWidth)} | {"Name".PadRight(nameWidth)} | {"Aliases".PadRight(aliasWidth)} | {"Identity".PadRight(identityWidth)} | Directory"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + foreach (var r in rows) + { + OutputWriter.WriteLine( + $"{((int)r.typeCode).ToString().PadRight(codeWidth)} | " + + $"{((string)r.name).PadRight(nameWidth)} | " + + $"{((string)r.aliases).PadRight(aliasWidth)} | " + + $"{((string)r.identity).PadRight(identityWidth)} | " + + $"{(string)r.directory}"); + } + OutputWriter.WriteLine($"\n{rows.Count} component type(s)."); + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI/TxcCliCommand.cs b/src/TALXIS.CLI/TxcCliCommand.cs index 3adf67e..1c705bf 100644 --- a/src/TALXIS.CLI/TxcCliCommand.cs +++ b/src/TALXIS.CLI/TxcCliCommand.cs @@ -4,7 +4,7 @@ namespace TALXIS.CLI; [CliCommand( Description = "Tool for automating development loops in Power Platform", - Children = new[] { typeof(TALXIS.CLI.Features.Data.DataCliCommand), typeof(TALXIS.CLI.Features.Environment.EnvironmentCliCommand), typeof(TALXIS.CLI.Features.Workspace.WorkspaceCliCommand), typeof(TALXIS.CLI.Features.Config.ConfigCliCommand), typeof(TALXIS.CLI.Features.Docs.DocsCliCommand) }, + Children = new[] { typeof(TALXIS.CLI.Features.Data.DataCliCommand), typeof(TALXIS.CLI.Features.Environment.EnvironmentCliCommand), typeof(TALXIS.CLI.Features.Workspace.WorkspaceCliCommand), typeof(TALXIS.CLI.Features.Config.ConfigCliCommand), typeof(TALXIS.CLI.Features.Docs.DocsCliCommand), typeof(TALXIS.CLI.Component.ComponentCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class TxcCliCommand From ea09a8b503c575ddcfa6d7eeed6333add90a6001 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 11 May 2026 18:32:57 +0200 Subject: [PATCH 03/10] refactor: replace ComponentTypeResolver with ComponentDefinitionRegistry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete ComponentTypeResolver.cs — its hardcoded type dictionaries are replaced by TALXIS.Platform.Metadata.ComponentDefinitionRegistry which provides the authoritative ComponentType enum (~95 codes), rich ComponentDefinition metadata, and alias support via GetByName(). Update all 8 call sites in env feature commands (dependency, layer, solution component, uninstall check) to use the registry directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Dataverse/ComponentTypeResolver.cs | 86 ------------------- ...omponentDependencyDeleteCheckCliCommand.cs | 10 ++- .../ComponentDependencyListCliCommand.cs | 10 ++- .../ComponentDependencyRequiredCliCommand.cs | 10 ++- .../Dependency/DependencyOutputHelper.cs | 7 +- .../SolutionComponentAddCliCommand.cs | 12 ++- .../SolutionComponentListCliCommand.cs | 13 +-- .../SolutionComponentRemoveCliCommand.cs | 12 ++- .../SolutionUninstallCheckCliCommand.cs | 6 +- 9 files changed, 51 insertions(+), 115 deletions(-) delete mode 100644 src/TALXIS.CLI.Core/Contracts/Dataverse/ComponentTypeResolver.cs diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/ComponentTypeResolver.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/ComponentTypeResolver.cs deleted file mode 100644 index 1c705da..0000000 --- a/src/TALXIS.CLI.Core/Contracts/Dataverse/ComponentTypeResolver.cs +++ /dev/null @@ -1,86 +0,0 @@ -namespace TALXIS.CLI.Core.Contracts.Dataverse; - -/// -/// Maps between integer component-type codes and human-friendly names. -/// Supports both directions: code → name and name → code. -/// Platform types are hardcoded; SCF types can be loaded dynamically from the environment. -/// -public sealed class ComponentTypeResolver -{ - private readonly Dictionary _codeToName; - private readonly Dictionary _nameToCode; - - public ComponentTypeResolver() - { - _codeToName = new Dictionary(PlatformTypes); - _nameToCode = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var (code, name) in PlatformTypes) - _nameToCode[name] = code; - // Register common aliases - foreach (var (alias, code) in PlatformAliases) - _nameToCode[alias] = code; - } - - /// Resolves a friendly name to its integer type code. - public bool TryResolveCode(string nameOrCode, out int code) - { - if (int.TryParse(nameOrCode, out code)) - return _codeToName.ContainsKey(code) || code > 0; - return _nameToCode.TryGetValue(nameOrCode, out code); - } - - /// Returns all known friendly names for use in error messages. - public IEnumerable GetKnownNames() => _nameToCode.Keys.Order(); - - /// Resolves an integer type code to a friendly name. - public string ResolveName(int code) - => _codeToName.TryGetValue(code, out var name) ? name : code.ToString(); - - /// Well-known platform component types (static, same across all environments). - private static readonly Dictionary PlatformTypes = new() - { - [1] = "Entity", - [2] = "Attribute", - [3] = "Relationship", - [9] = "OptionSet", - [10] = "EntityRelationship", - [14] = "EntityKey", - [16] = "Privilege", - [20] = "Role", - [26] = "SavedQuery", - [29] = "Workflow", - [31] = "Report", - [36] = "EmailTemplate", - [59] = "SavedQueryVisualization", - [60] = "SystemForm", - [61] = "WebResource", - [62] = "SiteMap", - [63] = "ConnectionRole", - [66] = "CustomControl", - [70] = "FieldSecurityProfile", - [80] = "AppModule", - [91] = "PluginAssembly", - [92] = "SdkMessageProcessingStep", - [95] = "ServiceEndpoint", - [300] = "CanvasApp", - [371] = "Connector", - [380] = "EnvironmentVariableDefinition", - [381] = "EnvironmentVariableValue", - }; - - /// Common aliases developers might use on the command line. - private static readonly Dictionary PlatformAliases = new(StringComparer.OrdinalIgnoreCase) - { - ["Table"] = 1, - ["Column"] = 2, - ["Choice"] = 9, - ["View"] = 26, - ["Chart"] = 59, - ["Form"] = 60, - ["Dashboard"] = 60, - ["SecurityRole"] = 20, - ["Process"] = 29, - ["PluginStep"] = 92, - ["EnvironmentVariable"] = 380, - }; -} diff --git a/src/TALXIS.CLI.Features.Environment/Component/Dependency/ComponentDependencyDeleteCheckCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Component/Dependency/ComponentDependencyDeleteCheckCliCommand.cs index e2740cd..42c1cf3 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Dependency/ComponentDependencyDeleteCheckCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Component/Dependency/ComponentDependencyDeleteCheckCliCommand.cs @@ -4,6 +4,7 @@ using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; using TALXIS.CLI.Logging; +using TALXIS.Platform.Metadata; namespace TALXIS.CLI.Features.Environment.Component.Dependency; @@ -41,13 +42,16 @@ protected override async Task ExecuteAsync() return ExitValidationError; } - var resolver = new ComponentTypeResolver(); - if (!resolver.TryResolveCode(typeName, out var typeCode)) + var def = ComponentDefinitionRegistry.GetByName(typeName); + if (def is null && int.TryParse(typeName, out var parsedCode)) + def = ComponentDefinitionRegistry.GetByType((ComponentType)parsedCode); + if (def is null) { - var known = string.Join(", ", resolver.GetKnownNames().Take(15)); + var known = string.Join(", ", ComponentDefinitionRegistry.GetAll().Select(d => d.Name).Take(15)); Logger.LogError("Unknown component type '{Type}'. Available types: {Known}.", typeName, known); return ExitValidationError; } + var typeCode = (int)def.TypeCode; var service = TxcServices.Get(); var deps = await service.CheckDeleteAsync(Profile, id, typeCode, CancellationToken.None).ConfigureAwait(false); diff --git a/src/TALXIS.CLI.Features.Environment/Component/Dependency/ComponentDependencyListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Component/Dependency/ComponentDependencyListCliCommand.cs index ae167f8..996edd4 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Dependency/ComponentDependencyListCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Component/Dependency/ComponentDependencyListCliCommand.cs @@ -4,6 +4,7 @@ using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; using TALXIS.CLI.Logging; +using TALXIS.Platform.Metadata; namespace TALXIS.CLI.Features.Environment.Component.Dependency; @@ -41,13 +42,16 @@ protected override async Task ExecuteAsync() return ExitValidationError; } - var resolver = new ComponentTypeResolver(); - if (!resolver.TryResolveCode(typeName, out var typeCode)) + var def = ComponentDefinitionRegistry.GetByName(typeName); + if (def is null && int.TryParse(typeName, out var parsedCode)) + def = ComponentDefinitionRegistry.GetByType((ComponentType)parsedCode); + if (def is null) { - var known = string.Join(", ", resolver.GetKnownNames().Take(15)); + var known = string.Join(", ", ComponentDefinitionRegistry.GetAll().Select(d => d.Name).Take(15)); Logger.LogError("Unknown component type '{Type}'. Available types: {Known}. Or use an integer code.", typeName, known); return ExitValidationError; } + var typeCode = (int)def.TypeCode; var service = TxcServices.Get(); var deps = await service.GetDependentsAsync(Profile, id, typeCode, CancellationToken.None).ConfigureAwait(false); diff --git a/src/TALXIS.CLI.Features.Environment/Component/Dependency/ComponentDependencyRequiredCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Component/Dependency/ComponentDependencyRequiredCliCommand.cs index 091eee2..fd05824 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Dependency/ComponentDependencyRequiredCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Component/Dependency/ComponentDependencyRequiredCliCommand.cs @@ -4,6 +4,7 @@ using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; using TALXIS.CLI.Logging; +using TALXIS.Platform.Metadata; namespace TALXIS.CLI.Features.Environment.Component.Dependency; @@ -41,13 +42,16 @@ protected override async Task ExecuteAsync() return ExitValidationError; } - var resolver = new ComponentTypeResolver(); - if (!resolver.TryResolveCode(typeName, out var typeCode)) + var def = ComponentDefinitionRegistry.GetByName(typeName); + if (def is null && int.TryParse(typeName, out var parsedCode)) + def = ComponentDefinitionRegistry.GetByType((ComponentType)parsedCode); + if (def is null) { - var known = string.Join(", ", resolver.GetKnownNames().Take(15)); + var known = string.Join(", ", ComponentDefinitionRegistry.GetAll().Select(d => d.Name).Take(15)); Logger.LogError("Unknown component type '{Type}'. Available types: {Known}.", typeName, known); return ExitValidationError; } + var typeCode = (int)def.TypeCode; var service = TxcServices.Get(); var deps = await service.GetRequiredAsync(Profile, id, typeCode, CancellationToken.None).ConfigureAwait(false); diff --git a/src/TALXIS.CLI.Features.Environment/Component/Dependency/DependencyOutputHelper.cs b/src/TALXIS.CLI.Features.Environment/Component/Dependency/DependencyOutputHelper.cs index 5561671..851e201 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Dependency/DependencyOutputHelper.cs +++ b/src/TALXIS.CLI.Features.Environment/Component/Dependency/DependencyOutputHelper.cs @@ -1,5 +1,6 @@ using TALXIS.CLI.Core; using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.Platform.Metadata; namespace TALXIS.CLI.Features.Environment.Component.Dependency; @@ -8,8 +9,6 @@ namespace TALXIS.CLI.Features.Environment.Component.Dependency; /// internal static class DependencyOutputHelper { - private static readonly ComponentTypeResolver Resolver = new(); - // OutputWriter usage is intentional — called from text-renderer callbacks. #pragma warning disable TXC003 public static void PrintDependencyTable( @@ -33,8 +32,8 @@ public static void PrintDependencyTable( foreach (var d in rows) { - var depType = Resolver.ResolveName(d.DependentComponentType); - var reqType = Resolver.ResolveName(d.RequiredComponentType); + var depType = ComponentDefinitionRegistry.GetByType((ComponentType)d.DependentComponentType)?.Name ?? d.DependentComponentType.ToString(); + var reqType = ComponentDefinitionRegistry.GetByType((ComponentType)d.RequiredComponentType)?.Name ?? d.RequiredComponentType.ToString(); var depKind = d.DependencyType switch { 1 => "Internal", diff --git a/src/TALXIS.CLI.Features.Environment/Solution/Component/SolutionComponentAddCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Solution/Component/SolutionComponentAddCliCommand.cs index ab357b6..d1c0757 100644 --- a/src/TALXIS.CLI.Features.Environment/Solution/Component/SolutionComponentAddCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Solution/Component/SolutionComponentAddCliCommand.cs @@ -4,6 +4,7 @@ using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; using TALXIS.CLI.Logging; +using TALXIS.Platform.Metadata; namespace TALXIS.CLI.Features.Environment.Solution.Component; @@ -39,13 +40,16 @@ protected override async Task ExecuteAsync() return ExitValidationError; } - var resolver = new ComponentTypeResolver(); - if (!resolver.TryResolveCode(Type, out var typeCode)) + var def = ComponentDefinitionRegistry.GetByName(Type); + if (def is null && int.TryParse(Type, out var parsedCode)) + def = ComponentDefinitionRegistry.GetByType((ComponentType)parsedCode); + if (def is null) { - var known = string.Join(", ", resolver.GetKnownNames().Take(15)); + var known = string.Join(", ", ComponentDefinitionRegistry.GetAll().Select(d => d.Name).Take(15)); Logger.LogError("Unknown component type '{Type}'. Available types: {Known}. Or use an integer code.", Type, known); return ExitValidationError; } + var typeCode = (int)def.TypeCode; // Pre-check: reject managed solutions (can't add components to managed) var detailService = TxcServices.Get(); @@ -60,7 +64,7 @@ protected override async Task ExecuteAsync() var service = TxcServices.Get(); await service.AddAsync(Profile, options, CancellationToken.None).ConfigureAwait(false); - var typeName = resolver.ResolveName(typeCode); + var typeName = def.Name; OutputFormatter.WriteData( new { status = "added", solution = SolutionName, componentId = ComponentId, componentType = typeName }, _ => diff --git a/src/TALXIS.CLI.Features.Environment/Solution/Component/SolutionComponentListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Solution/Component/SolutionComponentListCliCommand.cs index f824fc0..abe3883 100644 --- a/src/TALXIS.CLI.Features.Environment/Solution/Component/SolutionComponentListCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Solution/Component/SolutionComponentListCliCommand.cs @@ -4,6 +4,7 @@ using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; using TALXIS.CLI.Logging; +using TALXIS.Platform.Metadata; namespace TALXIS.CLI.Features.Environment.Solution.Component; @@ -33,14 +34,16 @@ protected override async Task ExecuteAsync() int? typeFilter = null; if (!string.IsNullOrWhiteSpace(Type)) { - var resolver = new ComponentTypeResolver(); - if (!resolver.TryResolveCode(Type, out var code)) + var def = ComponentDefinitionRegistry.GetByName(Type); + if (def is null && int.TryParse(Type, out var parsedCode)) + def = ComponentDefinitionRegistry.GetByType((ComponentType)parsedCode); + if (def is null) { - var known = string.Join(", ", resolver.GetKnownNames().Take(15)); - Logger.LogError("Unknown component type '{Type}'. Available types: {Known}. Or use an integer code.", Type, known); + var known = string.Join(", ", ComponentDefinitionRegistry.GetAll().Select(d => d.Name).Take(15)); + Logger.LogError("Unknown component type '{Type}'. Available types: {Known}. Or use an integer code.", Type, known); return ExitValidationError; } - typeFilter = code; + typeFilter = (int)def.TypeCode; } var service = TxcServices.Get(); diff --git a/src/TALXIS.CLI.Features.Environment/Solution/Component/SolutionComponentRemoveCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Solution/Component/SolutionComponentRemoveCliCommand.cs index bab5fcc..47f8928 100644 --- a/src/TALXIS.CLI.Features.Environment/Solution/Component/SolutionComponentRemoveCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Solution/Component/SolutionComponentRemoveCliCommand.cs @@ -5,6 +5,7 @@ using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; using TALXIS.CLI.Logging; +using TALXIS.Platform.Metadata; namespace TALXIS.CLI.Features.Environment.Solution.Component; @@ -37,13 +38,16 @@ protected override async Task ExecuteAsync() return ExitValidationError; } - var resolver = new ComponentTypeResolver(); - if (!resolver.TryResolveCode(Type, out var typeCode)) + var def = ComponentDefinitionRegistry.GetByName(Type); + if (def is null && int.TryParse(Type, out var parsedCode)) + def = ComponentDefinitionRegistry.GetByType((ComponentType)parsedCode); + if (def is null) { - var known = string.Join(", ", resolver.GetKnownNames().Take(15)); + var known = string.Join(", ", ComponentDefinitionRegistry.GetAll().Select(d => d.Name).Take(15)); Logger.LogError("Unknown component type '{Type}'. Available types: {Known}. Or use an integer code.", Type, known); return ExitValidationError; } + var typeCode = (int)def.TypeCode; // Pre-check: reject managed solutions (can't remove components from managed) var detailService = TxcServices.Get(); @@ -58,7 +62,7 @@ protected override async Task ExecuteAsync() var service = TxcServices.Get(); await service.RemoveAsync(Profile, options, CancellationToken.None).ConfigureAwait(false); - var typeName = resolver.ResolveName(typeCode); + var typeName = def.Name; OutputFormatter.WriteData( new { status = "removed", solution = SolutionName, componentId = ComponentId, componentType = typeName }, _ => diff --git a/src/TALXIS.CLI.Features.Environment/Solution/SolutionUninstallCheckCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Solution/SolutionUninstallCheckCliCommand.cs index 2507641..e8696ea 100644 --- a/src/TALXIS.CLI.Features.Environment/Solution/SolutionUninstallCheckCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Solution/SolutionUninstallCheckCliCommand.cs @@ -4,6 +4,7 @@ using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; using TALXIS.CLI.Logging; +using TALXIS.Platform.Metadata; namespace TALXIS.CLI.Features.Environment.Solution; @@ -50,7 +51,6 @@ private void PrintSafe() private void PrintBlocked(IReadOnlyList deps) { - var resolver = new ComponentTypeResolver(); OutputWriter.WriteLine($"Solution '{Name}' has {deps.Count} blocking dependency(ies):\n"); string header = $"{"Required Type",-25} | {"Required ID",-36} | {"Dependent Type",-25} | {"Dependent ID",-36} | Dep.Type"; @@ -59,8 +59,8 @@ private void PrintBlocked(IReadOnlyList deps) foreach (var d in deps) { - var reqType = resolver.ResolveName(d.RequiredComponentType); - var depType = resolver.ResolveName(d.DependentComponentType); + var reqType = ComponentDefinitionRegistry.GetByType((ComponentType)d.RequiredComponentType)?.Name ?? d.RequiredComponentType.ToString(); + var depType = ComponentDefinitionRegistry.GetByType((ComponentType)d.DependentComponentType)?.Name ?? d.DependentComponentType.ToString(); var depKind = d.DependencyType switch { 1 => "Internal", From ebfdfa9714c27e2919fa125c263dd1d890a74f97 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 11 May 2026 18:35:15 +0200 Subject: [PATCH 04/10] feat: add txc env component browse command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open the Power Platform web editor for any component instance. Supports 8 platform types with dedicated URL patterns across 3 domains (make.powerapps.com, make.powerautomate.com, copilotstudio.microsoft.com) plus SCF fallback to Dynamics UCI record form. Components: - BrowserLauncher: cross-platform URL opener, headless-aware - MakerPortalUrlBuilder: static URL templates for all browsable types - ComponentBrowseCliCommand: --type, --id/--name, --entity, --solution Name resolution supported for solution (unique name) and entity (logical name → MetadataId). All other types require --id (GUID). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/TALXIS.CLI.Core/Shared/BrowserLauncher.cs | 48 ++++++ .../Browse/ComponentBrowseCliCommand.cs | 159 ++++++++++++++++++ .../Component/Browse/MakerPortalUrlBuilder.cs | 87 ++++++++++ .../Component/ComponentCliCommand.cs | 3 +- 4 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 src/TALXIS.CLI.Core/Shared/BrowserLauncher.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrlBuilder.cs diff --git a/src/TALXIS.CLI.Core/Shared/BrowserLauncher.cs b/src/TALXIS.CLI.Core/Shared/BrowserLauncher.cs new file mode 100644 index 0000000..300e815 --- /dev/null +++ b/src/TALXIS.CLI.Core/Shared/BrowserLauncher.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; + +namespace TALXIS.CLI.Core; + +/// +/// Cross-platform utility to open a URL in the default browser. +/// Headless-aware: skips browser launch in CI/non-interactive environments. +/// +public static class BrowserLauncher +{ + /// + /// Opens in the default browser. + /// In headless/CI mode, logs a warning and returns without opening. + /// + public static void Open(Uri url, ILogger logger) + { + var detector = TxcServices.Get(); + if (detector.IsHeadless) + { + logger.LogInformation("Headless mode ({Reason}) — browser not opened.", detector.Reason); + return; + } + + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Process.Start(new ProcessStartInfo(url.AbsoluteUri) { UseShellExecute = true }); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Process.Start("open", url.AbsoluteUri); + } + else + { + Process.Start("xdg-open", url.AbsoluteUri); + } + } + catch (Exception ex) + { + logger.LogWarning("Could not open browser: {Error}", ex.Message); + } + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs new file mode 100644 index 0000000..09f1732 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs @@ -0,0 +1,159 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; +using TALXIS.Platform.Metadata; + +namespace TALXIS.CLI.Features.Environment.Component.Browse; + +/// +/// Opens the Power Platform web editor for a component instance. +/// Resolves the appropriate URL based on component type and opens it in the default browser. +/// In headless mode, only prints the URL without opening the browser. +/// +[CliReadOnly] +[CliCommand( + Name = "browse", + Description = "Open the web editor for a component in the connected live environment. Requires an active profile." +)] +public class ComponentBrowseCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(); + + [CliOption(Name = "--type", Description = "Component type (name, alias, or integer code). Run 'txc component type list' to see available types.", Required = true)] + public string Type { get; set; } = null!; + + [CliOption(Name = "--id", Description = "Component GUID. Mutually exclusive with --name.", Required = false)] + public string? Id { get; set; } + + [CliOption(Name = "--name", Description = "Component friendly name (resolved to GUID). Mutually exclusive with --id. Supported for: solution (unique name), entity (logical name).", Required = false)] + public string? Name { get; set; } + + [CliOption(Name = "--entity", Description = "Entity logical name. Required for form/view types. Also provides the backing entity name for SCF types.", Required = false)] + public string? Entity { get; set; } + + [CliOption(Name = "--solution", Description = "Solution unique name for solution-scoped URLs. Resolved to GUID.", Required = false)] + public string? Solution { get; set; } + + protected override async Task ExecuteAsync() + { + // Validate --id / --name mutual exclusion + if (string.IsNullOrWhiteSpace(Id) && string.IsNullOrWhiteSpace(Name)) + { + Logger.LogError("Provide --id or --name ."); + return ExitValidationError; + } + if (!string.IsNullOrWhiteSpace(Id) && !string.IsNullOrWhiteSpace(Name)) + { + Logger.LogError("--id and --name are mutually exclusive. Provide one, not both."); + return ExitValidationError; + } + + // Resolve component type + var def = ComponentDefinitionRegistry.GetByName(Type); + ComponentType? typeCode = def?.TypeCode; + + // Allow raw integer codes even without a registered definition + if (typeCode is null && int.TryParse(Type, out var rawCode)) + typeCode = (ComponentType)rawCode; + + if (typeCode is null) + { + Logger.LogError("Unknown component type '{Type}'. Run 'txc component type list' to see available types.", Type); + return ExitValidationError; + } + + // Resolve the component GUID + Guid componentId; + if (!string.IsNullOrWhiteSpace(Id)) + { + if (!Guid.TryParse(Id, out componentId)) + { + Logger.LogError("Invalid GUID: '{Id}'.", Id); + return ExitValidationError; + } + } + else + { + // --name resolution depends on type + var resolved = await ResolveNameToGuidAsync(typeCode.Value, Name!).ConfigureAwait(false); + if (resolved is null) + return ExitValidationError; + componentId = resolved.Value; + } + + // Resolve environment ID from profile connection + var resolver = TxcServices.Get(); + var context = await resolver.ResolveAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var connection = context.Connection; + + if (connection.EnvironmentId is null) + { + Logger.LogError("Environment ID is not set on the connection. Run 'txc config connection check' to populate it."); + return ExitValidationError; + } + var environmentId = connection.EnvironmentId.Value; + + // Resolve --solution if provided + Guid? solutionId = null; + if (!string.IsNullOrWhiteSpace(Solution)) + { + var slnService = TxcServices.Get(); + var (sln, _) = await slnService.ShowAsync(Profile, Solution, CancellationToken.None).ConfigureAwait(false); + solutionId = sln.Id; + } + + // Validate type-specific requirements + if (typeCode is ComponentType.SystemForm or ComponentType.Form or ComponentType.SavedQuery + && string.IsNullOrWhiteSpace(Entity)) + { + Logger.LogError("--entity is required for form/view types."); + return ExitValidationError; + } + + // Build URL + var orgUrl = connection.EnvironmentUrl?.Replace("https://", "").TrimEnd('/'); + var url = MakerPortalUrlBuilder.Build(environmentId, orgUrl, typeCode.Value, componentId, Entity, solutionId); + + if (url is null) + { + Logger.LogError( + "Cannot build URL for type '{Type}' (code {Code}). For SCF types, provide --entity (backing entity logical name).", + Type, (int)typeCode.Value); + return ExitValidationError; + } + + // Output URL and open browser + OutputFormatter.WriteData(new { url = url.AbsoluteUri, type = def?.Name ?? typeCode.Value.ToString(), componentId }, + _ => + { + OutputWriter.WriteLine(url.AbsoluteUri); + }); + + BrowserLauncher.Open(url, Logger); + return ExitSuccess; + } + + private async Task ResolveNameToGuidAsync(ComponentType typeCode, string name) + { + switch (typeCode) + { + case ComponentType.Solution: + var slnService = TxcServices.Get(); + var (sln, _) = await slnService.ShowAsync(Profile, name, CancellationToken.None).ConfigureAwait(false); + return sln.Id; + + case ComponentType.Entity: + var metadataResolver = TxcServices.Get(); + var entityId = await metadataResolver.ResolveEntityIdAsync(Profile, name, CancellationToken.None).ConfigureAwait(false); + return entityId; + + default: + Logger.LogError("--name is not supported for type '{Type}'. Use --id instead.", Type); + return null; + } + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrlBuilder.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrlBuilder.cs new file mode 100644 index 0000000..6e480f9 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrlBuilder.cs @@ -0,0 +1,87 @@ +using TALXIS.Platform.Metadata; + +namespace TALXIS.CLI.Features.Environment.Component.Browse; + +/// +/// Builds Power Platform maker portal URLs for component types. +/// Each method corresponds to a specific component editor URL pattern. +/// +public static class MakerPortalUrlBuilder +{ + /// Default Solution GUID used when no solution context is specified for form/view/securityrole. + public const string DefaultSolutionId = "fd140aaf-4df4-11dd-bd17-0019b9312238"; + + public static Uri Solution(Guid environmentId, Guid solutionId) + => new($"https://make.powerapps.com/environments/{environmentId}/solutions/{solutionId}"); + + public static Uri Entity(Guid environmentId, Guid metadataId, Guid? solutionId = null) + => solutionId.HasValue + ? new($"https://make.powerapps.com/environments/{environmentId}/solutions/{solutionId}/entities/{metadataId}/fields") + : new($"https://make.powerapps.com/environments/{environmentId}/entities/{metadataId}/fields"); + + public static Uri Form(Guid environmentId, string entityLogicalName, Guid formId, Guid? solutionId = null) + { + var slnId = solutionId ?? Guid.Parse(DefaultSolutionId); + return new($"https://make.powerapps.com/e/{environmentId}/s/{slnId}/entity/{entityLogicalName}/form/edit/{formId}"); + } + + public static Uri View(Guid environmentId, string entityLogicalName, Guid viewId, Guid? solutionId = null) + { + var slnId = solutionId ?? Guid.Parse(DefaultSolutionId); + return new($"https://make.powerapps.com/e/{environmentId}/s/{slnId}/entity/{entityLogicalName}/view/{viewId}"); + } + + public static Uri Flow(Guid environmentId, Guid flowId, Guid? solutionId = null) + => solutionId.HasValue + ? new($"https://make.powerautomate.com/environments/{environmentId}/solutions/{solutionId}/flows/{flowId}") + : new($"https://make.powerautomate.com/environments/{environmentId}/flows/{flowId}"); + + public static Uri Bot(Guid environmentId, Guid botId, Guid? solutionId = null) + => solutionId.HasValue + ? new($"https://copilotstudio.microsoft.com/environments/{environmentId}/bots/{botId}?solutionId={solutionId}") + : new($"https://copilotstudio.microsoft.com/environments/{environmentId}/bots/{botId}"); + + public static Uri Dataflow(Guid environmentId, Guid dataflowId) + => new($"https://make.powerapps.com/environments/{environmentId}/dataintegration/list/{dataflowId}/edit"); + + public static Uri SecurityRole(Guid environmentId, Guid roleId, Guid? solutionId = null) + { + var slnId = solutionId ?? Guid.Parse(DefaultSolutionId); + return new($"https://make.powerapps.com/e/{environmentId}/s/{slnId}/securityroles/{roleId}/roleeditor"); + } + + /// + /// Fallback for SCF and unrecognized component types — opens the backing entity record form. + /// + public static Uri ScfRecord(string orgUrl, string entityLogicalName, Guid recordId) + => new($"https://{orgUrl.TrimEnd('/')}/main.aspx?forceUCI=1&newWindow=true&pagetype=entityrecord&etn={entityLogicalName}&id={recordId}"); + + /// + /// Builds the appropriate URL for a component type code. + /// Returns null if the type requires additional context that wasn't provided. + /// + public static Uri? Build( + Guid environmentId, + string? orgUrl, + ComponentType typeCode, + Guid componentId, + string? entityLogicalName = null, + Guid? solutionId = null) + { + return typeCode switch + { + ComponentType.Solution => Solution(environmentId, componentId), + ComponentType.Entity => Entity(environmentId, componentId, solutionId), + ComponentType.SystemForm when entityLogicalName != null => Form(environmentId, entityLogicalName, componentId, solutionId), + ComponentType.Form when entityLogicalName != null => Form(environmentId, entityLogicalName, componentId, solutionId), + ComponentType.SavedQuery when entityLogicalName != null => View(environmentId, entityLogicalName, componentId, solutionId), + ComponentType.Workflow => Flow(environmentId, componentId, solutionId), + ComponentType.Bot => Bot(environmentId, componentId, solutionId), + ComponentType.Dataflow => Dataflow(environmentId, componentId), + ComponentType.Role => SecurityRole(environmentId, componentId, solutionId), + // SCF / unknown — fallback to record form if org URL and entity name available + _ when orgUrl != null && entityLogicalName != null => ScfRecord(orgUrl, entityLogicalName, componentId), + _ => null + }; + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Component/ComponentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Component/ComponentCliCommand.cs index 94f582f..6e69e4c 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/ComponentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Component/ComponentCliCommand.cs @@ -5,9 +5,10 @@ namespace TALXIS.CLI.Features.Environment.Component; [CliCommand( Name = "component", Alias = "comp", - Description = "Inspect components independent of a specific solution (layers, dependencies).", + Description = "Inspect and navigate components in the live environment (layers, dependencies, browser editor).", Children = new[] { + typeof(Browse.ComponentBrowseCliCommand), typeof(Layer.ComponentLayerCliCommand), typeof(Dependency.ComponentDependencyCliCommand), }, From 1b512c714c1bf1692c8aa9f1627db4a3c55cdae8 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 11 May 2026 18:37:10 +0200 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20delete=20dead=20code=20?= =?UTF-8?q?=E2=80=94=20metamodel=20stubs,=20old=20workspace=20type=20comma?= =?UTF-8?q?nds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete: - Metamodel/ stubs (3 files, never wired) — replaced by txc component type list/explain - ComponentTypeListCliCommand.cs — moved to top-level - ComponentTypeExplainCliCommand.cs — moved to top-level - Nested ComponentTypeCliCommand/ComponentParameterCliCommand subgroups Restructure workspace ComponentCliCommand: direct children are now create and parameter-list. Type introspection lives at top level. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ComponentCliCommand.cs | 35 +++--------- .../ComponentParameterListCliCommand.cs | 4 +- .../ComponentTypeExplainCliCommand.cs | 54 ------------------- .../ComponentTypeListCliCommand.cs | 48 ----------------- .../Metamodel/MetamodelCliCommand.cs | 36 ------------- .../Metamodel/MetamodelDescribeCliCommand.cs | 27 ---------- .../Metamodel/MetamodelListCliCommand.cs | 27 ---------- 7 files changed, 9 insertions(+), 222 deletions(-) delete mode 100644 src/TALXIS.CLI.Features.Workspace/ComponentTypeExplainCliCommand.cs delete mode 100644 src/TALXIS.CLI.Features.Workspace/ComponentTypeListCliCommand.cs delete mode 100644 src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelCliCommand.cs delete mode 100644 src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelDescribeCliCommand.cs delete mode 100644 src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelListCliCommand.cs diff --git a/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs index 731ac0b..8fc8e64 100644 --- a/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs @@ -2,13 +2,18 @@ namespace TALXIS.CLI.Features.Workspace; +/// +/// Workspace component operations — create and inspect components in the local repository. +/// Component type introspection (list, explain) is at the top level: txc component type list/explain. +/// [CliCommand( - Description = "Create or modify components of your solution", + Description = "Create and inspect components in your local workspace", Name = "component", Alias = "comp", Children = new[] { - typeof(ComponentCreateCliCommand) + typeof(ComponentCreateCliCommand), + typeof(ComponentParameterListCliCommand), }, ShortFormAutoGenerate = CliNameAutoGenerate.None)] public class ComponentCliCommand @@ -17,30 +22,4 @@ public void Run(CliContext context) { context.ShowHelp(); } - - [CliCommand( - Description = "Parameters for a specific component", - Children = new[] { typeof(ComponentParameterListCliCommand) }, - Name = "parameter", - ShortFormAutoGenerate = CliNameAutoGenerate.None)] - public class ComponentParameterCliCommand - { - public void Run(CliContext context) - { - context.ShowHelp(); - } - } - - [CliCommand( - Description = "Types of available components", - Children = new[] { typeof(ComponentTypeListCliCommand), typeof(ComponentTypeExplainCliCommand) }, - Name = "type", - ShortFormAutoGenerate = CliNameAutoGenerate.None)] - public class ComponentTypeCliCommand - { - public void Run(CliContext context) - { - context.ShowHelp(); - } - } } diff --git a/src/TALXIS.CLI.Features.Workspace/ComponentParameterListCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentParameterListCliCommand.cs index ce45726..d0c0c19 100644 --- a/src/TALXIS.CLI.Features.Workspace/ComponentParameterListCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/ComponentParameterListCliCommand.cs @@ -13,8 +13,8 @@ namespace TALXIS.CLI.Features.Workspace; /// [CliReadOnly] [CliCommand( - Description = "Lists parameters for a specific component", - Name = "list" + Description = "Lists parameters for a specific component template. Use 'txc component type explain ' for type metadata.", + Name = "parameter-list" )] public class ComponentParameterListCliCommand : TxcLeafCommand { diff --git a/src/TALXIS.CLI.Features.Workspace/ComponentTypeExplainCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentTypeExplainCliCommand.cs deleted file mode 100644 index 026efca..0000000 --- a/src/TALXIS.CLI.Features.Workspace/ComponentTypeExplainCliCommand.cs +++ /dev/null @@ -1,54 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Features.Workspace.TemplateEngine; - -namespace TALXIS.CLI.Features.Workspace; - -[CliReadOnly] -[CliCommand( - Description = "Explains a solution component type. Use names returned by 'component type list' command", - Name = "explain" -)] -public class ComponentTypeExplainCliCommand : TxcLeafCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ComponentTypeExplainCliCommand)); - - [CliArgument(Description = "Type of the component to explain")] - public required string Type { get; set; } - - protected override async Task ExecuteAsync() - { - if (string.IsNullOrWhiteSpace(Type)) - { - Logger.LogError("Please provide a component type"); - return ExitValidationError; - } - - using var scaffolder = new TemplateInvoker(); - var templates = await scaffolder.ListTemplatesAsync(); - var template = templates?.FirstOrDefault(t => string.Equals(t.Name, Type, StringComparison.OrdinalIgnoreCase) - || t.ShortNameList.Any(sn => string.Equals(sn, Type, StringComparison.OrdinalIgnoreCase))); - - if (template == null) - { - Logger.LogError("Component template {Type} not found", Type); - return ExitValidationError; - } - - var data = new - { - type = template.ShortNameList.FirstOrDefault(), - description = template.Description - }; - - OutputFormatter.WriteData(data, d => - { - OutputWriter.WriteLine($"Type: {d.type}"); - OutputWriter.WriteLine($"Description: {d.description}"); - }); - - return ExitSuccess; - } -} diff --git a/src/TALXIS.CLI.Features.Workspace/ComponentTypeListCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentTypeListCliCommand.cs deleted file mode 100644 index da7367c..0000000 --- a/src/TALXIS.CLI.Features.Workspace/ComponentTypeListCliCommand.cs +++ /dev/null @@ -1,48 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Logging; -using TALXIS.CLI.Features.Workspace.TemplateEngine; - -namespace TALXIS.CLI.Features.Workspace; - -[CliReadOnly] -[CliCommand( - Description = "Lists available solution components", - Name = "list" -)] -public class ComponentTypeListCliCommand : TxcLeafCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ComponentTypeListCliCommand)); - - protected override async Task ExecuteAsync() - { - using var scaffolder = new TemplateInvoker(); - var templates = await scaffolder.ListTemplatesAsync(); - if (templates == null || !templates.Any()) - { - OutputFormatter.WriteList(Array.Empty().ToList().AsReadOnly(), items => - { - OutputWriter.WriteLine("No components available."); - }); - return ExitSuccess; - } - - var projected = templates.Select(t => new - { - shortName = t.ShortNameList.FirstOrDefault(), - description = t.Description - }).ToList(); - - OutputFormatter.WriteList(projected, items => - { - foreach (var item in items) - { - var desc = string.IsNullOrWhiteSpace(item.description) ? "" : $" - {item.description}"; - OutputWriter.WriteLine($"- {item.shortName}{desc}"); - } - }); - - return ExitSuccess; - } -} diff --git a/src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelCliCommand.cs deleted file mode 100644 index 3079ae0..0000000 --- a/src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelCliCommand.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Reserved skeleton — intentionally NOT wired into any parent's `Children` array. -// -// Group parent for the future `txc workspace metamodel` family. `metamodel describe` -// and `metamodel list` will expose the metamodel that describes the shape of -// user-authored workspace artifacts — the grammar driving validation, the -// language server, and richer scaffolding beyond the current templating- -// engine path. -// -// Because it is unreachable from `WorkspaceCliCommand.Children`, DotMake will -// not surface it or its children to the CLI and the MCP adapter will not -// surface them as tools. Activating the group is a two-edit change: -// 1. Add `typeof(MetamodelCliCommand)` to `WorkspaceCliCommand.Children`. -// 2. Replace the `NotImplementedException` bodies under Metamodel/ with real -// implementations. -// -// Please read `CONTRIBUTING.md` before changing the shape of this class. - -using DotMake.CommandLine; - -namespace TALXIS.CLI.Features.Workspace.Metamodel; - -[CliCommand( - Description = "Reserved — not yet implemented.", - Name = "metamodel", - Children = new[] - { - typeof(MetamodelDescribeCliCommand), - typeof(MetamodelListCliCommand) - })] -public sealed class MetamodelCliCommand -{ - public void Run(CliContext context) - { - context.ShowHelp(); - } -} diff --git a/src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelDescribeCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelDescribeCliCommand.cs deleted file mode 100644 index ee9aa85..0000000 --- a/src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelDescribeCliCommand.cs +++ /dev/null @@ -1,27 +0,0 @@ -#pragma warning disable TXC001 // Reserved stub — will inherit TxcLeafCommand when implemented -#pragma warning disable TXC004 // Reserved stub — safety annotation deferred until implemented -// Reserved skeleton — intentionally NOT wired into any parent's `Children` array. -// -// `txc workspace metamodel describe` will render a human-readable description -// of a named metamodel entity (component kind, relationship, validation rule...) -// that governs user-authored workspace artifacts. -// -// See MetamodelCliCommand.cs and CONTRIBUTING.md for the activation procedure -// and the design philosophy that governs this surface. - -using DotMake.CommandLine; - -namespace TALXIS.CLI.Features.Workspace.Metamodel; - -[CliCommand( - Description = "Reserved — not yet implemented.", - Name = "describe")] -public sealed class MetamodelDescribeCliCommand -{ - public Task RunAsync() - { - throw new NotImplementedException( - "`workspace metamodel describe` is reserved for a future metamodel " - + "inspection workflow and is not yet implemented. See CONTRIBUTING.md."); - } -} diff --git a/src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelListCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelListCliCommand.cs deleted file mode 100644 index cb65272..0000000 --- a/src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelListCliCommand.cs +++ /dev/null @@ -1,27 +0,0 @@ -#pragma warning disable TXC001 // Reserved stub — will inherit TxcLeafCommand when implemented -#pragma warning disable TXC004 // Reserved stub — safety annotation deferred until implemented -// Reserved skeleton — intentionally NOT wired into any parent's `Children` array. -// -// `txc workspace metamodel list` will enumerate metamodel entities (component -// kinds, relationship types, validation rules...) that govern user-authored -// workspace artifacts, with lightweight filtering. -// -// See MetamodelCliCommand.cs and CONTRIBUTING.md for the activation procedure -// and the design philosophy that governs this surface. - -using DotMake.CommandLine; - -namespace TALXIS.CLI.Features.Workspace.Metamodel; - -[CliCommand( - Description = "Reserved — not yet implemented.", - Name = "list")] -public sealed class MetamodelListCliCommand -{ - public Task RunAsync() - { - throw new NotImplementedException( - "`workspace metamodel list` is reserved for a future metamodel " - + "enumeration workflow and is not yet implemented. See CONTRIBUTING.md."); - } -} From 0a43db3e9d3f5c559ad90245395a98418fd23456 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 11 May 2026 18:42:25 +0200 Subject: [PATCH 06/10] test: update integration tests for new command paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update CLI, MCP, and equivalence tests to reflect: - component type list/explain moved to top-level (txc component type ...) - workspace component parameter-list renamed from parameter list - MCP tool names updated to match new command paths - Assertions updated: pp-entity → Entity (registry-backed) All 26 tests pass, 5 skipped (env-dependent). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/TALXIS.CLI.IntegrationTests/CliTests.cs | 22 +++++++++---------- .../EquivalenceTests.cs | 14 ++++++------ tests/TALXIS.CLI.IntegrationTests/McpTests.cs | 11 +++++----- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/tests/TALXIS.CLI.IntegrationTests/CliTests.cs b/tests/TALXIS.CLI.IntegrationTests/CliTests.cs index ae9def5..8ab1758 100644 --- a/tests/TALXIS.CLI.IntegrationTests/CliTests.cs +++ b/tests/TALXIS.CLI.IntegrationTests/CliTests.cs @@ -10,9 +10,9 @@ namespace TALXIS.CLI.IntegrationTests; public class CliTests { [Theory] - [InlineData("workspace component type list")] - [InlineData("workspace component type explain pp-entity")] - [InlineData("workspace component parameter list pp-entity")] + [InlineData("component type list")] + [InlineData("component type explain Entity")] + [InlineData("workspace component parameter-list pp-entity")] [InlineData("environment package import --help")] [InlineData("environment package uninstall --help")] [InlineData("environment solution import --help")] @@ -30,21 +30,21 @@ public async Task Command_ExecutesSuccessfully(string command) } [Fact] - public async Task WorkspaceComponentList_ContainsExpectedComponents() + public async Task ComponentTypeList_ContainsExpectedTypes() { - var output = await CliRunner.RunAsync("workspace component type list"); + var output = await CliRunner.RunAsync("component type list"); - Assert.Contains("pp-entity", output); + Assert.Contains("Entity", output); } [Fact] - public async Task WorkspaceComponentType_ReturnsComponentDetails() + public async Task ComponentTypeExplain_ReturnsTypeDetails() { - var output = await CliRunner.RunAsync("workspace component type explain pp-entity"); + var output = await CliRunner.RunAsync("component type explain Entity"); - // Output is JSON when stdout is redirected (piped) — the new TxcLeafCommand + // Output is JSON when stdout is redirected (piped) — the TxcLeafCommand // base auto-detects format, so integration tests see JSON instead of plain text. - Assert.Contains("pp-entity", output); - Assert.Contains("description", output); + Assert.Contains("Entity", output); + Assert.Contains("Table", output); // alias } } diff --git a/tests/TALXIS.CLI.IntegrationTests/EquivalenceTests.cs b/tests/TALXIS.CLI.IntegrationTests/EquivalenceTests.cs index 85f4c03..ef39b73 100644 --- a/tests/TALXIS.CLI.IntegrationTests/EquivalenceTests.cs +++ b/tests/TALXIS.CLI.IntegrationTests/EquivalenceTests.cs @@ -31,16 +31,16 @@ public static IEnumerable GetTestCases() { yield return new object[] { - "workspace component type list", - "workspace_component_type_list", + "component type list", + "component_type_list", new Dictionary() }; yield return new object[] { - "workspace component type explain pp-entity", - "workspace_component_type_explain", - new Dictionary { { "Type", "pp-entity" } } + "component type explain Entity", + "component_type_explain", + new Dictionary { { "Type", "Entity" } } }; yield return new object[] @@ -52,8 +52,8 @@ public static IEnumerable GetTestCases() yield return new object[] { - "workspace component parameter list pp-entity", - "workspace_component_parameter_list", + "workspace component parameter-list pp-entity", + "workspace_component_parameter-list", new Dictionary { { "ShortName", "pp-entity" } } }; } diff --git a/tests/TALXIS.CLI.IntegrationTests/McpTests.cs b/tests/TALXIS.CLI.IntegrationTests/McpTests.cs index 602adde..9043f39 100644 --- a/tests/TALXIS.CLI.IntegrationTests/McpTests.cs +++ b/tests/TALXIS.CLI.IntegrationTests/McpTests.cs @@ -33,7 +33,7 @@ public async Task ListTools_ReturnsAlwaysOnTools() public async Task ExecuteOperation_WorkspaceComponentList_ReturnsValidResponse() { var client = await McpTestClient.InstanceAsync; - var args = new Dictionary { { "operation", "workspace_component_type_list" } }; + var args = new Dictionary { { "operation", "component_type_list" } }; var result = await client.CallToolAsync("execute_operation", args); @@ -49,18 +49,17 @@ public async Task ExecuteOperation_WorkspaceComponentExplain_ReturnsComponentDet // First verify the tool exists in the catalog by listing component types. // This also triggers template package auto-installation if needed. - var listArgs = new Dictionary { { "operation", "workspace_component_type_list" } }; + var listArgs = new Dictionary { { "operation", "component_type_list" } }; var listResult = await client.CallToolAsync("execute_operation", listArgs); - // If component type list returned empty or error, templates aren't available — skip + // If component type list returned empty or error, registry isn't available — skip var listText = listResult.Content?.OfType().FirstOrDefault()?.Text ?? ""; if (listResult.IsError == true || listText == "[]" || string.IsNullOrWhiteSpace(listText)) { - // Template package not available on this runner return; } - var args = new Dictionary { { "operation", "workspace_component_type_explain" }, { "arguments", "{\"Type\": \"pp-entity\"}" } }; + var args = new Dictionary { { "operation", "component_type_explain" }, { "arguments", "{\"Type\": \"Entity\"}" } }; var result = await client.CallToolAsync("execute_operation", args); Assert.NotNull(result.Content); @@ -74,7 +73,7 @@ public async Task ExecuteOperation_WorkspaceComponentExplain_ReturnsComponentDet if (result.Content[0] is TextContentBlock textBlock) { - Assert.Contains("pp-entity", textBlock.Text); + Assert.Contains("Entity", textBlock.Text); } } } From 609848ef4b02fe4305f33854640c1de0d014e250 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 11 May 2026 19:21:19 +0200 Subject: [PATCH 07/10] feat: extend browse with app launch + UCI deep links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support opening apps at runtime and deep-linking into specific pages within model-driven and canvas apps. Model-driven apps (AppModule): - App shell by name or GUID - Deep-link via --pagetype: entityrecord, entitylist, dashboard, webresource, control, custom, inlinedialog, genux, search - Record/form/view/dashboard/custom-page/PCF control/genux targeting - Form field pre-population via --extraqs - UI options: --navbar (on/off/entity), --cmdbar (true/false) Canvas apps: - Player URL with --screen navigation and --param custom parameters - --hidenavbar for embedding/testing Reports: - Viewer URL with --report-action (run/filter) URL builder uses generic AppModuleDeepLink() with pagetype + params dictionary — automatically supports future page types without code changes. UCI page types sourced from decompiled D365CE server 9.2 RedirectUtility.cs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Browse/ComponentBrowseCliCommand.cs | 253 ++++++++++++++---- .../Component/Browse/MakerPortalUrlBuilder.cs | 71 ++++- 2 files changed, 266 insertions(+), 58 deletions(-) diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs index 09f1732..6e3c72f 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs @@ -38,66 +38,219 @@ public class ComponentBrowseCliCommand : ProfiledCliCommand [CliOption(Name = "--solution", Description = "Solution unique name for solution-scoped URLs. Resolved to GUID.", Required = false)] public string? Solution { get; set; } + // ── App deep-link options (model-driven apps) ── + + [CliOption(Name = "--pagetype", Description = "UCI page type for deep-linking within an app: entityrecord, entitylist, dashboard, webresource, control, custom, inlinedialog, genux, search.", Required = false)] + public string? PageType { get; set; } + + [CliOption(Name = "--record", Description = "Record GUID to open in an entity form (pagetype=entityrecord).", Required = false)] + public string? Record { get; set; } + + [CliOption(Name = "--formid", Description = "Specific form GUID to use for entity record.", Required = false)] + public string? FormId { get; set; } + + [CliOption(Name = "--viewid", Description = "View GUID for entity list (pagetype=entitylist).", Required = false)] + public string? ViewId { get; set; } + + [CliOption(Name = "--dashboard", Description = "Dashboard GUID (pagetype=dashboard).", Required = false)] + public string? Dashboard { get; set; } + + [CliOption(Name = "--custom-page", Description = "Custom page logical name (pagetype=custom).", Required = false)] + public string? CustomPage { get; set; } + + [CliOption(Name = "--control", Description = "Full-page PCF control fully-qualified name (pagetype=control).", Required = false)] + public string? Control { get; set; } + + [CliOption(Name = "--webresource", Description = "Web resource logical name (pagetype=webresource).", Required = false)] + public string? WebResource { get; set; } + + [CliOption(Name = "--genux", Description = "Generative/Copilot AI page ID (pagetype=genux).", Required = false)] + public string? Genux { get; set; } + + [CliOption(Name = "--dialog-name", Description = "Inline dialog name (pagetype=inlinedialog).", Required = false)] + public string? DialogName { get; set; } + + [CliOption(Name = "--dialog-options", Description = "Inline dialog options JSON (pagetype=inlinedialog).", Required = false)] + public string? DialogOptions { get; set; } + + [CliOption(Name = "--data", Description = "Data parameter for control, genux, or webresource page types.", Required = false)] + public string? Data { get; set; } + + [CliOption(Name = "--extraqs", Description = "Pre-populate form fields as key=value pairs (URL-encoded automatically).", Required = false)] + public string? ExtraQs { get; set; } + + [CliOption(Name = "--navbar", Description = "Navigation bar mode: on, off, entity.", Required = false)] + public string? Navbar { get; set; } + + [CliOption(Name = "--cmdbar", Description = "Show command bar: true or false.", Required = false)] + public string? Cmdbar { get; set; } + + // ── Canvas app options ── + + [CliOption(Name = "--screen", Description = "Canvas app screen name to navigate to on launch.", Required = false)] + public string? Screen { get; set; } + + [CliOption(Name = "--param", Description = "Canvas app custom parameter (key=value). Can be specified multiple times.", Required = false)] + public List Param { get; set; } = new(); + + [CliOption(Name = "--hidenavbar", Description = "Canvas app: hide the Power Apps navigation bar.", Required = false)] + public bool HideNavbar { get; set; } + + // ── Report options ── + + [CliOption(Name = "--report-action", Description = "Report viewer action: run (default) or filter.", Required = false)] + public string? ReportAction { get; set; } + protected override async Task ExecuteAsync() { - // Validate --id / --name mutual exclusion - if (string.IsNullOrWhiteSpace(Id) && string.IsNullOrWhiteSpace(Name)) + // Resolve component type + var def = ComponentDefinitionRegistry.GetByName(Type); + ComponentType? typeCode = def?.TypeCode; + if (typeCode is null && int.TryParse(Type, out var rawCode)) + typeCode = (ComponentType)rawCode; + if (typeCode is null) { - Logger.LogError("Provide --id or --name ."); + Logger.LogError("Unknown component type '{Type}'. Run 'txc component type list' to see available types.", Type); return ExitValidationError; } - if (!string.IsNullOrWhiteSpace(Id) && !string.IsNullOrWhiteSpace(Name)) + + // Resolve profile + connection + var configResolver = TxcServices.Get(); + var ctx = await configResolver.ResolveAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var connection = ctx.Connection; + var orgUrl = connection.EnvironmentUrl; + + if (connection.EnvironmentId is null) { - Logger.LogError("--id and --name are mutually exclusive. Provide one, not both."); + Logger.LogError("Environment ID is not set on the connection. Run 'txc config connection check' to populate it."); return ExitValidationError; } + var environmentId = connection.EnvironmentId.Value; - // Resolve component type - var def = ComponentDefinitionRegistry.GetByName(Type); - ComponentType? typeCode = def?.TypeCode; + // Dispatch by component type + Uri? url = typeCode.Value switch + { + ComponentType.AppModule => BuildAppModuleUrl(orgUrl!), + ComponentType.CanvasApp => BuildCanvasAppUrl(environmentId, connection.TenantId), + ComponentType.Report => BuildReportUrl(orgUrl!), + _ => await BuildMakerEditorUrlAsync(typeCode.Value, environmentId, orgUrl).ConfigureAwait(false) + }; - // Allow raw integer codes even without a registered definition - if (typeCode is null && int.TryParse(Type, out var rawCode)) - typeCode = (ComponentType)rawCode; + if (url is null) + return ExitValidationError; // error already logged - if (typeCode is null) + OutputFormatter.WriteData(new { url = url.AbsoluteUri, type = def?.Name ?? typeCode.Value.ToString() }, + _ => OutputWriter.WriteLine(url.AbsoluteUri)); + + BrowserLauncher.Open(url, Logger); + return ExitSuccess; + } + + /// Build URL for model-driven app (shell or deep-link via pagetype). + private Uri? BuildAppModuleUrl(string orgUrl) + { + // App shell (no pagetype specified) + if (string.IsNullOrWhiteSpace(PageType)) { - Logger.LogError("Unknown component type '{Type}'. Run 'txc component type list' to see available types.", Type); - return ExitValidationError; + if (!string.IsNullOrWhiteSpace(Name)) + return MakerPortalUrlBuilder.AppModuleByName(orgUrl, Name); + if (!string.IsNullOrWhiteSpace(Id) && Guid.TryParse(Id, out var appId)) + return MakerPortalUrlBuilder.AppModuleById(orgUrl, appId); + Logger.LogError("Provide --name or --id for the app module."); + return null; + } + + // Deep-link — build pagetype + query params + var queryParams = new Dictionary(); + + // Add type-specific parameters based on pagetype + if (!string.IsNullOrWhiteSpace(Entity)) queryParams["etn"] = Entity; + if (!string.IsNullOrWhiteSpace(Record)) queryParams["id"] = Record; + if (!string.IsNullOrWhiteSpace(FormId)) queryParams["formid"] = FormId; + if (!string.IsNullOrWhiteSpace(ViewId)) { queryParams["viewid"] = ViewId; queryParams["viewtype"] = "1039"; } + if (!string.IsNullOrWhiteSpace(Dashboard)) queryParams["id"] = Dashboard; + if (!string.IsNullOrWhiteSpace(CustomPage)) queryParams["name"] = CustomPage; + if (!string.IsNullOrWhiteSpace(Control)) queryParams["controlName"] = Control; + if (!string.IsNullOrWhiteSpace(WebResource)) queryParams["webresourceName"] = WebResource; + if (!string.IsNullOrWhiteSpace(Genux)) queryParams["id"] = Genux; + if (!string.IsNullOrWhiteSpace(DialogName)) queryParams["name"] = DialogName; + if (!string.IsNullOrWhiteSpace(DialogOptions)) queryParams["dialogOptions"] = DialogOptions; + if (!string.IsNullOrWhiteSpace(Data)) queryParams["data"] = Data; + if (!string.IsNullOrWhiteSpace(ExtraQs)) queryParams["extraqs"] = ExtraQs; + if (!string.IsNullOrWhiteSpace(Navbar)) queryParams["navbar"] = Navbar; + if (!string.IsNullOrWhiteSpace(Cmdbar)) queryParams["cmdbar"] = Cmdbar; + + Guid? appId2 = null; + if (!string.IsNullOrWhiteSpace(Id) && Guid.TryParse(Id, out var parsed)) + appId2 = parsed; + + return MakerPortalUrlBuilder.AppModuleDeepLink(orgUrl, Name, appId2, PageType, queryParams); + } + + /// Build URL for canvas app player. + private Uri? BuildCanvasAppUrl(Guid environmentId, string? tenantId) + { + if (string.IsNullOrWhiteSpace(Id) || !Guid.TryParse(Id, out var appId)) + { + Logger.LogError("--id is required for canvas apps."); + return null; + } + + var customParams = new Dictionary(); + foreach (var p in Param) + { + var idx = p.IndexOf('='); + if (idx > 0 && idx < p.Length - 1) + customParams[p[..idx]] = p[(idx + 1)..]; + } + + return MakerPortalUrlBuilder.CanvasApp(environmentId, appId, tenantId, Screen, + customParams.Count > 0 ? customParams : null, HideNavbar); + } + + /// Build URL for report viewer. + private Uri? BuildReportUrl(string orgUrl) + { + if (string.IsNullOrWhiteSpace(Id) || !Guid.TryParse(Id, out var reportId)) + { + Logger.LogError("--id is required for reports."); + return null; + } + return MakerPortalUrlBuilder.Report(orgUrl, reportId, ReportAction ?? "run"); + } + + /// Build URL for maker portal editor (existing component types). + private async Task BuildMakerEditorUrlAsync(ComponentType typeCode, Guid environmentId, string? orgUrl) + { + // Validate --id / --name + if (string.IsNullOrWhiteSpace(Id) && string.IsNullOrWhiteSpace(Name)) + { + Logger.LogError("Provide --id or --name ."); + return null; + } + if (!string.IsNullOrWhiteSpace(Id) && !string.IsNullOrWhiteSpace(Name)) + { + Logger.LogError("--id and --name are mutually exclusive."); + return null; } - // Resolve the component GUID Guid componentId; if (!string.IsNullOrWhiteSpace(Id)) { if (!Guid.TryParse(Id, out componentId)) { Logger.LogError("Invalid GUID: '{Id}'.", Id); - return ExitValidationError; + return null; } } else { - // --name resolution depends on type - var resolved = await ResolveNameToGuidAsync(typeCode.Value, Name!).ConfigureAwait(false); - if (resolved is null) - return ExitValidationError; + var resolved = await ResolveNameToGuidAsync(typeCode, Name!).ConfigureAwait(false); + if (resolved is null) return null; componentId = resolved.Value; } - // Resolve environment ID from profile connection - var resolver = TxcServices.Get(); - var context = await resolver.ResolveAsync(Profile, CancellationToken.None).ConfigureAwait(false); - var connection = context.Connection; - - if (connection.EnvironmentId is null) - { - Logger.LogError("Environment ID is not set on the connection. Run 'txc config connection check' to populate it."); - return ExitValidationError; - } - var environmentId = connection.EnvironmentId.Value; - - // Resolve --solution if provided + // Resolve --solution Guid? solutionId = null; if (!string.IsNullOrWhiteSpace(Solution)) { @@ -106,35 +259,17 @@ protected override async Task ExecuteAsync() solutionId = sln.Id; } - // Validate type-specific requirements if (typeCode is ComponentType.SystemForm or ComponentType.Form or ComponentType.SavedQuery && string.IsNullOrWhiteSpace(Entity)) { Logger.LogError("--entity is required for form/view types."); - return ExitValidationError; + return null; } - // Build URL - var orgUrl = connection.EnvironmentUrl?.Replace("https://", "").TrimEnd('/'); - var url = MakerPortalUrlBuilder.Build(environmentId, orgUrl, typeCode.Value, componentId, Entity, solutionId); - + var url = MakerPortalUrlBuilder.Build(environmentId, orgUrl, typeCode, componentId, Entity, solutionId); if (url is null) - { - Logger.LogError( - "Cannot build URL for type '{Type}' (code {Code}). For SCF types, provide --entity (backing entity logical name).", - Type, (int)typeCode.Value); - return ExitValidationError; - } - - // Output URL and open browser - OutputFormatter.WriteData(new { url = url.AbsoluteUri, type = def?.Name ?? typeCode.Value.ToString(), componentId }, - _ => - { - OutputWriter.WriteLine(url.AbsoluteUri); - }); - - BrowserLauncher.Open(url, Logger); - return ExitSuccess; + Logger.LogError("Cannot build URL for type '{Type}' (code {Code}). For SCF types, provide --entity.", Type, (int)typeCode); + return url; } private async Task ResolveNameToGuidAsync(ComponentType typeCode, string name) @@ -148,8 +283,12 @@ protected override async Task ExecuteAsync() case ComponentType.Entity: var metadataResolver = TxcServices.Get(); - var entityId = await metadataResolver.ResolveEntityIdAsync(Profile, name, CancellationToken.None).ConfigureAwait(false); - return entityId; + return await metadataResolver.ResolveEntityIdAsync(Profile, name, CancellationToken.None).ConfigureAwait(false); + + case ComponentType.AppModule: + // AppModule name resolution handled in BuildAppModuleUrl + Logger.LogError("AppModule name resolution is handled via --name directly."); + return null; default: Logger.LogError("--name is not supported for type '{Type}'. Use --id instead.", Type); diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrlBuilder.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrlBuilder.cs index 6e480f9..25af546 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrlBuilder.cs +++ b/src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrlBuilder.cs @@ -56,9 +56,75 @@ public static Uri SecurityRole(Guid environmentId, Guid roleId, Guid? solutionId public static Uri ScfRecord(string orgUrl, string entityLogicalName, Guid recordId) => new($"https://{orgUrl.TrimEnd('/')}/main.aspx?forceUCI=1&newWindow=true&pagetype=entityrecord&etn={entityLogicalName}&id={recordId}"); + // ── Model-driven app runtime URLs ── + + /// Open a model-driven app by its unique name. + public static Uri AppModuleByName(string orgUrl, string uniqueName) + => new($"https://{NormalizeOrg(orgUrl)}/main.aspx?appname={Uri.EscapeDataString(uniqueName)}"); + + /// Open a model-driven app by its AppModuleId GUID. + public static Uri AppModuleById(string orgUrl, Guid appModuleId) + => new($"https://{NormalizeOrg(orgUrl)}/main.aspx?appid={appModuleId}"); + + /// + /// Generic deep-link into a model-driven app via main.aspx. + /// Builds the URL from app identity + pagetype + arbitrary query parameters. + /// Supports all UCI page types: entityrecord, entitylist, dashboard, webresource, + /// control, custom, inlinedialog, genux, search, apps. + /// + public static Uri AppModuleDeepLink(string orgUrl, string? appName, Guid? appId, string pageType, IDictionary queryParams) + { + var qs = new List(); + if (!string.IsNullOrWhiteSpace(appName)) + qs.Add($"appname={Uri.EscapeDataString(appName)}"); + else if (appId.HasValue) + qs.Add($"appid={appId.Value}"); + + qs.Add($"pagetype={Uri.EscapeDataString(pageType)}"); + + foreach (var (key, value) in queryParams) + qs.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}"); + + return new Uri($"https://{NormalizeOrg(orgUrl)}/main.aspx?{string.Join("&", qs)}"); + } + + // ── Canvas app runtime URL ── + /// - /// Builds the appropriate URL for a component type code. + /// Open a canvas app in the Power Apps player. + /// Supports screen navigation, custom parameters, and hidden navbar. + /// + public static Uri CanvasApp(Guid environmentId, Guid appId, string? tenantId, + string? screenName = null, IDictionary? customParams = null, bool hideNavbar = false) + { + var qs = new List(); + if (!string.IsNullOrWhiteSpace(tenantId)) + qs.Add($"tenantId={Uri.EscapeDataString(tenantId)}"); + if (!string.IsNullOrWhiteSpace(screenName)) + qs.Add($"screenName={Uri.EscapeDataString(screenName)}"); + if (hideNavbar) + qs.Add("hidenavbar=true"); + if (customParams != null) + foreach (var (key, value) in customParams) + qs.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}"); + + var query = qs.Count > 0 ? "?" + string.Join("&", qs) : ""; + return new Uri($"https://apps.powerapps.com/play/e/{environmentId}/a/{appId}{query}"); + } + + // ── Report URL ── + + /// Open a report in the Dynamics report viewer. + public static Uri Report(string orgUrl, Guid reportId, string action = "run") + => new($"https://{NormalizeOrg(orgUrl)}/crmreports/viewer/viewer.aspx?action={Uri.EscapeDataString(action)}&id=%7b{reportId}%7d"); + + // ── Existing Build() for maker portal editor URLs ── + + /// + /// Builds the appropriate maker portal editor URL for a component type code. /// Returns null if the type requires additional context that wasn't provided. + /// For app runtime URLs, use , , + /// , or directly. /// public static Uri? Build( Guid environmentId, @@ -84,4 +150,7 @@ public static Uri ScfRecord(string orgUrl, string entityLogicalName, Guid record _ => null }; } + + private static string NormalizeOrg(string orgUrl) + => orgUrl.Replace("https://", "").Replace("http://", "").TrimEnd('/'); } From 126b35143313a468a606bd1d14815497f9fcccd0 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 11 May 2026 19:31:20 +0200 Subject: [PATCH 08/10] refactor: split URL builders into focused files per experience Replace monolithic MakerPortalUrlBuilder with focused URL builder classes: - MakerPortalUrls: make.powerapps.com editor URLs (solution, entity, form, view, role, dataflow) - PowerAutomateUrls: make.powerautomate.com (flow editor, details, runs, specific run) - CopilotStudioUrls: copilotstudio.microsoft.com (bot editor) - DynamicsUciUrls: {org}.crm.dynamics.com/main.aspx (app shell, UCI deep-links, SCF records, reports) - CanvasAppUrls: apps.powerapps.com/play (canvas app player) - BrowseUrlConstants: shared constants (default solution GUID, org URL normalization) Add Power Automate flow navigation options: - --flow-view: editor (default), details, or runs - --run: open a specific flow run by ID Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Component/Browse/BrowseUrlConstants.cs | 14 ++ .../Component/Browse/CanvasAppUrls.cs | 31 ++++ .../Browse/ComponentBrowseCliCommand.cs | 72 ++++++-- .../Component/Browse/CopilotStudioUrls.cs | 16 ++ .../Component/Browse/DynamicsUciUrls.cs | 51 ++++++ .../Component/Browse/MakerPortalUrlBuilder.cs | 156 ------------------ .../Component/Browse/MakerPortalUrls.cs | 40 +++++ .../Component/Browse/PowerAutomateUrls.cs | 34 ++++ 8 files changed, 248 insertions(+), 166 deletions(-) create mode 100644 src/TALXIS.CLI.Features.Environment/Component/Browse/BrowseUrlConstants.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Component/Browse/CanvasAppUrls.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Component/Browse/CopilotStudioUrls.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Component/Browse/DynamicsUciUrls.cs delete mode 100644 src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrlBuilder.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrls.cs create mode 100644 src/TALXIS.CLI.Features.Environment/Component/Browse/PowerAutomateUrls.cs diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/BrowseUrlConstants.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/BrowseUrlConstants.cs new file mode 100644 index 0000000..b0258c6 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Component/Browse/BrowseUrlConstants.cs @@ -0,0 +1,14 @@ +namespace TALXIS.CLI.Features.Environment.Component.Browse; + +/// +/// Shared constants for browse URL construction. +/// +public static class BrowseUrlConstants +{ + /// Default Solution GUID — used when no solution context is specified for form/view/securityrole. + public const string DefaultSolutionId = "fd140aaf-4df4-11dd-bd17-0019b9312238"; + + /// Strips protocol and trailing slash from an org URL for use in URL construction. + public static string NormalizeOrgUrl(string orgUrl) + => orgUrl.Replace("https://", "").Replace("http://", "").TrimEnd('/'); +} diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/CanvasAppUrls.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/CanvasAppUrls.cs new file mode 100644 index 0000000..8c3bfea --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Component/Browse/CanvasAppUrls.cs @@ -0,0 +1,31 @@ +namespace TALXIS.CLI.Features.Environment.Component.Browse; + +/// +/// URL builders for the Power Apps canvas app player (apps.powerapps.com/play). +/// Supports screen navigation, custom parameters, and hidden navbar. +/// +public static class CanvasAppUrls +{ + private const string Base = "https://apps.powerapps.com/play"; + + /// + /// Open a canvas app in the Power Apps player. + /// + public static Uri Play(Guid environmentId, Guid appId, string? tenantId, + string? screenName = null, IDictionary? customParams = null, bool hideNavbar = false) + { + var qs = new List(); + if (!string.IsNullOrWhiteSpace(tenantId)) + qs.Add($"tenantId={Uri.EscapeDataString(tenantId)}"); + if (!string.IsNullOrWhiteSpace(screenName)) + qs.Add($"screenName={Uri.EscapeDataString(screenName)}"); + if (hideNavbar) + qs.Add("hidenavbar=true"); + if (customParams != null) + foreach (var (key, value) in customParams) + qs.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}"); + + var query = qs.Count > 0 ? "?" + string.Join("&", qs) : ""; + return new Uri($"{Base}/e/{environmentId}/a/{appId}{query}"); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs index 6e3c72f..825d55d 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs @@ -96,6 +96,14 @@ public class ComponentBrowseCliCommand : ProfiledCliCommand [CliOption(Name = "--hidenavbar", Description = "Canvas app: hide the Power Apps navigation bar.", Required = false)] public bool HideNavbar { get; set; } + // ── Power Automate options ── + + [CliOption(Name = "--flow-view", Description = "Flow page to open: editor (default), details, or runs.", Required = false)] + public string? FlowView { get; set; } + + [CliOption(Name = "--run", Description = "Specific flow run ID to open.", Required = false)] + public string? Run { get; set; } + // ── Report options ── [CliOption(Name = "--report-action", Description = "Report viewer action: run (default) or filter.", Required = false)] @@ -133,6 +141,7 @@ protected override async Task ExecuteAsync() ComponentType.AppModule => BuildAppModuleUrl(orgUrl!), ComponentType.CanvasApp => BuildCanvasAppUrl(environmentId, connection.TenantId), ComponentType.Report => BuildReportUrl(orgUrl!), + ComponentType.Workflow => await BuildFlowUrlAsync(environmentId).ConfigureAwait(false), _ => await BuildMakerEditorUrlAsync(typeCode.Value, environmentId, orgUrl).ConfigureAwait(false) }; @@ -149,21 +158,17 @@ protected override async Task ExecuteAsync() /// Build URL for model-driven app (shell or deep-link via pagetype). private Uri? BuildAppModuleUrl(string orgUrl) { - // App shell (no pagetype specified) if (string.IsNullOrWhiteSpace(PageType)) { if (!string.IsNullOrWhiteSpace(Name)) - return MakerPortalUrlBuilder.AppModuleByName(orgUrl, Name); + return DynamicsUciUrls.AppByName(orgUrl, Name); if (!string.IsNullOrWhiteSpace(Id) && Guid.TryParse(Id, out var appId)) - return MakerPortalUrlBuilder.AppModuleById(orgUrl, appId); + return DynamicsUciUrls.AppById(orgUrl, appId); Logger.LogError("Provide --name or --id for the app module."); return null; } - // Deep-link — build pagetype + query params var queryParams = new Dictionary(); - - // Add type-specific parameters based on pagetype if (!string.IsNullOrWhiteSpace(Entity)) queryParams["etn"] = Entity; if (!string.IsNullOrWhiteSpace(Record)) queryParams["id"] = Record; if (!string.IsNullOrWhiteSpace(FormId)) queryParams["formid"] = FormId; @@ -184,7 +189,35 @@ protected override async Task ExecuteAsync() if (!string.IsNullOrWhiteSpace(Id) && Guid.TryParse(Id, out var parsed)) appId2 = parsed; - return MakerPortalUrlBuilder.AppModuleDeepLink(orgUrl, Name, appId2, PageType, queryParams); + return DynamicsUciUrls.DeepLink(orgUrl, Name, appId2, PageType, queryParams); + } + + /// Build URL for Power Automate flow — editor, details, runs, or specific run. + private async Task BuildFlowUrlAsync(Guid environmentId) + { + if (string.IsNullOrWhiteSpace(Id) || !Guid.TryParse(Id, out var flowId)) + { + Logger.LogError("--id is required for flows."); + return null; + } + + Guid? solutionId = null; + if (!string.IsNullOrWhiteSpace(Solution)) + { + var slnService = TxcServices.Get(); + var (sln, _) = await slnService.ShowAsync(Profile, Solution, CancellationToken.None).ConfigureAwait(false); + solutionId = sln.Id; + } + + if (!string.IsNullOrWhiteSpace(Run)) + return PowerAutomateUrls.FlowRun(environmentId, flowId, Run, solutionId); + + return (FlowView?.ToLowerInvariant()) switch + { + "details" => PowerAutomateUrls.FlowDetails(environmentId, flowId, solutionId), + "runs" => PowerAutomateUrls.FlowRuns(environmentId, flowId, solutionId), + _ => PowerAutomateUrls.FlowEditor(environmentId, flowId, solutionId) + }; } /// Build URL for canvas app player. @@ -204,7 +237,7 @@ protected override async Task ExecuteAsync() customParams[p[..idx]] = p[(idx + 1)..]; } - return MakerPortalUrlBuilder.CanvasApp(environmentId, appId, tenantId, Screen, + return CanvasAppUrls.Play(environmentId, appId, tenantId, Screen, customParams.Count > 0 ? customParams : null, HideNavbar); } @@ -216,7 +249,7 @@ protected override async Task ExecuteAsync() Logger.LogError("--id is required for reports."); return null; } - return MakerPortalUrlBuilder.Report(orgUrl, reportId, ReportAction ?? "run"); + return DynamicsUciUrls.Report(orgUrl, reportId, ReportAction ?? "run"); } /// Build URL for maker portal editor (existing component types). @@ -266,12 +299,31 @@ protected override async Task ExecuteAsync() return null; } - var url = MakerPortalUrlBuilder.Build(environmentId, orgUrl, typeCode, componentId, Entity, solutionId); + var url = BuildEditorUrl(typeCode, environmentId, orgUrl, componentId, Entity, solutionId); if (url is null) Logger.LogError("Cannot build URL for type '{Type}' (code {Code}). For SCF types, provide --entity.", Type, (int)typeCode); return url; } + /// Dispatches to the appropriate URL builder based on component type. + private static Uri? BuildEditorUrl(ComponentType typeCode, Guid envId, string? orgUrl, Guid componentId, string? entity, Guid? solutionId) + { + return typeCode switch + { + ComponentType.Solution => MakerPortalUrls.Solution(envId, componentId), + ComponentType.Entity => MakerPortalUrls.Entity(envId, componentId, solutionId), + ComponentType.SystemForm when entity != null => MakerPortalUrls.FormDesigner(envId, entity, componentId, solutionId), + ComponentType.Form when entity != null => MakerPortalUrls.FormDesigner(envId, entity, componentId, solutionId), + ComponentType.SavedQuery when entity != null => MakerPortalUrls.ViewDesigner(envId, entity, componentId, solutionId), + ComponentType.Bot => CopilotStudioUrls.BotEditor(envId, componentId, solutionId), + ComponentType.Dataflow => MakerPortalUrls.DataflowEditor(envId, componentId), + ComponentType.Role => MakerPortalUrls.SecurityRoleEditor(envId, componentId, solutionId), + // SCF / unknown — fallback to UCI record form + _ when orgUrl != null && entity != null => DynamicsUciUrls.RecordForm(orgUrl, entity, componentId), + _ => null + }; + } + private async Task ResolveNameToGuidAsync(ComponentType typeCode, string name) { switch (typeCode) diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/CopilotStudioUrls.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/CopilotStudioUrls.cs new file mode 100644 index 0000000..6ad8902 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Component/Browse/CopilotStudioUrls.cs @@ -0,0 +1,16 @@ +namespace TALXIS.CLI.Features.Environment.Component.Browse; + +/// +/// URL builders for Copilot Studio (copilotstudio.microsoft.com). +/// Covers bot/agent editor. +/// +public static class CopilotStudioUrls +{ + private const string Base = "https://copilotstudio.microsoft.com"; + + /// Open the bot/agent editor in Copilot Studio. + public static Uri BotEditor(Guid environmentId, Guid botId, Guid? solutionId = null) + => solutionId.HasValue + ? new($"{Base}/environments/{environmentId}/bots/{botId}?solutionId={solutionId}") + : new($"{Base}/environments/{environmentId}/bots/{botId}"); +} diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/DynamicsUciUrls.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/DynamicsUciUrls.cs new file mode 100644 index 0000000..d39c512 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Component/Browse/DynamicsUciUrls.cs @@ -0,0 +1,51 @@ +namespace TALXIS.CLI.Features.Environment.Component.Browse; + +/// +/// URL builders for the Dynamics 365 UCI runtime ({org}.crm{N}.dynamics.com/main.aspx). +/// Covers model-driven app shell, all UCI page types (entityrecord, entitylist, dashboard, +/// webresource, control, custom, inlinedialog, genux, search), SCF record forms, and reports. +/// +public static class DynamicsUciUrls +{ + /// Open a model-driven app by its unique name. + public static Uri AppByName(string orgUrl, string uniqueName) + => new($"https://{BrowseUrlConstants.NormalizeOrgUrl(orgUrl)}/main.aspx?appname={Uri.EscapeDataString(uniqueName)}"); + + /// Open a model-driven app by its AppModuleId GUID. + public static Uri AppById(string orgUrl, Guid appModuleId) + => new($"https://{BrowseUrlConstants.NormalizeOrgUrl(orgUrl)}/main.aspx?appid={appModuleId}"); + + /// + /// Generic deep-link into a model-driven app via main.aspx. + /// Builds the URL from app identity + pagetype + arbitrary query parameters. + /// Supports all UCI page types: entityrecord, entitylist, dashboard, webresource, + /// control, custom, inlinedialog, genux, search, apps. + /// + public static Uri DeepLink(string orgUrl, string? appName, Guid? appId, string pageType, IDictionary queryParams) + { + var org = BrowseUrlConstants.NormalizeOrgUrl(orgUrl); + var qs = new List(); + + if (!string.IsNullOrWhiteSpace(appName)) + qs.Add($"appname={Uri.EscapeDataString(appName)}"); + else if (appId.HasValue) + qs.Add($"appid={appId.Value}"); + + qs.Add($"pagetype={Uri.EscapeDataString(pageType)}"); + + foreach (var (key, value) in queryParams) + qs.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}"); + + return new Uri($"https://{org}/main.aspx?{string.Join("&", qs)}"); + } + + /// + /// Open an SCF or unrecognized component type's backing entity record form. + /// + public static Uri RecordForm(string orgUrl, string entityLogicalName, Guid recordId) + => new($"https://{BrowseUrlConstants.NormalizeOrgUrl(orgUrl)}/main.aspx?forceUCI=1&newWindow=true&pagetype=entityrecord&etn={entityLogicalName}&id={recordId}"); + + /// Open a report in the Dynamics report viewer. + public static Uri Report(string orgUrl, Guid reportId, string action = "run") + => new($"https://{BrowseUrlConstants.NormalizeOrgUrl(orgUrl)}/crmreports/viewer/viewer.aspx?action={Uri.EscapeDataString(action)}&id=%7b{reportId}%7d"); +} diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrlBuilder.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrlBuilder.cs deleted file mode 100644 index 25af546..0000000 --- a/src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrlBuilder.cs +++ /dev/null @@ -1,156 +0,0 @@ -using TALXIS.Platform.Metadata; - -namespace TALXIS.CLI.Features.Environment.Component.Browse; - -/// -/// Builds Power Platform maker portal URLs for component types. -/// Each method corresponds to a specific component editor URL pattern. -/// -public static class MakerPortalUrlBuilder -{ - /// Default Solution GUID used when no solution context is specified for form/view/securityrole. - public const string DefaultSolutionId = "fd140aaf-4df4-11dd-bd17-0019b9312238"; - - public static Uri Solution(Guid environmentId, Guid solutionId) - => new($"https://make.powerapps.com/environments/{environmentId}/solutions/{solutionId}"); - - public static Uri Entity(Guid environmentId, Guid metadataId, Guid? solutionId = null) - => solutionId.HasValue - ? new($"https://make.powerapps.com/environments/{environmentId}/solutions/{solutionId}/entities/{metadataId}/fields") - : new($"https://make.powerapps.com/environments/{environmentId}/entities/{metadataId}/fields"); - - public static Uri Form(Guid environmentId, string entityLogicalName, Guid formId, Guid? solutionId = null) - { - var slnId = solutionId ?? Guid.Parse(DefaultSolutionId); - return new($"https://make.powerapps.com/e/{environmentId}/s/{slnId}/entity/{entityLogicalName}/form/edit/{formId}"); - } - - public static Uri View(Guid environmentId, string entityLogicalName, Guid viewId, Guid? solutionId = null) - { - var slnId = solutionId ?? Guid.Parse(DefaultSolutionId); - return new($"https://make.powerapps.com/e/{environmentId}/s/{slnId}/entity/{entityLogicalName}/view/{viewId}"); - } - - public static Uri Flow(Guid environmentId, Guid flowId, Guid? solutionId = null) - => solutionId.HasValue - ? new($"https://make.powerautomate.com/environments/{environmentId}/solutions/{solutionId}/flows/{flowId}") - : new($"https://make.powerautomate.com/environments/{environmentId}/flows/{flowId}"); - - public static Uri Bot(Guid environmentId, Guid botId, Guid? solutionId = null) - => solutionId.HasValue - ? new($"https://copilotstudio.microsoft.com/environments/{environmentId}/bots/{botId}?solutionId={solutionId}") - : new($"https://copilotstudio.microsoft.com/environments/{environmentId}/bots/{botId}"); - - public static Uri Dataflow(Guid environmentId, Guid dataflowId) - => new($"https://make.powerapps.com/environments/{environmentId}/dataintegration/list/{dataflowId}/edit"); - - public static Uri SecurityRole(Guid environmentId, Guid roleId, Guid? solutionId = null) - { - var slnId = solutionId ?? Guid.Parse(DefaultSolutionId); - return new($"https://make.powerapps.com/e/{environmentId}/s/{slnId}/securityroles/{roleId}/roleeditor"); - } - - /// - /// Fallback for SCF and unrecognized component types — opens the backing entity record form. - /// - public static Uri ScfRecord(string orgUrl, string entityLogicalName, Guid recordId) - => new($"https://{orgUrl.TrimEnd('/')}/main.aspx?forceUCI=1&newWindow=true&pagetype=entityrecord&etn={entityLogicalName}&id={recordId}"); - - // ── Model-driven app runtime URLs ── - - /// Open a model-driven app by its unique name. - public static Uri AppModuleByName(string orgUrl, string uniqueName) - => new($"https://{NormalizeOrg(orgUrl)}/main.aspx?appname={Uri.EscapeDataString(uniqueName)}"); - - /// Open a model-driven app by its AppModuleId GUID. - public static Uri AppModuleById(string orgUrl, Guid appModuleId) - => new($"https://{NormalizeOrg(orgUrl)}/main.aspx?appid={appModuleId}"); - - /// - /// Generic deep-link into a model-driven app via main.aspx. - /// Builds the URL from app identity + pagetype + arbitrary query parameters. - /// Supports all UCI page types: entityrecord, entitylist, dashboard, webresource, - /// control, custom, inlinedialog, genux, search, apps. - /// - public static Uri AppModuleDeepLink(string orgUrl, string? appName, Guid? appId, string pageType, IDictionary queryParams) - { - var qs = new List(); - if (!string.IsNullOrWhiteSpace(appName)) - qs.Add($"appname={Uri.EscapeDataString(appName)}"); - else if (appId.HasValue) - qs.Add($"appid={appId.Value}"); - - qs.Add($"pagetype={Uri.EscapeDataString(pageType)}"); - - foreach (var (key, value) in queryParams) - qs.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}"); - - return new Uri($"https://{NormalizeOrg(orgUrl)}/main.aspx?{string.Join("&", qs)}"); - } - - // ── Canvas app runtime URL ── - - /// - /// Open a canvas app in the Power Apps player. - /// Supports screen navigation, custom parameters, and hidden navbar. - /// - public static Uri CanvasApp(Guid environmentId, Guid appId, string? tenantId, - string? screenName = null, IDictionary? customParams = null, bool hideNavbar = false) - { - var qs = new List(); - if (!string.IsNullOrWhiteSpace(tenantId)) - qs.Add($"tenantId={Uri.EscapeDataString(tenantId)}"); - if (!string.IsNullOrWhiteSpace(screenName)) - qs.Add($"screenName={Uri.EscapeDataString(screenName)}"); - if (hideNavbar) - qs.Add("hidenavbar=true"); - if (customParams != null) - foreach (var (key, value) in customParams) - qs.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}"); - - var query = qs.Count > 0 ? "?" + string.Join("&", qs) : ""; - return new Uri($"https://apps.powerapps.com/play/e/{environmentId}/a/{appId}{query}"); - } - - // ── Report URL ── - - /// Open a report in the Dynamics report viewer. - public static Uri Report(string orgUrl, Guid reportId, string action = "run") - => new($"https://{NormalizeOrg(orgUrl)}/crmreports/viewer/viewer.aspx?action={Uri.EscapeDataString(action)}&id=%7b{reportId}%7d"); - - // ── Existing Build() for maker portal editor URLs ── - - /// - /// Builds the appropriate maker portal editor URL for a component type code. - /// Returns null if the type requires additional context that wasn't provided. - /// For app runtime URLs, use , , - /// , or directly. - /// - public static Uri? Build( - Guid environmentId, - string? orgUrl, - ComponentType typeCode, - Guid componentId, - string? entityLogicalName = null, - Guid? solutionId = null) - { - return typeCode switch - { - ComponentType.Solution => Solution(environmentId, componentId), - ComponentType.Entity => Entity(environmentId, componentId, solutionId), - ComponentType.SystemForm when entityLogicalName != null => Form(environmentId, entityLogicalName, componentId, solutionId), - ComponentType.Form when entityLogicalName != null => Form(environmentId, entityLogicalName, componentId, solutionId), - ComponentType.SavedQuery when entityLogicalName != null => View(environmentId, entityLogicalName, componentId, solutionId), - ComponentType.Workflow => Flow(environmentId, componentId, solutionId), - ComponentType.Bot => Bot(environmentId, componentId, solutionId), - ComponentType.Dataflow => Dataflow(environmentId, componentId), - ComponentType.Role => SecurityRole(environmentId, componentId, solutionId), - // SCF / unknown — fallback to record form if org URL and entity name available - _ when orgUrl != null && entityLogicalName != null => ScfRecord(orgUrl, entityLogicalName, componentId), - _ => null - }; - } - - private static string NormalizeOrg(string orgUrl) - => orgUrl.Replace("https://", "").Replace("http://", "").TrimEnd('/'); -} diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrls.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrls.cs new file mode 100644 index 0000000..8d9a731 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrls.cs @@ -0,0 +1,40 @@ +namespace TALXIS.CLI.Features.Environment.Component.Browse; + +/// +/// URL builders for the Power Apps maker portal (make.powerapps.com). +/// Covers solution editor, entity fields, form designer, view designer, +/// security role editor, and dataflow editor. +/// +public static class MakerPortalUrls +{ + private const string Base = "https://make.powerapps.com"; + + public static Uri Solution(Guid environmentId, Guid solutionId) + => new($"{Base}/environments/{environmentId}/solutions/{solutionId}"); + + public static Uri Entity(Guid environmentId, Guid metadataId, Guid? solutionId = null) + => solutionId.HasValue + ? new($"{Base}/environments/{environmentId}/solutions/{solutionId}/entities/{metadataId}/fields") + : new($"{Base}/environments/{environmentId}/entities/{metadataId}/fields"); + + public static Uri FormDesigner(Guid environmentId, string entityLogicalName, Guid formId, Guid? solutionId = null) + { + var slnId = solutionId ?? Guid.Parse(BrowseUrlConstants.DefaultSolutionId); + return new($"{Base}/e/{environmentId}/s/{slnId}/entity/{entityLogicalName}/form/edit/{formId}"); + } + + public static Uri ViewDesigner(Guid environmentId, string entityLogicalName, Guid viewId, Guid? solutionId = null) + { + var slnId = solutionId ?? Guid.Parse(BrowseUrlConstants.DefaultSolutionId); + return new($"{Base}/e/{environmentId}/s/{slnId}/entity/{entityLogicalName}/view/{viewId}"); + } + + public static Uri SecurityRoleEditor(Guid environmentId, Guid roleId, Guid? solutionId = null) + { + var slnId = solutionId ?? Guid.Parse(BrowseUrlConstants.DefaultSolutionId); + return new($"{Base}/e/{environmentId}/s/{slnId}/securityroles/{roleId}/roleeditor"); + } + + public static Uri DataflowEditor(Guid environmentId, Guid dataflowId) + => new($"{Base}/environments/{environmentId}/dataintegration/list/{dataflowId}/edit"); +} diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/PowerAutomateUrls.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/PowerAutomateUrls.cs new file mode 100644 index 0000000..b590885 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Component/Browse/PowerAutomateUrls.cs @@ -0,0 +1,34 @@ +namespace TALXIS.CLI.Features.Environment.Component.Browse; + +/// +/// URL builders for the Power Automate maker portal (make.powerautomate.com). +/// Covers flow editor, flow details, run history, and specific run inspection. +/// +public static class PowerAutomateUrls +{ + private const string Base = "https://make.powerautomate.com"; + + /// Flow base path — editor view (default landing page for a flow). + public static Uri FlowEditor(Guid environmentId, Guid flowId, Guid? solutionId = null) + => solutionId.HasValue + ? new($"{Base}/environments/{environmentId}/solutions/{solutionId}/flows/{flowId}") + : new($"{Base}/environments/{environmentId}/flows/{flowId}"); + + /// Flow details page — shows connection references, owners, and metadata. + public static Uri FlowDetails(Guid environmentId, Guid flowId, Guid? solutionId = null) + => solutionId.HasValue + ? new($"{Base}/environments/{environmentId}/solutions/{solutionId}/flows/{flowId}/details") + : new($"{Base}/environments/{environmentId}/flows/{flowId}/details"); + + /// Flow run history — lists all runs with status. + public static Uri FlowRuns(Guid environmentId, Guid flowId, Guid? solutionId = null) + => solutionId.HasValue + ? new($"{Base}/environments/{environmentId}/solutions/{solutionId}/flows/{flowId}/runs") + : new($"{Base}/environments/{environmentId}/flows/{flowId}/runs"); + + /// Specific flow run — shows the run's step-by-step execution details. + public static Uri FlowRun(Guid environmentId, Guid flowId, string runId, Guid? solutionId = null) + => solutionId.HasValue + ? new($"{Base}/environments/{environmentId}/solutions/{solutionId}/flows/{flowId}/runs/{Uri.EscapeDataString(runId)}") + : new($"{Base}/environments/{environmentId}/flows/{flowId}/runs/{Uri.EscapeDataString(runId)}"); +} From 0539fcb98061fefe2668a7b5d94dc29f14153048 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 11 May 2026 19:32:58 +0200 Subject: [PATCH 09/10] =?UTF-8?q?refactor:=20rename=20DynamicsUciUrls=20?= =?UTF-8?q?=E2=86=92=20PowerAppsUciUrls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UCI runtime is Power Apps, not Dynamics 365. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Component/Browse/ComponentBrowseCliCommand.cs | 10 +++++----- .../Browse/{DynamicsUciUrls.cs => PowerAppsUciUrls.cs} | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) rename src/TALXIS.CLI.Features.Environment/Component/Browse/{DynamicsUciUrls.cs => PowerAppsUciUrls.cs} (95%) diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs index 825d55d..bbd26bb 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs @@ -161,9 +161,9 @@ protected override async Task ExecuteAsync() if (string.IsNullOrWhiteSpace(PageType)) { if (!string.IsNullOrWhiteSpace(Name)) - return DynamicsUciUrls.AppByName(orgUrl, Name); + return PowerAppsUciUrls.AppByName(orgUrl, Name); if (!string.IsNullOrWhiteSpace(Id) && Guid.TryParse(Id, out var appId)) - return DynamicsUciUrls.AppById(orgUrl, appId); + return PowerAppsUciUrls.AppById(orgUrl, appId); Logger.LogError("Provide --name or --id for the app module."); return null; } @@ -189,7 +189,7 @@ protected override async Task ExecuteAsync() if (!string.IsNullOrWhiteSpace(Id) && Guid.TryParse(Id, out var parsed)) appId2 = parsed; - return DynamicsUciUrls.DeepLink(orgUrl, Name, appId2, PageType, queryParams); + return PowerAppsUciUrls.DeepLink(orgUrl, Name, appId2, PageType, queryParams); } /// Build URL for Power Automate flow — editor, details, runs, or specific run. @@ -249,7 +249,7 @@ protected override async Task ExecuteAsync() Logger.LogError("--id is required for reports."); return null; } - return DynamicsUciUrls.Report(orgUrl, reportId, ReportAction ?? "run"); + return PowerAppsUciUrls.Report(orgUrl, reportId, ReportAction ?? "run"); } /// Build URL for maker portal editor (existing component types). @@ -319,7 +319,7 @@ protected override async Task ExecuteAsync() ComponentType.Dataflow => MakerPortalUrls.DataflowEditor(envId, componentId), ComponentType.Role => MakerPortalUrls.SecurityRoleEditor(envId, componentId, solutionId), // SCF / unknown — fallback to UCI record form - _ when orgUrl != null && entity != null => DynamicsUciUrls.RecordForm(orgUrl, entity, componentId), + _ when orgUrl != null && entity != null => PowerAppsUciUrls.RecordForm(orgUrl, entity, componentId), _ => null }; } diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/DynamicsUciUrls.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/PowerAppsUciUrls.cs similarity index 95% rename from src/TALXIS.CLI.Features.Environment/Component/Browse/DynamicsUciUrls.cs rename to src/TALXIS.CLI.Features.Environment/Component/Browse/PowerAppsUciUrls.cs index d39c512..838a031 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Browse/DynamicsUciUrls.cs +++ b/src/TALXIS.CLI.Features.Environment/Component/Browse/PowerAppsUciUrls.cs @@ -1,11 +1,11 @@ namespace TALXIS.CLI.Features.Environment.Component.Browse; /// -/// URL builders for the Dynamics 365 UCI runtime ({org}.crm{N}.dynamics.com/main.aspx). +/// URL builders for the Power Apps UCI runtime ({org}.crm{N}.dynamics.com/main.aspx). /// Covers model-driven app shell, all UCI page types (entityrecord, entitylist, dashboard, /// webresource, control, custom, inlinedialog, genux, search), SCF record forms, and reports. /// -public static class DynamicsUciUrls +public static class PowerAppsUciUrls { /// Open a model-driven app by its unique name. public static Uri AppByName(string orgUrl, string uniqueName) From 5030c2ed511df7c920de197cdd2c02d45b6c9329 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 11 May 2026 19:56:54 +0200 Subject: [PATCH 10/10] fix: address PR #101 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Validate EnvironmentUrl before use — parse with Uri.TryCreate, fail with validation error for types that need orgUrl (AppModule, Report, SCF). CanvasApp/Workflow don't require it. 2. Reject non-positive raw type codes (rawCode > 0). 3. NormalizeOrgUrl uses Uri.TryCreate to extract host, not string replace. 4. BrowserLauncher logs at Warning level (not Information) for headless skip. 5. JSON output: aliases kept as string array, identity as enum value (not ToString). Text renderer formats for display only. 6. Package version note: 0.5.0-preview.1 is the local pack of the platform-metadata PR; will be updated to 0.5.0 when PR #55 is released. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/TALXIS.CLI.Core/Shared/BrowserLauncher.cs | 2 +- .../Component/Browse/BrowseUrlConstants.cs | 12 ++++++++++-- .../Browse/ComponentBrowseCliCommand.cs | 18 ++++++++++++++++-- .../ComponentTypeExplainCliCommand.cs | 2 +- .../Component/ComponentTypeListCliCommand.cs | 18 ++++++++++++------ 5 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/TALXIS.CLI.Core/Shared/BrowserLauncher.cs b/src/TALXIS.CLI.Core/Shared/BrowserLauncher.cs index 300e815..781e4e3 100644 --- a/src/TALXIS.CLI.Core/Shared/BrowserLauncher.cs +++ b/src/TALXIS.CLI.Core/Shared/BrowserLauncher.cs @@ -21,7 +21,7 @@ public static void Open(Uri url, ILogger logger) var detector = TxcServices.Get(); if (detector.IsHeadless) { - logger.LogInformation("Headless mode ({Reason}) — browser not opened.", detector.Reason); + logger.LogWarning("Headless mode ({Reason}) — browser not opened.", detector.Reason); return; } diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/BrowseUrlConstants.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/BrowseUrlConstants.cs index b0258c6..f326ce2 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Browse/BrowseUrlConstants.cs +++ b/src/TALXIS.CLI.Features.Environment/Component/Browse/BrowseUrlConstants.cs @@ -8,7 +8,15 @@ public static class BrowseUrlConstants /// Default Solution GUID — used when no solution context is specified for form/view/securityrole. public const string DefaultSolutionId = "fd140aaf-4df4-11dd-bd17-0019b9312238"; - /// Strips protocol and trailing slash from an org URL for use in URL construction. + /// + /// Extracts the hostname from an org URL for use in URL construction. + /// Accepts either a full URL or a bare hostname. + /// public static string NormalizeOrgUrl(string orgUrl) - => orgUrl.Replace("https://", "").Replace("http://", "").TrimEnd('/'); + { + if (Uri.TryCreate(orgUrl, UriKind.Absolute, out var uri)) + return uri.Host; + // Already a bare hostname + return orgUrl.TrimEnd('/'); + } } diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs index bbd26bb..d94bb99 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Component/Browse/ComponentBrowseCliCommand.cs @@ -114,7 +114,7 @@ protected override async Task ExecuteAsync() // Resolve component type var def = ComponentDefinitionRegistry.GetByName(Type); ComponentType? typeCode = def?.TypeCode; - if (typeCode is null && int.TryParse(Type, out var rawCode)) + if (typeCode is null && int.TryParse(Type, out var rawCode) && rawCode > 0) typeCode = (ComponentType)rawCode; if (typeCode is null) { @@ -126,7 +126,6 @@ protected override async Task ExecuteAsync() var configResolver = TxcServices.Get(); var ctx = await configResolver.ResolveAsync(Profile, CancellationToken.None).ConfigureAwait(false); var connection = ctx.Connection; - var orgUrl = connection.EnvironmentUrl; if (connection.EnvironmentId is null) { @@ -135,6 +134,21 @@ protected override async Task ExecuteAsync() } var environmentId = connection.EnvironmentId.Value; + // Validate EnvironmentUrl for types that require it (UCI, reports, SCF record forms) + var needsOrgUrl = typeCode.Value is ComponentType.AppModule or ComponentType.Report + || (typeCode.Value is not ComponentType.CanvasApp and not ComponentType.Workflow); + string? orgUrl = null; + if (!string.IsNullOrWhiteSpace(connection.EnvironmentUrl) + && Uri.TryCreate(connection.EnvironmentUrl, UriKind.Absolute, out var orgUri)) + { + orgUrl = orgUri.Host; + } + else if (needsOrgUrl) + { + Logger.LogError("Environment URL is not set or invalid on the connection. Run 'txc config connection check' to populate it."); + return ExitValidationError; + } + // Dispatch by component type Uri? url = typeCode.Value switch { diff --git a/src/TALXIS.CLI/Component/ComponentTypeExplainCliCommand.cs b/src/TALXIS.CLI/Component/ComponentTypeExplainCliCommand.cs index 8982ae2..3abb615 100644 --- a/src/TALXIS.CLI/Component/ComponentTypeExplainCliCommand.cs +++ b/src/TALXIS.CLI/Component/ComponentTypeExplainCliCommand.cs @@ -45,7 +45,7 @@ protected override Task ExecuteAsync() serializedName = def.SerializedName, directory = def.Directory, filePattern = def.FilePattern, - identity = def.Identity.ToString(), + identity = def.Identity, supportsMerge = def.SupportsMerge, isMergeable = def.IsMergeable, isFileBacked = def.IsFileBacked, diff --git a/src/TALXIS.CLI/Component/ComponentTypeListCliCommand.cs b/src/TALXIS.CLI/Component/ComponentTypeListCliCommand.cs index 588c7ee..260d0db 100644 --- a/src/TALXIS.CLI/Component/ComponentTypeListCliCommand.cs +++ b/src/TALXIS.CLI/Component/ComponentTypeListCliCommand.cs @@ -40,8 +40,8 @@ protected override Task ExecuteAsync() { typeCode = (int)d.TypeCode, name = d.Name, - aliases = d.Aliases != null ? string.Join(", ", d.Aliases) : "", - identity = d.Identity.ToString(), + aliases = d.Aliases ?? (IReadOnlyList)Array.Empty(), + identity = d.Identity, directory = d.Directory }).ToList(); @@ -53,7 +53,6 @@ protected override Task ExecuteAsync() #pragma warning disable TXC003 private static void PrintTypeTable(IReadOnlyList items) where T : notnull { - // Use dynamic to access anonymous type properties var rows = items.Cast().ToList(); if (rows.Count == 0) { @@ -61,9 +60,16 @@ private static void PrintTypeTable(IReadOnlyList items) where T : notnull return; } + // Format aliases as comma-separated for text display + static string FormatAliases(dynamic aliases) + { + var list = (IReadOnlyList)aliases; + return list.Count > 0 ? string.Join(", ", list) : ""; + } + int codeWidth = 6; int nameWidth = Math.Clamp(rows.Max(r => ((string)r.name).Length), 10, 35); - int aliasWidth = Math.Clamp(rows.Max(r => ((string)r.aliases).Length), 7, 30); + int aliasWidth = Math.Clamp(rows.Max(r => FormatAliases(r.aliases).Length), 7, 30); int identityWidth = 10; string header = $"{"Code".PadRight(codeWidth)} | {"Name".PadRight(nameWidth)} | {"Aliases".PadRight(aliasWidth)} | {"Identity".PadRight(identityWidth)} | Directory"; @@ -74,8 +80,8 @@ private static void PrintTypeTable(IReadOnlyList items) where T : notnull OutputWriter.WriteLine( $"{((int)r.typeCode).ToString().PadRight(codeWidth)} | " + $"{((string)r.name).PadRight(nameWidth)} | " + - $"{((string)r.aliases).PadRight(aliasWidth)} | " + - $"{((string)r.identity).PadRight(identityWidth)} | " + + $"{FormatAliases(r.aliases).PadRight(aliasWidth)} | " + + $"{r.identity.ToString().PadRight(identityWidth)} | " + $"{(string)r.directory}"); } OutputWriter.WriteLine($"\n{rows.Count} component type(s).");