diff --git a/src/Dataverse/CodeApp/README.md b/src/Dataverse/CodeApp/README.md index bdd1f1d..08894e9 100644 --- a/src/Dataverse/CodeApp/README.md +++ b/src/Dataverse/CodeApp/README.md @@ -30,9 +30,9 @@ Or use the SDK approach: ### Build-time targets -1. **CheckCodeAppPrereqs** -- validates that `package.json` exists and that `node` / `npm` are available in PATH. Runs only when `RunNodeBuild` is `true` (auto-detected from the presence of `package.json`). -2. **BuildCodeApp** (runs before `Build`, depends on `CheckCodeAppPrereqs`) -- executes `npm install` followed by `npm run build` in the project root directory. -3. **CopyCodeAppDist** (runs after `Build`) -- copies the `dist/` folder to `$(OutputPath)$(AppName)\`. Fails the build if `dist/` is missing or if `AppName` is not set. +1. **CheckNodePrereqs** / **NodeInstallPackagesWithLock** / **NodeInstallPackagesWithoutLock** -- shared Node.js targets from `TALXIS.DevKit.Build.Dataverse.Tasks` that validate `package.json`, check `node` / `npm`, and install packages in the project root when `RunNodeBuild` is enabled. +2. **BuildCodeApp** (runs before `Build`, depends on the shared Node.js targets) -- executes `npm run $(NodeBuildScript)` in the project root directory and passes `NODE_ENV` plus `BUILD_OUTPUT_DIR` to the npm script. +3. **CopyCodeAppDist** (runs after `Build`) -- copies the npm build output to `$(OutputPath)$(AppName)\`, preferring `$(NodeBuildOutputDir)` and falling back to `dist/`. Fails the build if no output folder is found or if `AppName` is not set. 4. **CopyCodeAppDistPublish** (runs after `Publish`) -- same as above, but copies to `$(PublishDir)` instead. ### Integration targets @@ -40,7 +40,7 @@ Or use the SDK approach: These targets are called by `TALXIS.DevKit.Build.Dataverse.Solution` when it discovers this project via `ProjectReference`: - **GetProjectType** -- returns `CodeApp` so the Solution build knows how to handle this reference. -- **GetCodeAppOutputs** (depends on `Build`) -- returns the path to the compiled `dist/` folder along with `AppName` and `ConfigPath` (location of `power.config.json`) metadata. The Solution project uses this to call `GenerateCodeAppMetaXml` and produce the `.meta.xml` file for PAC packaging. +- **GetCodeAppOutputs** (depends on `Build`) -- returns the path to the compiled npm output folder, preferring `$(NodeBuildOutputDir)` and falling back to `dist/`, along with `AppName` and `ConfigPath` (location of `power.config.json`) metadata. The Solution project uses this to call `GenerateCodeAppMetaXml` and produce the `.meta.xml` file for PAC packaging. ### What happens in the Solution project @@ -49,7 +49,7 @@ When a Solution project has a `ProjectReference` to a CodeApp project, the follo 1. **ProbeCodeApps** discovers the CodeApp reference by calling `GetProjectType`. 2. **BuildCodeApps** calls `GetCodeAppOutputs`, which triggers the full CodeApp build (npm install + build). 3. **PrepareCodeAppsSources** generates `.meta.xml` via `GenerateCodeAppMetaXml`, adds a `RootComponent` entry (Type 300) to `Solution.xml`, and ensures the `CanvasApps` node exists in `Customizations.xml`. -4. **CopyCodeAppsToMetadata** copies the CodeApp dist output into the solution metadata `CanvasApps/` folder before PAC packages the solution. +4. **CopyCodeAppsToMetadata** copies the resolved CodeApp build output into the solution metadata `CanvasApps/` folder before PAC packages the solution. The CodeApp reference is automatically filtered out of the standard `ResolveProjectReferences` pipeline to avoid unnecessary assembly resolution. diff --git a/src/Dataverse/CodeApp/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.CodeApp.targets b/src/Dataverse/CodeApp/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.CodeApp.targets index 1e6dd98..4e06e29 100644 --- a/src/Dataverse/CodeApp/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.CodeApp.targets +++ b/src/Dataverse/CodeApp/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.CodeApp.targets @@ -1,28 +1,14 @@ - - true - false - - - - - - - - - - - - - + + - + + - + @@ -52,7 +41,13 @@ DependsOnTargets="Build" Returns="@(_CodeAppOutputs)"> - <_CodeAppOutputs Include="$(MSBuildProjectDirectory)/dist"> + <_CodeAppOutputs Include="$(NodeBuildOutputDir)" + Condition="Exists('$(NodeBuildOutputDir)')"> + $(AppName) + $(MSBuildProjectDirectory)/power.config.json + + <_CodeAppOutputs Include="$(MSBuildProjectDirectory)/dist" + Condition="'@(_CodeAppOutputs)'=='' and Exists('$(MSBuildProjectDirectory)/dist')"> $(AppName) $(MSBuildProjectDirectory)/power.config.json @@ -65,10 +60,13 @@ - + + - + diff --git a/src/Dataverse/GenPage/README.md b/src/Dataverse/GenPage/README.md index 8207b11..5e61480 100644 --- a/src/Dataverse/GenPage/README.md +++ b/src/Dataverse/GenPage/README.md @@ -1,63 +1,23 @@ -# TALXIS.DevKit.Build.Dataverse.GenPage +# TALXIS DevKit Dataverse GenPage build support -MSBuild integration for Dataverse generative page (GenPage) projects. Transpiles TSX source files via TypeScript, patches in RuntimeTypes, generates config metadata, and exposes output targets for Solution projects to discover and integrate GenPages as `uxagentprojects`. +MSBuild integration for Dataverse GenPage projects. -## Installation +A GenPage project is a project-type marker only. It carries no Dataverse IDs or page metadata. Every `*.tsx` file outside excluded build folders is treated as a page, and the page name is the file name without extension. -```xml - -``` - -Or use the SDK approach: +## Project contract ```xml - - - GenPage - {your-genpage-guid} - datasource1,datasource2 - - + + GenPage + ``` -## Prerequisites - -- **Node.js** must be available on `PATH` -- **npx** must be available on `PATH` - -The build will fail with a descriptive error if either is missing. - -## How It Works - -The package sets `ProjectType` to `GenPage` and disables `GenerateAssemblyInfo` by default since this is not a traditional .NET assembly project. - -### Build-time targets - -1. **CheckGenPagePrereqs** — validates that the main TSX file exists and `node`/`npx` are on `PATH`. -2. **TranspileGenPage** (runs before `Build`) — executes `tsc` via `npx` to transpile TSX to JS, then patches the output by stripping `RuntimeTypes` imports and prepending `RuntimeTypes.js` content if present. -3. **GenerateGenPageConfig** — generates `config.json` from project properties (`GenPageDataSources`). -4. **CopyGenPageOutputs** (runs after `Build`) — copies `page.tsx`, `page.compiled`, and `config.json` to the output directory. - -### Integration targets - -Called by `TALXIS.DevKit.Build.Dataverse.Solution` via `ProjectReference`: - -- **GetProjectType** — returns `GenPage`. -- **GetGenPageOutputs** — exposes the compiled output folder and metadata for the solution to copy into `uxagentprojects/`. +Optional source files: -## MSBuild Properties +- `.config.json`, otherwise shared `genpage.config.json` -| Property | Default | Description | -|----------|---------|-------------| -| `ProjectType` | `GenPage` | Marks the project for reference discovery by Solution projects. | -| `GenPageMainFile` | `page.tsx` | Main TSX source file to transpile. | -| `GenPageName` | `$(MSBuildProjectName)` | Name used for the output folder and metadata. | -| `GenPageId` | _(required)_ | GUID identifying this GenPage in Dataverse. | -| `GenPageDataSources` | _(empty)_ | Comma-separated list of data source identifiers. | -| `LangVersion` | `latest` | C# language version for the project. | -| `GenerateAssemblyInfo` | `false` | Disables auto-generated assembly info. | +Build output is normalized to `$(TargetDir).js` for each page. -## Related Packages +## Solution integration -- **Depends on**: `TALXIS.DevKit.Build.Dataverse.Tasks` -- **Consumed by**: `TALXIS.DevKit.Build.Dataverse.Solution` projects via `ProjectReference` +Solution projects discover referenced GenPage projects, call `GetGenPageOutputs`, ensure XML-only `uxagentprojects//...` declarations exist in solution source, then project native `filecontent/` only into the SolutionPackager metadata working directory under `obj`. diff --git a/src/Dataverse/GenPage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.GenPage.targets b/src/Dataverse/GenPage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.GenPage.targets index 02e24d1..ba5702d 100644 --- a/src/Dataverse/GenPage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.GenPage.targets +++ b/src/Dataverse/GenPage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.GenPage.targets @@ -2,79 +2,98 @@ - page.tsx - $([System.IO.Path]::ChangeExtension('$(GenPageMainFile)', '.js')) - $(MSBuildProjectName) - - <_NormalizedGenPageId>$([System.String]::Copy('$(GenPageId)').Trim().Replace('{','').Replace('}','').ToLowerInvariant()) - genpage.config.json + $(MSBuildProjectDirectory)/RuntimeTypes.ts + react;react-dom;react-dom/client;react/jsx-runtime;@fluentui/react-components;@fluentui/react-icons - - - - - + + + <_ProjectType Include="$(MSBuildProjectFullPath)"> + $(ProjectType) + + - - - - + + + <_DiscoveredGenPage Remove="@(_DiscoveredGenPage)" /> + + + + + - - - + + + - - + + + - - + + + + <_GenPageBundle Remove="@(_GenPageBundle)" /> + - + + - + + - + - + Condition="'@(_DiscoveredGenPage)'!='' and !Exists('$(NodeBuildOutputDir)%(_DiscoveredGenPage.PageName).js') and !Exists('$(MSBuildProjectDirectory)/dist/%(_DiscoveredGenPage.PageName).js') and Exists('$(MSBuildProjectDirectory)/build/%(_DiscoveredGenPage.PageName).js')" /> - - - <_ProjectType Include="$(MSBuildProjectFullPath)"> - $(ProjectType) - + + + + <_GenPageBundle Include="$(TargetDir)%(_DiscoveredGenPage.PageName).js"> + %(_DiscoveredGenPage.PageName) + $(TargetDir)%(_DiscoveredGenPage.PageName).js + + + - - <_GenPageOutputs Include="$(MSBuildProjectDirectory)/$(OutputPath)$(GenPageName)"> - $(GenPageName) - $(_NormalizedGenPageId) - $(MSBuildProjectDirectory)/$(GenPageConfigFile) + <_GenPageOutputs Remove="@(_GenPageOutputs)" /> + <_GenPageOutputs Include="$(TargetDir)%(_DiscoveredGenPage.PageName).js"> + %(_DiscoveredGenPage.PageName) + $(TargetDir)%(_DiscoveredGenPage.PageName).js + %(_DiscoveredGenPage.EntryFile) + %(_DiscoveredGenPage.ConfigJsonPath) + $(MSBuildProjectFullPath) diff --git a/src/Dataverse/Pcf/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Pcf.targets b/src/Dataverse/Pcf/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Pcf.targets index ed1cd74..9366c62 100644 --- a/src/Dataverse/Pcf/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Pcf.targets +++ b/src/Dataverse/Pcf/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Pcf.targets @@ -19,12 +19,8 @@ - - - - - + diff --git a/src/Dataverse/ScriptLibrary/README.md b/src/Dataverse/ScriptLibrary/README.md index e3fa26d..9b0f0fa 100644 --- a/src/Dataverse/ScriptLibrary/README.md +++ b/src/Dataverse/ScriptLibrary/README.md @@ -33,9 +33,9 @@ The package sets `ProjectType` to `ScriptLibrary` and disables `GenerateAssembly ### Build-time targets -1. **CheckScriptLibraryPrereqs** -- validates that `TypeScriptDir` exists, `package.json` is present, and `node`/`npm` are on `PATH`. -2. **BuildTypeScript** (runs before `Build`) -- executes `npm install` followed by `npm run build` in `TypeScriptDir`. -3. **CopyScriptLibraryMainToOutput** (runs after `Build`) -- copies the main JS file from `TypeScriptDir\build\` to the output directory. +1. **NodeInstallPackagesWithLock** / **NodeInstallPackagesWithoutLock** -- shared Node.js targets from `TALXIS.DevKit.Build.Dataverse.Tasks` that validate `package.json`, check `node` / `npm`, and install packages in `TypeScriptDir`. +2. **BuildTypeScript** (runs before `Build`) -- first validates that `TypeScriptDir` exists, then executes `npm run $(NodeBuildScript)` in `TypeScriptDir` and passes `NODE_ENV` plus `BUILD_OUTPUT_DIR` to the npm script. +3. **CopyScriptLibraryMainToOutput** (runs after `Build`) -- copies the main JS file for `ScriptLibraryName` from `TypeScriptDir` to the output directory, regardless of which subfolder the bundler used. ### Integration targets diff --git a/src/Dataverse/ScriptLibrary/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.targets b/src/Dataverse/ScriptLibrary/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.targets index fc30977..f156807 100644 --- a/src/Dataverse/ScriptLibrary/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.targets +++ b/src/Dataverse/ScriptLibrary/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.targets @@ -3,28 +3,17 @@ $(MSBuildProjectDirectory)/TS - - true - false + $(TypeScriptDir) - - - - - - - - - - - + + diff --git a/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.CodeApps.targets b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.CodeApps.targets index d3a9a03..ddf78df 100644 --- a/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.CodeApps.targets +++ b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.CodeApps.targets @@ -107,7 +107,7 @@ + Text="Copying CodeApp output to $(_MetadataCanvasAppsDir)$(PublisherPrefix)_%(_CodeAppOutputs.AppName)_CodeAppPackages/" /> - - <_ProjectTypeFromReferences Remove="@(_ProjectTypeFromReferences)" /> @@ -47,70 +43,62 @@ DependsOnTargets="ProbeGenPages" Condition="'@(_GenPageProjects)'!=''"> + + <_GenPageOutput Remove="@(_GenPageOutput)" /> + + + - + + + + %(_GenPageOutput.PageName) + %(_GenPageOutput.CompiledJsPath) + %(_GenPageOutput.EntrySourcePath) + %(_GenPageOutput.ConfigJsonPath) + %(_GenPageOutput.OwningProjectPath) + + + + Text="GenPage outputs: @(GenPageRef->'%(PageName) from %(OwningProjectPath)', ', ')" + Condition="'@(GenPageRef)'!=''" /> - + Condition="'@(GenPageRef)'!=''"> - <_MetadataUxAgentProjectsDir>$(SolutionPackagerMetadataWorkingDirectory)/uxagentprojects/ + <_GenPageSolutionRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(SolutionRootPath)')) - + + <_DeclaredGenPage Remove="@(_DeclaredGenPage)" /> + - + + + - - - - - - - - - - - - - + + + + diff --git a/src/Dataverse/Tasks/Tasks/GenPageDiscoverPages.cs b/src/Dataverse/Tasks/Tasks/GenPageDiscoverPages.cs new file mode 100644 index 0000000..239c198 --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/GenPageDiscoverPages.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +public sealed class GenPageDiscoverPages : Task +{ + [Required] + public string ProjectDirectory { get; set; } = ""; + + [Output] + public ITaskItem[] GenPages { get; private set; } = Array.Empty(); + + public override bool Execute() + { + try + { + var root = Path.GetFullPath(ProjectDirectory); + if (!Directory.Exists(root)) + throw new DirectoryNotFoundException($"GenPage project directory not found: {root}"); + + var excludedDirs = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "node_modules", "dist", "build", "bin", "obj", ".git" + }; + + var files = Directory.EnumerateFiles(root, "*.tsx", SearchOption.AllDirectories) + .Where(f => + { + var relative = MakeRelative(root, f); + var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return !segments.Take(segments.Length - 1).Any(s => excludedDirs.Contains(s)); + }) + .OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var duplicates = files + .GroupBy(f => Path.GetFileNameWithoutExtension(f), StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .ToArray(); + + if (duplicates.Length > 0) + { + foreach (var dup in duplicates) + Log.LogError($"Duplicate GenPage page name '{dup.Key}': {string.Join(", ", dup.Select(f => MakeRelative(root, f)))}"); + return false; + } + + var items = new List(); + foreach (var file in files) + { + var pageName = Path.GetFileNameWithoutExtension(file); + if (string.IsNullOrWhiteSpace(pageName)) + { + Log.LogError($"Invalid GenPage page file name: {file}"); + continue; + } + + var siblingConfig = Path.Combine(root, pageName + ".config.json"); + var sharedConfig = Path.Combine(root, "genpage.config.json"); + + var item = new TaskItem(file); + item.SetMetadata("PageName", pageName); + item.SetMetadata("EntryFile", file); + item.SetMetadata("ConfigJsonPath", File.Exists(siblingConfig) ? siblingConfig : (File.Exists(sharedConfig) ? sharedConfig : "")); + items.Add(item); + } + + if (items.Count == 0) + Log.LogWarning($"No *.tsx files found in {root} (excluding node_modules/dist/build/bin/obj)."); + + GenPages = items.ToArray(); + return !Log.HasLoggedErrors; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, true, true, null); + return false; + } + } + + private static string MakeRelative(string root, string path) + { + return path.Substring(root.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } +} diff --git a/src/Dataverse/Tasks/Tasks/GenPageEnsureDeclarations.cs b/src/Dataverse/Tasks/Tasks/GenPageEnsureDeclarations.cs new file mode 100644 index 0000000..dd915e3 --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/GenPageEnsureDeclarations.cs @@ -0,0 +1,394 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +public sealed class GenPageEnsureDeclarations : Task +{ + private static readonly GenPageFileDefinition CompiledFile = new("Compiled", "src/pages/page.compiled", "page.compiled", "application/octet-stream", "200000001"); + private static readonly GenPageFileDefinition SourceFile = new("Source", "src/pages/page.tsx", "page.tsx", "application/octet-stream", "200000000"); + private static readonly GenPageFileDefinition ConfigFile = new("Config", "config.json", "config.json", "application/json", "200000000"); + + [Required] + public string SolutionRoot { get; set; } = ""; + + [Required] + public ITaskItem[] Pages { get; set; } = Array.Empty(); + + public string CustomizationsXmlPath { get; set; } = ""; + + [Output] + public ITaskItem[] DeclaredPages { get; private set; } = Array.Empty(); + + public override bool Execute() + { + try + { + var solutionRoot = Path.GetFullPath(SolutionRoot); + var uxRoot = Path.Combine(solutionRoot, "uxagentprojects"); + Directory.CreateDirectory(uxRoot); + + RemoveUxAgentProjectsPlaceholder(); + RemoveGenPageRootComponents(solutionRoot); + + var duplicates = Pages.GroupBy(p => p.GetMetadata("PageName"), StringComparer.OrdinalIgnoreCase) + .Where(g => string.IsNullOrWhiteSpace(g.Key) || g.Count() > 1) + .Select(g => string.IsNullOrWhiteSpace(g.Key) ? "" : g.Key) + .ToArray(); + if (duplicates.Length > 0) + { + Log.LogError($"Duplicate GenPage page name(s) across referenced projects: {string.Join(", ", duplicates)}"); + return false; + } + + var pagesByName = Pages.ToDictionary(p => p.GetMetadata("PageName"), StringComparer.OrdinalIgnoreCase); + var existing = ReadExistingDeclarations(uxRoot); + + foreach (var group in existing.Values.GroupBy(d => d.PageName, StringComparer.OrdinalIgnoreCase).Where(g => g.Count() > 1)) + Log.LogError($"Duplicate uxagentproject name '{group.Key}' found in solution source."); + + foreach (var declaration in existing.Values) + { + if (!pagesByName.ContainsKey(declaration.PageName)) + Log.LogError($"Orphan GenPage declaration '{declaration.PageName}' at {declaration.ProjectXmlPath}; no referenced GenPage project emits this page."); + } + + if (Log.HasLoggedErrors) + return false; + + ValidateSitemapReferences(pagesByName.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase)); + if (Log.HasLoggedErrors) + return false; + + var declared = new List(); + foreach (var page in Pages.OrderBy(p => p.GetMetadata("PageName"), StringComparer.OrdinalIgnoreCase)) + { + var pageName = page.GetMetadata("PageName"); + if (!existing.TryGetValue(pageName, out var declaration)) + { + declaration = CreateDeclaration(uxRoot, pageName); + existing.Add(pageName, declaration); + } + + NormalizeDeclaration(declaration, pageName, GetDesiredFiles(page).ToArray()); + + var item = new TaskItem(page.ItemSpec); + page.CopyMetadataTo(item); + item.SetMetadata("PageGuid", declaration.PageGuid); + item.SetMetadata("ProjectXmlPath", declaration.ProjectXmlPath); + foreach (var file in declaration.Files.Values) + { + item.SetMetadata(file.Definition.Prefix + "FileGuid", file.FileGuid); + item.SetMetadata(file.Definition.Prefix + "FileXmlPath", file.FileXmlPath); + } + + // Backwards-compatible metadata for the compiled payload. + if (declaration.Files.TryGetValue(CompiledFile.LogicalPath, out var compiled)) + { + item.SetMetadata("FileGuid", compiled.FileGuid); + item.SetMetadata("FileXmlPath", compiled.FileXmlPath); + } + + declared.Add(item); + } + + DeclaredPages = declared.ToArray(); + return true; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, true, true, null); + return false; + } + } + + private Dictionary ReadExistingDeclarations(string uxRoot) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!Directory.Exists(uxRoot)) + return result; + + foreach (var projectXml in Directory.GetFiles(uxRoot, "uxagentproject.xml", SearchOption.AllDirectories)) + { + var doc = XDocument.Load(projectXml); + var root = doc.Root; + if (root == null || root.Name.LocalName != "uxagentproject") + continue; + + var pageName = root.Element("name")?.Value?.Trim(); + if (string.IsNullOrWhiteSpace(pageName)) + { + Log.LogError($"GenPage declaration is missing a name: {projectXml}"); + continue; + } + + var pageGuid = NormalizeGuid(root.Attribute("uxagentprojectid")?.Value); + if (string.IsNullOrWhiteSpace(pageGuid)) + pageGuid = NormalizeGuid(Path.GetFileName(Path.GetDirectoryName(projectXml) ?? "")); + if (string.IsNullOrWhiteSpace(pageGuid)) + { + Log.LogError($"GenPage declaration has no valid page GUID: {projectXml}"); + continue; + } + + var declaration = new Declaration(pageName, pageGuid, projectXml); + var projectDir = Path.GetDirectoryName(projectXml) ?? uxRoot; + foreach (var fileXml in Directory.GetFiles(projectDir, "uxagentprojectfile.xml", SearchOption.AllDirectories).OrderBy(p => p, StringComparer.OrdinalIgnoreCase)) + { + var fileDoc = XDocument.Load(fileXml); + var fileGuid = NormalizeGuid(fileDoc.Root?.Attribute("uxagentprojectfileid")?.Value) ?? NormalizeGuid(Path.GetFileName(Path.GetDirectoryName(fileXml) ?? "")); + if (string.IsNullOrWhiteSpace(fileGuid)) + { + Log.LogError($"GenPage declaration '{pageName}' has no valid file GUID: {fileXml}"); + continue; + } + + var logicalPath = NormalizeLogicalPath(fileDoc.Root?.Element("filename")?.Value, fileDoc.Root?.Element("filecontent")?.Value); + var definition = GetDefinition(logicalPath); + if (definition == null) + continue; + + if (!declaration.Files.ContainsKey(definition.LogicalPath)) + declaration.Files.Add(definition.LogicalPath, new FileDeclaration(definition, fileGuid, fileXml)); + } + + if (result.ContainsKey(pageName)) + Log.LogError($"Duplicate GenPage declaration name '{pageName}' at {projectXml}."); + else + result.Add(pageName, declaration); + } + + return result; + } + + private Declaration CreateDeclaration(string uxRoot, string pageName) + { + var pageGuid = Guid.NewGuid().ToString("D"); + var pageDir = Path.Combine(uxRoot, pageGuid); + Directory.CreateDirectory(pageDir); + + var projectXml = Path.Combine(pageDir, "uxagentproject.xml"); + var declaration = new Declaration(pageName, pageGuid, projectXml); + + Log.LogMessage(MessageImportance.High, $"Created GenPage declaration '{pageName}' ({pageGuid}) in {pageDir}"); + return declaration; + } + + private void NormalizeDeclaration(Declaration declaration, string pageName, GenPageFileDefinition[] desiredFiles) + { + var pageDir = Path.Combine(Path.GetDirectoryName(declaration.ProjectXmlPath) ?? Path.Combine(Path.GetFullPath(SolutionRoot), "uxagentprojects"), ""); + Directory.CreateDirectory(pageDir); + + SaveProjectXml(declaration.ProjectXmlPath, declaration.PageGuid, pageName); + + var desired = new HashSet(desiredFiles.Select(f => f.LogicalPath), StringComparer.OrdinalIgnoreCase); + foreach (var definition in desiredFiles) + { + if (!declaration.Files.TryGetValue(definition.LogicalPath, out var file)) + { + var fileGuid = Guid.NewGuid().ToString("D"); + var fileXml = Path.Combine(pageDir, "uxagentprojectfiles", fileGuid, "uxagentprojectfile.xml"); + file = new FileDeclaration(definition, fileGuid, fileXml); + declaration.Files.Add(definition.LogicalPath, file); + } + else + { + file.Definition = definition; + file.FileXmlPath = Path.Combine(pageDir, "uxagentprojectfiles", file.FileGuid, "uxagentprojectfile.xml"); + } + + SaveFileXml(file.FileXmlPath, file.FileGuid, definition); + } + + foreach (var stale in declaration.Files.Values.Where(f => !desired.Contains(f.Definition.LogicalPath)).ToArray()) + { + TryDeleteDirectory(Path.GetDirectoryName(stale.FileXmlPath)); + declaration.Files.Remove(stale.Definition.LogicalPath); + } + + foreach (var legacyFileXml in Directory.GetFiles(pageDir, "uxagentprojectfile.xml", SearchOption.AllDirectories)) + { + var normalized = Path.GetFullPath(legacyFileXml); + if (!declaration.Files.Values.Any(f => string.Equals(Path.GetFullPath(f.FileXmlPath), normalized, StringComparison.OrdinalIgnoreCase))) + TryDeleteDirectory(Path.GetDirectoryName(legacyFileXml)); + } + } + + private IEnumerable GetDesiredFiles(ITaskItem page) + { + yield return CompiledFile; + yield return SourceFile; + yield return ConfigFile; + } + + private void ValidateSitemapReferences(HashSet knownPageNames) + { + var path = string.IsNullOrWhiteSpace(CustomizationsXmlPath) ? Path.Combine(SolutionRoot, "Other", "Customizations.xml") : CustomizationsXmlPath; + if (!File.Exists(path)) + return; + + var doc = XDocument.Load(path); + foreach (var element in doc.Descendants().Where(e => string.Equals(e.Name.LocalName, "SubArea", StringComparison.OrdinalIgnoreCase))) + { + foreach (var attrName in new[] { "PageName", "GenPage" }) + { + var value = element.Attributes().FirstOrDefault(a => string.Equals(a.Name.LocalName, attrName, StringComparison.OrdinalIgnoreCase))?.Value; + if (!string.IsNullOrWhiteSpace(value) && !knownPageNames.Contains(value.Trim())) + Log.LogError($"Sitemap GenPage reference '{value}' in {path} does not match any referenced GenPage page."); + } + } + } + + private void RemoveUxAgentProjectsPlaceholder() + { + var path = string.IsNullOrWhiteSpace(CustomizationsXmlPath) ? Path.Combine(SolutionRoot, "Other", "Customizations.xml") : CustomizationsXmlPath; + if (!File.Exists(path)) + return; + + var doc = XDocument.Load(path, LoadOptions.PreserveWhitespace); + var nodes = doc.Root?.Elements().Where(e => string.Equals(e.Name.LocalName, "uxagentprojects", StringComparison.OrdinalIgnoreCase)).ToArray() ?? Array.Empty(); + if (nodes.Length == 0) + return; + + foreach (var node in nodes) + node.Remove(); + SaveXml(doc, path, omitDeclaration: false); + Log.LogMessage(MessageImportance.High, $"Removed uxagentprojects placeholder from {path}"); + } + + private void RemoveGenPageRootComponents(string solutionRoot) + { + var path = Path.Combine(solutionRoot, "Other", "Solution.xml"); + if (!File.Exists(path)) + return; + + var doc = XDocument.Load(path, LoadOptions.PreserveWhitespace); + var nodes = doc.Descendants() + .Where(e => string.Equals(e.Name.LocalName, "RootComponent", StringComparison.OrdinalIgnoreCase) + && string.Equals(e.Attribute("type")?.Value, "10090", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (nodes.Length == 0) + return; + + foreach (var node in nodes) + node.Remove(); + SaveXml(doc, path, omitDeclaration: false); + Log.LogMessage(MessageImportance.High, $"Removed {nodes.Length} GenPage root component declaration(s) from {path}"); + } + + private static GenPageFileDefinition? GetDefinition(string logicalPath) + { + return new[] { CompiledFile, SourceFile, ConfigFile } + .FirstOrDefault(f => string.Equals(f.LogicalPath, logicalPath, StringComparison.OrdinalIgnoreCase) + || string.Equals(f.BaseFileName, logicalPath, StringComparison.OrdinalIgnoreCase)); + } + + private static string NormalizeLogicalPath(string? filename, string? filecontent) + { + var value = string.IsNullOrWhiteSpace(filename) ? filecontent : filename; + value = (value ?? "").Trim().Replace('\\', '/'); + return GetDefinition(value)?.LogicalPath ?? value; + } + + private static void SaveProjectXml(string path, string pageGuid, string pageName) + { + SaveXml(new XDocument(new XElement("uxagentproject", + new XAttribute("uxagentprojectid", pageGuid), + new XElement("iscustomizable", "1"), + new XElement("name", pageName.ToLowerInvariant()), + new XElement("statecode", "0"), + new XElement("statuscode", "1"))), path, omitDeclaration: true); + } + + private static void SaveFileXml(string path, string fileGuid, GenPageFileDefinition definition) + { + SaveXml(new XDocument(new XElement("uxagentprojectfile", + new XAttribute("uxagentprojectfileid", fileGuid), + new XElement("filecontent", new XAttribute("mimetype", definition.MimeType), definition.BaseFileName), + new XElement("filename", definition.LogicalPath), + new XElement("filetype", definition.FileType), + new XElement("iscustomizable", "1"), + new XElement("statecode", "0"), + new XElement("statuscode", "1"))), path, omitDeclaration: true); + } + + private static void SaveXml(XDocument doc, string path, bool omitDeclaration) + { + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? "."); + var settings = new XmlWriterSettings + { + Encoding = new UTF8Encoding(false), + Indent = true, + OmitXmlDeclaration = omitDeclaration, + NewLineChars = Environment.NewLine, + NewLineHandling = NewLineHandling.Replace + }; + using var writer = XmlWriter.Create(path, settings); + doc.Save(writer); + } + + private static void TryDeleteDirectory(string? directory) + { + if (!string.IsNullOrWhiteSpace(directory) && Directory.Exists(directory)) + Directory.Delete(directory, true); + } + + private static string NormalizeGuid(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return ""; + return Guid.TryParse(value.Trim().Trim('{', '}'), out var guid) ? guid.ToString("D") : ""; + } + + private sealed class Declaration + { + public Declaration(string pageName, string pageGuid, string projectXmlPath) + { + PageName = pageName; + PageGuid = pageGuid; + ProjectXmlPath = projectXmlPath; + } + + public string PageName { get; } + public string PageGuid { get; } + public string ProjectXmlPath { get; } + public Dictionary Files { get; } = new(StringComparer.OrdinalIgnoreCase); + } + + private sealed class FileDeclaration + { + public FileDeclaration(GenPageFileDefinition definition, string fileGuid, string fileXmlPath) + { + Definition = definition; + FileGuid = fileGuid; + FileXmlPath = fileXmlPath; + } + + public GenPageFileDefinition Definition { get; set; } + public string FileGuid { get; } + public string FileXmlPath { get; set; } + } + + private sealed class GenPageFileDefinition + { + public GenPageFileDefinition(string prefix, string logicalPath, string baseFileName, string mimeType, string fileType) + { + Prefix = prefix; + LogicalPath = logicalPath; + BaseFileName = baseFileName; + MimeType = mimeType; + FileType = fileType; + } + + public string Prefix { get; } + public string LogicalPath { get; } + public string BaseFileName { get; } + public string MimeType { get; } + public string FileType { get; } + } +} diff --git a/src/Dataverse/Tasks/Tasks/GenPageGenerateRuntimeTypes.cs b/src/Dataverse/Tasks/Tasks/GenPageGenerateRuntimeTypes.cs new file mode 100644 index 0000000..777c780 --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/GenPageGenerateRuntimeTypes.cs @@ -0,0 +1,84 @@ +using System; +using System.IO; +using System.Text; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +public sealed class GenPageGenerateRuntimeTypes : Task +{ + [Required] + public string ProjectDirectory { get; set; } = ""; + + [Required] + public string OutputPath { get; set; } = ""; + + public string Command { get; set; } = ""; + + public int TimeoutSeconds { get; set; } = 600; + + public override bool Execute() + { + try + { + var projectDirectory = Path.GetFullPath(ProjectDirectory); + var outputPath = Path.GetFullPath(OutputPath); + Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? projectDirectory); + + var command = string.IsNullOrWhiteSpace(Command) + ? $"pac generate-types --output \"{outputPath}\"" + : Command.Replace("$(OutputPath)", outputPath).Replace("$(ProjectDirectory)", projectDirectory); + + var commandSucceeded = TryRun(command, projectDirectory); + if (commandSucceeded && File.Exists(outputPath)) + { + Log.LogMessage(MessageImportance.High, $"Generated GenPage runtime types: {outputPath}"); + return true; + } + + if (!commandSucceeded) + Log.LogWarning($"PAC GenPage runtime type generation command did not complete successfully: {command}"); + + if (!File.Exists(outputPath)) + { + File.WriteAllText(outputPath, + "// Generated placeholder. Run PAC GenPage type generation to refresh runtime types.\n" + + "export {};\n", + new UTF8Encoding(false)); + Log.LogWarning($"PAC GenPage runtime type generation did not produce {outputPath}; wrote a placeholder RuntimeTypes.ts."); + } + + return true; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, true, true, null); + return false; + } + } + + private bool TryRun(string command, string workingDirectory) + { + if (!NodeProcessRunner.TrySplitCommandLine(command, out var fileName, out var arguments, out var error)) + { + Log.LogWarning($"Could not parse GenPage runtime type command '{command}': {error}"); + return false; + } + + var result = NodeProcessRunner.Run( + Log, + fileName, + arguments, + workingDirectory, + TimeoutSeconds, + ignoreExitCode: true, + standardOutputImportance: MessageImportance.Normal, + standardErrorImportance: MessageImportance.High); + + if (result.TimedOut) + Log.LogWarning($"GenPage runtime type command timed out after {TimeoutSeconds} seconds: {command}"); + else if (result.ExitCode != 0) + Log.LogWarning($"GenPage runtime type command exited with code {result.ExitCode}: {command}"); + + return result.Succeeded; + } +} diff --git a/src/Dataverse/Tasks/Tasks/GenPageProjectNativeTree.cs b/src/Dataverse/Tasks/Tasks/GenPageProjectNativeTree.cs new file mode 100644 index 0000000..c415ac2 --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/GenPageProjectNativeTree.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +public sealed class GenPageProjectNativeTree : Task +{ + [Required] + public string MetadataRoot { get; set; } = ""; + + [Required] + public ITaskItem[] Pages { get; set; } = Array.Empty(); + + public override bool Execute() + { + try + { + var metadataRoot = Path.GetFullPath(MetadataRoot); + foreach (var page in Pages) + ProjectPage(metadataRoot, page); + return !Log.HasLoggedErrors; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, true, true, null); + return false; + } + } + + private void ProjectPage(string metadataRoot, ITaskItem page) + { + var pageName = page.GetMetadata("PageName"); + var pageGuid = page.GetMetadata("PageGuid"); + var projectXml = page.GetMetadata("ProjectXmlPath"); + var compiledJs = page.GetMetadata("CompiledJsPath"); + var entrySource = page.GetMetadata("EntrySourcePath"); + var config = page.GetMetadata("ConfigJsonPath"); + + RequireFile(projectXml, $"GenPage project XML for {pageName}"); + RequireFile(page.GetMetadata("CompiledFileXmlPath"), $"compiled GenPage file XML for {pageName}"); + RequireFile(page.GetMetadata("SourceFileXmlPath"), $"source GenPage file XML for {pageName}"); + RequireFile(compiledJs, $"compiled GenPage bundle for {pageName}"); + RequireFile(entrySource, $"GenPage source entry for {pageName}"); + if (!string.IsNullOrWhiteSpace(config)) RequireFile(config, $"GenPage config for {pageName}"); + if (Log.HasLoggedErrors) return; + + var pageDir = Path.Combine(metadataRoot, "uxagentprojects", pageGuid); + if (Directory.Exists(pageDir)) + Directory.Delete(pageDir, true); + + Directory.CreateDirectory(pageDir); + File.Copy(projectXml, Path.Combine(pageDir, "uxagentproject.xml"), true); + + ProjectFile(pageDir, page, "Compiled", compiledJs, "page.compiled", pageName, ""); + ProjectFile(pageDir, page, "Source", entrySource, "page.tsx", pageName, ""); + ProjectFile(pageDir, page, "Config", config, "config.json", pageName, "{\"dataSources\":[],\"model\":\"\"}"); + + Log.LogMessage(MessageImportance.High, $"Projected GenPage '{pageName}' native tree to {pageDir}"); + } + + private void ProjectFile(string pageDir, ITaskItem page, string prefix, string payloadPath, string payloadBaseName, string pageName, string defaultPayload) + { + var fileGuid = page.GetMetadata(prefix + "FileGuid"); + var fileXml = page.GetMetadata(prefix + "FileXmlPath"); + RequireFile(fileXml, $"{prefix} GenPage file XML for {pageName}"); + if (string.IsNullOrWhiteSpace(defaultPayload)) + RequireFile(payloadPath, $"{prefix} GenPage payload for {pageName}"); + else if (!string.IsNullOrWhiteSpace(payloadPath)) + RequireFile(payloadPath, $"{prefix} GenPage payload for {pageName}"); + if (Log.HasLoggedErrors) return; + + var fileDir = Path.Combine(pageDir, "uxagentprojectfiles", fileGuid); + var fileContent = Path.Combine(fileDir, "filecontent"); + Directory.CreateDirectory(fileContent); + File.Copy(fileXml, Path.Combine(fileDir, "uxagentprojectfile.xml"), true); + + var destination = Path.Combine(fileContent, payloadBaseName); + if (!string.IsNullOrWhiteSpace(payloadPath)) + File.Copy(payloadPath, destination, true); + else + File.WriteAllText(destination, defaultPayload); + } + + private void RequireFile(string path, string description) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + Log.LogError($"Missing {description}: {path}"); + } +} diff --git a/src/Dataverse/Tasks/Tasks/GenPageValidateBundle.cs b/src/Dataverse/Tasks/Tasks/GenPageValidateBundle.cs new file mode 100644 index 0000000..4c83eac --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/GenPageValidateBundle.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +public sealed class GenPageValidateBundle : Task +{ + private static readonly Regex DefaultExportRegex = new(@"\bexport\s+default\b", RegexOptions.Compiled); + private static readonly Regex NamedDefaultExportRegex = new(@"\bexport\s*\{\s*[^}]*\bas\s+default\b[^}]*\}", RegexOptions.Compiled); + private static readonly Regex FromImportRegex = new(@"\b(?:import|export)\s+(?:[^'"";]+?\s+from\s+)?['""](?[^'""]+)['""]", RegexOptions.Compiled); + private static readonly Regex DynamicImportRegex = new(@"\bimport\s*\(\s*['""](?[^'""]+)['""]\s*\)", RegexOptions.Compiled); + + [Required] + public ITaskItem[] Bundles { get; set; } = Array.Empty(); + + public string AllowedBareImports { get; set; } = "react;react-dom;react-dom/client;react/jsx-runtime;@fluentui/react-components;@fluentui/react-icons"; + + public override bool Execute() + { + var allowed = AllowedBareImports + .Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var item in Bundles) + { + var path = item.GetMetadata("CompiledJsPath"); + if (string.IsNullOrWhiteSpace(path)) + path = item.ItemSpec; + + var pageName = item.GetMetadata("PageName"); + if (!File.Exists(path)) + { + Log.LogError($"GenPage bundle not found for '{pageName}': {path}"); + continue; + } + + var content = StripComments(File.ReadAllText(path)); + if (!HasDefaultExport(content)) + Log.LogError($"GenPage bundle '{path}' must contain an ESM default export."); + + foreach (var module in FindModules(content)) + { + if (IsBareImport(module) && !IsAllowed(module, allowed)) + Log.LogError($"GenPage bundle '{path}' contains unsupported bare import '{module}'. Bundle dependencies or add a supported external."); + } + } + + return !Log.HasLoggedErrors; + } + + private static bool HasDefaultExport(string content) + { + return DefaultExportRegex.IsMatch(content) || NamedDefaultExportRegex.IsMatch(content); + } + + private static IEnumerable FindModules(string content) + { + foreach (Match match in FromImportRegex.Matches(content)) + yield return match.Groups["module"].Value; + foreach (Match match in DynamicImportRegex.Matches(content)) + yield return match.Groups["module"].Value; + } + + private static bool IsBareImport(string module) + { + return !module.StartsWith(".", StringComparison.Ordinal) + && !module.StartsWith("/", StringComparison.Ordinal) + && !module.Contains("://", StringComparison.Ordinal); + } + + private static bool IsAllowed(string module, HashSet allowed) + { + return allowed.Contains(module) || allowed.Any(a => module.StartsWith(a + "/", StringComparison.OrdinalIgnoreCase)); + } + + private static string StripComments(string content) + { + var result = new StringBuilder(content.Length); + var inSingleQuote = false; + var inDoubleQuote = false; + var inTemplate = false; + var inLineComment = false; + var inBlockComment = false; + var escaped = false; + + for (var i = 0; i < content.Length; i++) + { + var c = content[i]; + var next = i + 1 < content.Length ? content[i + 1] : '\0'; + + if (inLineComment) + { + if (c == '\r' || c == '\n') + { + inLineComment = false; + result.Append(c); + } + continue; + } + + if (inBlockComment) + { + if (c == '*' && next == '/') + { + inBlockComment = false; + i++; + } + else if (c == '\r' || c == '\n') + { + result.Append(c); + } + continue; + } + + if (!inSingleQuote && !inDoubleQuote && !inTemplate && c == '/' && next == '/') + { + inLineComment = true; + i++; + continue; + } + + if (!inSingleQuote && !inDoubleQuote && !inTemplate && c == '/' && next == '*') + { + inBlockComment = true; + i++; + continue; + } + + result.Append(c); + + if (escaped) + { + escaped = false; + continue; + } + + if ((inSingleQuote || inDoubleQuote || inTemplate) && c == '\\') + { + escaped = true; + continue; + } + + if (!inDoubleQuote && !inTemplate && c == '\'') + inSingleQuote = !inSingleQuote; + else if (!inSingleQuote && !inTemplate && c == '"') + inDoubleQuote = !inDoubleQuote; + else if (!inSingleQuote && !inDoubleQuote && c == '`') + inTemplate = !inTemplate; + } + + return result.ToString(); + } +} diff --git a/src/Dataverse/Tasks/Tasks/GenerateGenPageConfigJson.cs b/src/Dataverse/Tasks/Tasks/GenerateGenPageConfigJson.cs deleted file mode 100644 index 89db340..0000000 --- a/src/Dataverse/Tasks/Tasks/GenerateGenPageConfigJson.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Text; -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -public class GenerateGenPageConfigJson : Task -{ - public string DataSources { get; set; } - - [Required] - public string OutputPath { get; set; } - - public override bool Execute() - { - try - { - var sources = new JArray(); - - if (!string.IsNullOrWhiteSpace(DataSources)) - { - var items = DataSources - .Split(',') - .Select(s => s.Trim()) - .Where(s => s.Length > 0); - - foreach (var item in items) - { - sources.Add(item); - } - } - - var config = new JObject - { - ["dataSources"] = sources, - ["model"] = "" - }; - - var directory = Path.GetDirectoryName(OutputPath); - if (!string.IsNullOrWhiteSpace(directory)) - { - Directory.CreateDirectory(directory); - } - - File.WriteAllText(OutputPath, config.ToString(Formatting.Indented), new UTF8Encoding(false)); - - Log.LogMessage(MessageImportance.High, $"Generated GenPage config.json: {OutputPath}"); - return true; - } - catch (Exception ex) - { - Log.LogErrorFromException(ex, true); - return false; - } - } -} diff --git a/src/Dataverse/Tasks/Tasks/GenerateGenPageFileXml.cs b/src/Dataverse/Tasks/Tasks/GenerateGenPageFileXml.cs deleted file mode 100644 index d6e3308..0000000 --- a/src/Dataverse/Tasks/Tasks/GenerateGenPageFileXml.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System; -using System.IO; -using System.Security.Cryptography; -using System.Text; -using System.Xml; -using System.Xml.Linq; -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; - -public class GenerateGenPageFileXml : Task -{ - [Required] - public string ProjectId { get; set; } - - [Required] - public string SourceFilePath { get; set; } - - [Required] - public string FileName { get; set; } - - [Required] - public string FileType { get; set; } - - [Required] - public string MimeType { get; set; } - - [Required] - public string OutputDir { get; set; } - - [Output] - public string GeneratedFileId { get; set; } - - public override bool Execute() - { - try - { - if (!File.Exists(SourceFilePath)) - { - Log.LogError($"Source file not found: {SourceFilePath}"); - return false; - } - - var normalizedId = ProjectId.Trim().Trim('{', '}').ToLowerInvariant(); - - // Generate deterministic GUID from ProjectId + FileName - var seed = normalizedId + "-" + FileName; - GeneratedFileId = DeterministicGuid(seed); - - var fileDir = Path.Combine(OutputDir, GeneratedFileId); - var fileContentDir = Path.Combine(fileDir, "filecontent"); - Directory.CreateDirectory(fileContentDir); - - // Copy source file preserving original filename - var sourceFileName = Path.GetFileName(SourceFilePath); - var destPath = Path.Combine(fileContentDir, sourceFileName); - File.Copy(SourceFilePath, destPath, true); - - // Generate uxagentprojectfile.xml - var doc = new XDocument( - new XElement("uxagentprojectfile", - new XAttribute("uxagentprojectfileid", GeneratedFileId), - new XElement("filecontent", - new XAttribute("mimetype", MimeType), - sourceFileName), - new XElement("filename", FileName), - new XElement("filetype", FileType), - new XElement("iscustomizable", "1"), - new XElement("statecode", "0"), - new XElement("statuscode", "1") - ) - ); - - var xmlPath = Path.Combine(fileDir, "uxagentprojectfile.xml"); - - var settings = new XmlWriterSettings - { - Encoding = new UTF8Encoding(false), - Indent = true, - OmitXmlDeclaration = true, - NewLineChars = Environment.NewLine, - NewLineHandling = NewLineHandling.Replace - }; - - using (var writer = XmlWriter.Create(xmlPath, settings)) - { - doc.Save(writer); - } - - Log.LogMessage(MessageImportance.High, $"Generated GenPage file XML: {xmlPath} (FileId={GeneratedFileId})"); - return true; - } - catch (Exception ex) - { - Log.LogErrorFromException(ex, true); - return false; - } - } - - private static string DeterministicGuid(string seed) - { - using (var md5 = MD5.Create()) - { - var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(seed)); - var hex = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); - return hex.Substring(0, 8) + "-" - + hex.Substring(8, 4) + "-" - + hex.Substring(12, 4) + "-" - + hex.Substring(16, 4) + "-" - + hex.Substring(20, 12); - } - } -} diff --git a/src/Dataverse/Tasks/Tasks/GenerateGenPageProjectXml.cs b/src/Dataverse/Tasks/Tasks/GenerateGenPageProjectXml.cs deleted file mode 100644 index c935470..0000000 --- a/src/Dataverse/Tasks/Tasks/GenerateGenPageProjectXml.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.IO; -using System.Text; -using System.Xml; -using System.Xml.Linq; -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; - -public class GenerateGenPageProjectXml : Task -{ - [Required] - public string ProjectId { get; set; } - - [Required] - public string PageName { get; set; } - - [Required] - public string OutputDir { get; set; } - - public override bool Execute() - { - try - { - Directory.CreateDirectory(OutputDir); - - var normalizedId = ProjectId.Trim().Trim('{', '}').ToLowerInvariant(); - - var doc = new XDocument( - new XElement("uxagentproject", - new XAttribute("uxagentprojectid", normalizedId), - new XElement("iscustomizable", "1"), - new XElement("name", PageName), - new XElement("statecode", "0"), - new XElement("statuscode", "1") - ) - ); - - var outputPath = Path.Combine(OutputDir, "uxagentproject.xml"); - - var settings = new XmlWriterSettings - { - Encoding = new UTF8Encoding(false), - Indent = true, - OmitXmlDeclaration = true, - NewLineChars = Environment.NewLine, - NewLineHandling = NewLineHandling.Replace - }; - - using (var writer = XmlWriter.Create(outputPath, settings)) - { - doc.Save(writer); - } - - Log.LogMessage(MessageImportance.High, $"Generated GenPage project XML: {outputPath}"); - return true; - } - catch (Exception ex) - { - Log.LogErrorFromException(ex, true); - return false; - } - } -} diff --git a/src/Dataverse/Tasks/Tasks/PatchGenPageCompiledCode.cs b/src/Dataverse/Tasks/Tasks/PatchGenPageCompiledCode.cs deleted file mode 100644 index c535017..0000000 --- a/src/Dataverse/Tasks/Tasks/PatchGenPageCompiledCode.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; - -public class PatchGenPageCompiledCode : Task -{ - [Required] - public string CompiledJsPath { get; set; } - - public string RuntimeTypesJsPath { get; set; } - - [Required] - public string OutputPath { get; set; } - - public override bool Execute() - { - try - { - if (!File.Exists(CompiledJsPath)) - { - Log.LogError($"Compiled JS file not found: {CompiledJsPath}"); - return false; - } - - var js = File.ReadAllText(CompiledJsPath, Encoding.UTF8); - - // Strip RuntimeTypes import lines (single or double quotes) - js = Regex.Replace(js, @"import\s+.*?from\s+['""]\.\/RuntimeTypes['""];?\s*", ""); - - var result = js; - - if (!string.IsNullOrEmpty(RuntimeTypesJsPath) && File.Exists(RuntimeTypesJsPath)) - { - var rt = File.ReadAllText(RuntimeTypesJsPath, Encoding.UTF8); - result = "// --- BEGIN GENERATED RUNTIME TYPES ---\n\n" - + rt - + "\n// --- END GENERATED RUNTIME TYPES ---\n\n" - + js; - } - - var directory = Path.GetDirectoryName(OutputPath); - if (!string.IsNullOrWhiteSpace(directory)) - { - Directory.CreateDirectory(directory); - } - - File.WriteAllText(OutputPath, result, new UTF8Encoding(false)); - - Log.LogMessage(MessageImportance.High, $"Patched GenPage compiled code: {OutputPath}"); - return true; - } - catch (Exception ex) - { - Log.LogErrorFromException(ex, true); - return false; - } - } -} diff --git a/src/Dataverse/Tasks/Tasks/RunNodeTool.cs b/src/Dataverse/Tasks/Tasks/RunNodeTool.cs new file mode 100644 index 0000000..590851b --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/RunNodeTool.cs @@ -0,0 +1,413 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +public sealed class RunNodeTool : Task +{ + [Required] + public string FileName { get; set; } = ""; + + public string Arguments { get; set; } = ""; + + public string WorkingDirectory { get; set; } = ""; + + public int TimeoutSeconds { get; set; } = 600; + + public bool IgnoreExitCode { get; set; } + + public string StandardOutputImportance { get; set; } = "Normal"; + + public string StandardErrorImportance { get; set; } = "High"; + + /// + /// Additional environment variables to set for the process, in KEY=VALUE format. + /// + public string[] EnvironmentVariables { get; set; } = Array.Empty(); + + [Output] + public int ExitCode { get; set; } + + [Output] + public bool TimedOut { get; set; } + + public override bool Execute() + { + var result = NodeProcessRunner.Run( + Log, + FileName, + Arguments, + WorkingDirectory, + TimeoutSeconds, + IgnoreExitCode, + NodeProcessRunner.ParseImportance(StandardOutputImportance, MessageImportance.Normal), + NodeProcessRunner.ParseImportance(StandardErrorImportance, MessageImportance.High), + EnvironmentVariables); + + ExitCode = result.ExitCode; + TimedOut = result.TimedOut; + + return result.Succeeded || IgnoreExitCode; + } +} + +internal sealed class NodeProcessResult +{ + public int ExitCode { get; set; } = -1; + + public bool TimedOut { get; set; } + + public bool Succeeded { get; set; } +} + +internal static class NodeProcessRunner +{ + public static NodeProcessResult Run( + TaskLoggingHelper log, + string fileName, + string arguments, + string workingDirectory, + int timeoutSeconds, + bool ignoreExitCode, + MessageImportance standardOutputImportance, + MessageImportance standardErrorImportance, + string[] environmentVariables = null) + { + var result = new NodeProcessResult(); + Process process = null; + + try + { + if (string.IsNullOrWhiteSpace(fileName)) + { + LogFailure(log, ignoreExitCode, "Node tool FileName is required."); + return result; + } + + var effectiveWorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) + ? Directory.GetCurrentDirectory() + : Path.GetFullPath(workingDirectory); + + if (!Directory.Exists(effectiveWorkingDirectory)) + { + LogFailure(log, ignoreExitCode, $"Working directory does not exist: {effectiveWorkingDirectory}"); + return result; + } + + var resolvedFileName = ResolveExecutable(fileName, effectiveWorkingDirectory); + var timeoutMilliseconds = timeoutSeconds <= 0 + ? 600000 + : checked(timeoutSeconds * 1000); + + log.LogMessage(MessageImportance.High, $"Running {resolvedFileName} {arguments}".Trim()); + + process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = resolvedFileName, + Arguments = arguments ?? string.Empty, + WorkingDirectory = effectiveWorkingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }, + EnableRaisingEvents = false + }; + + foreach (var envVar in environmentVariables ?? Array.Empty()) + { + var eqIdx = envVar.IndexOf('='); + if (eqIdx <= 0) + continue; + + var key = envVar.Substring(0, eqIdx); + var value = envVar.Substring(eqIdx + 1); + process.StartInfo.EnvironmentVariables[key] = value; + } + + process.OutputDataReceived += (_, e) => + { + if (!string.IsNullOrEmpty(e.Data)) + log.LogMessage(standardOutputImportance, e.Data); + }; + process.ErrorDataReceived += (_, e) => + { + if (!string.IsNullOrEmpty(e.Data)) + log.LogMessage(standardErrorImportance, e.Data); + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (!process.WaitForExit(timeoutMilliseconds)) + { + result.TimedOut = true; + KillProcessTree(process, log); + process.WaitForExit(10000); + LogFailure(log, ignoreExitCode, $"Command timed out after {timeoutSeconds} seconds: {resolvedFileName} {arguments}".Trim()); + return result; + } + + process.WaitForExit(); + result.ExitCode = process.ExitCode; + result.Succeeded = process.ExitCode == 0; + + if (!result.Succeeded) + LogFailure(log, ignoreExitCode, $"Command exited with code {process.ExitCode}: {resolvedFileName} {arguments}".Trim()); + + return result; + } + catch (Exception ex) + { + if (process != null) + KillProcessTree(process, log); + + LogFailure(log, ignoreExitCode, $"Could not run command '{fileName} {arguments}'. {ex.Message}".Trim()); + return result; + } + finally + { + if (process != null) + process.Dispose(); + } + } + + public static MessageImportance ParseImportance(string value, MessageImportance defaultValue) + { + MessageImportance parsed; + return Enum.TryParse(value, true, out parsed) ? parsed : defaultValue; + } + + public static bool TrySplitCommandLine(string command, out string fileName, out string arguments, out string error) + { + fileName = string.Empty; + arguments = string.Empty; + error = string.Empty; + + if (string.IsNullOrWhiteSpace(command)) + { + error = "Command is empty."; + return false; + } + + var trimmed = command.Trim(); + if (trimmed[0] == '"') + { + var closingQuote = trimmed.IndexOf('"', 1); + if (closingQuote < 0) + { + error = "Quoted command executable is missing a closing quote."; + return false; + } + + fileName = trimmed.Substring(1, closingQuote - 1); + arguments = trimmed.Substring(closingQuote + 1).TrimStart(); + return !string.IsNullOrWhiteSpace(fileName); + } + + var firstWhitespace = -1; + for (var i = 0; i < trimmed.Length; i++) + { + if (char.IsWhiteSpace(trimmed[i])) + { + firstWhitespace = i; + break; + } + } + + if (firstWhitespace < 0) + { + fileName = trimmed; + return true; + } + + fileName = trimmed.Substring(0, firstWhitespace); + arguments = trimmed.Substring(firstWhitespace + 1).TrimStart(); + return !string.IsNullOrWhiteSpace(fileName); + } + + private static void LogFailure(TaskLoggingHelper log, bool ignoreExitCode, string message) + { + if (ignoreExitCode) + log.LogMessage(MessageImportance.High, message); + else + log.LogError(message); + } + + private static string ResolveExecutable(string fileName, string workingDirectory) + { + var hasDirectory = fileName.IndexOf(Path.DirectorySeparatorChar) >= 0 || + fileName.IndexOf(Path.AltDirectorySeparatorChar) >= 0 || + Path.IsPathRooted(fileName); + + if (hasDirectory) + { + if (File.Exists(fileName)) + return Path.GetFullPath(fileName); + + var rootedCandidate = Path.Combine(workingDirectory, fileName); + if (File.Exists(rootedCandidate)) + return Path.GetFullPath(rootedCandidate); + + if (IsWindows() && string.IsNullOrEmpty(Path.GetExtension(fileName))) + { + foreach (var extension in GetWindowsExecutableExtensions()) + { + if (File.Exists(fileName + extension)) + return Path.GetFullPath(fileName + extension); + + var workingDirectoryCandidate = Path.Combine(workingDirectory, fileName + extension); + if (File.Exists(workingDirectoryCandidate)) + return Path.GetFullPath(workingDirectoryCandidate); + } + } + + return fileName; + } + + foreach (var directory in GetPathDirectories()) + { + if (IsWindows()) + { + var extensions = string.IsNullOrEmpty(Path.GetExtension(fileName)) + ? GetWindowsExecutableExtensions() + : new[] { string.Empty }; + + foreach (var extension in extensions) + { + var candidate = Path.Combine(directory, fileName + extension); + if (File.Exists(candidate)) + return candidate; + } + } + else + { + var candidate = Path.Combine(directory, fileName); + if (File.Exists(candidate)) + return candidate; + } + } + + return fileName; + } + + private static string[] GetPathDirectories() + { + var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + return path.Split(new[] { Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries); + } + + private static string[] GetWindowsExecutableExtensions() + { + var pathExt = Environment.GetEnvironmentVariable("PATHEXT"); + if (string.IsNullOrWhiteSpace(pathExt)) + pathExt = ".COM;.EXE;.BAT;.CMD"; + + return pathExt.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries); + } + + private static bool IsWindows() + { + return Path.DirectorySeparatorChar == '\\'; + } + + private static void KillProcessTree(Process process, TaskLoggingHelper log) + { + try + { + if (process.HasExited) + return; + +#if NET6_0_OR_GREATER + process.Kill(entireProcessTree: true); +#else + if (IsWindows()) + KillWindowsProcessTree(process.Id, log); + else + KillUnixProcessTree(process, log); +#endif + } + catch (Exception ex) + { + log.LogWarning($"Failed to kill timed-out command process tree for PID {SafeGetProcessId(process)}: {ex.Message}"); + } + } + +#if !NET6_0_OR_GREATER + private static void KillWindowsProcessTree(int processId, TaskLoggingHelper log) + { + using (var taskKill = new Process()) + { + taskKill.StartInfo = new ProcessStartInfo + { + FileName = "taskkill", + Arguments = $"/PID {processId} /T /F", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + taskKill.Start(); + if (!taskKill.WaitForExit(10000)) + { + taskKill.Kill(); + log.LogWarning($"taskkill timed out while killing PID {processId}."); + } + } + } + + private static void KillUnixProcessTree(Process process, TaskLoggingHelper log) + { + TryRunKillCommand($"-TERM -{process.Id}", log); + Thread.Sleep(1000); + + if (!process.HasExited) + process.Kill(); + } + + private static void TryRunKillCommand(string arguments, TaskLoggingHelper log) + { + try + { + using (var kill = new Process()) + { + kill.StartInfo = new ProcessStartInfo + { + FileName = "kill", + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + kill.Start(); + kill.WaitForExit(5000); + } + } + catch (Exception ex) + { + log.LogMessage(MessageImportance.Low, $"Could not signal Unix process group with kill {arguments}: {ex.Message}"); + } + } +#endif + + private static int SafeGetProcessId(Process process) + { + try + { + return process.Id; + } + catch + { + return -1; + } + } +} diff --git a/src/Dataverse/Tasks/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Tasks.targets b/src/Dataverse/Tasks/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Tasks.targets index 05d3dad..28b7b64 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Tasks.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Tasks.targets @@ -40,8 +40,10 @@ - - - - + + + + + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild.targets new file mode 100644 index 0000000..e638985 --- /dev/null +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild.targets @@ -0,0 +1,93 @@ + + + + + + + $(MSBuildProjectDirectory) + $(MSBuildProjectName) + + true + false + + + production + development + + build + + + $(IntermediateOutputPath)node/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +