From 678effdd8b9ce08b84f6e199122cd9e48f5b247b Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Fri, 31 Jul 2026 10:39:29 +0200 Subject: [PATCH 01/20] Fix: bare dotnet restore doesn't hydrate Node deps for Pcf/ScriptLibrary/CodeApp A standalone "dotnet restore" sets ExcludeRestorePackageImports=true, so NuGet never imports ordinary PackageReference/buildTransitive-delivered .targets files - including the Tasks package's NodeRestore.targets - during that operation. The per-project-type NodeRestore anchors (_PcfNodeRestore, _ScriptLibraryNodeRestore, _CodeAppNodeRestore) lived in exactly those excluded files, so a bare restore silently skipped Node/rush hydration entirely. Moves the anchor into Sdk.targets, which is imported through the MSBuild SDK-resolver mechanism and is therefore not subject to ExcludeRestorePackageImports. Scoped to Pcf/ScriptLibrary/CodeApp only, since every other project type also references this SDK for unrelated shared targets (e.g. GenerateVersionNumber) but must never attempt Node/rush restore. Two additional issues surfaced during real restore testing against a live PCF control, both fixed here: - $(NuGetPackageRoot) is not reliably populated in the nested MSBuild evaluation NuGet uses internally to build the restore graph, silently preventing the anchor from ever firing. Sdk.props now derives the NuGet package cache root from its own resolved file location instead. - On a brand new machine/cache, the Tasks package isn't downloaded yet when the anchor's nested evaluation runs, so Node deps were never hydrated by that restore at all. A second hook, AfterTargets="Restore", re-invokes the anchor in a freshly re-evaluated nested build once the outer restore has finished downloading packages. Validated end-to-end against FileExplorer (a real, complex PCF control) with disposable local test packages: a single cold-cache "dotnet restore" now hydrates node_modules via rush, a warm-cache restore is idempotent, a normal dotnet build has no duplicate-import warnings, and a Solution-type project restore correctly triggers zero Node/rush activity for itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...XIS.DevKit.Build.Dataverse.CodeApp.targets | 12 +--- .../TALXIS.DevKit.Build.Dataverse.Pcf.targets | 15 +---- ...vKit.Build.Dataverse.ScriptLibrary.targets | 16 ++--- ...ALXIS.DevKit.Build.Dataverse.Tasks.targets | 6 ++ src/Sdk/Sdk/Sdk.props | 6 ++ src/Sdk/Sdk/Sdk.targets | 67 +++++++++++++++++++ 6 files changed, 87 insertions(+), 35 deletions(-) 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 ea9c3a5..4c9b6a2 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 @@ -17,16 +17,8 @@ - - - - + - - - + + (the build step and _NodeRestoreResolve's own DependsOnTargets chain) all resolve against + the same directory without each needing to repeat this override. --> $(TypeScriptDir) - - - - + + + <_TALXISDevKitDataverseTasksImported>true + \ No newline at end of file diff --git a/src/Sdk/Sdk/Sdk.props b/src/Sdk/Sdk/Sdk.props index 82bb1a4..0b2d4d4 100644 --- a/src/Sdk/Sdk/Sdk.props +++ b/src/Sdk/Sdk/Sdk.props @@ -10,6 +10,12 @@ <_TALXISDevKitBuildSdkDir>$([System.IO.Path]::GetDirectoryName($(MSBuildThisFileDirectory.TrimEnd('\/')))) $([System.IO.Path]::GetFileName($(_TALXISDevKitBuildSdkDir))) + + + <_TALXISDevKitNuGetPackageRoot>$([System.IO.Path]::GetDirectoryName($([System.IO.Path]::GetDirectoryName($(_TALXISDevKitBuildSdkDir)))))/ + + <_TALXISDevKitDataverseTasksTargets>$(_TALXISDevKitNuGetPackageRoot)talxis.devkit.build.dataverse.tasks/$(TALXISDevKitDataversePackageVersion)/buildTransitive/TALXIS.DevKit.Build.Dataverse.Tasks.targets + <_TALXISDevKitDataverseTasksAvailable Condition="Exists('$(_TALXISDevKitDataverseTasksTargets)')">true + + + + + + + $(MSBuildProjectDirectory)/TS + $(TypeScriptDir) + + + + + + + + + + + + + From 5b1b1cc7e4fe1f206a230f406ea0bec9e5574327 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Fri, 31 Jul 2026 14:19:55 +0200 Subject: [PATCH 02/20] Split Sdk.targets by concern for readability Sdk.targets previously mixed four unrelated concerns in one file: Microsoft.NET.Sdk passthrough, per-project-type PackageReference wiring, Git-versioning defaults, and the NodeRestore restore-time anchor. Split into: - Sdk.targets: thin entry point, imports the three files below. - Sdk.PackageReference.targets: wires in the per-project-type Dataverse package based on $(ProjectType). - Sdk.GitVersioning.targets: Git-based version-number defaults + ResolveGitBranch. - Sdk.NodeRestore.targets: the restore-time Node dependency hydration anchor, with a top-of-file note clarifying it's restore-time only - build-time Node delegation lives in each project type's own .targets file and the Tasks package's NodeBuild targets. Pure reorganization, no logic changes. Validated: dotnet build 0 warnings/0 errors; cold-cache restore against a disposable FileExplorer test project hydrates node_modules via rush; warm restore is idempotent (~2s, no-op); full dotnet build succeeds with 0 warnings (no MSB4011 duplicate-import regression); Solution-type negative test confirms NodeRestore never fires for non-Node project types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Sdk/Sdk/Sdk.GitVersioning.targets | 36 +++++++ src/Sdk/Sdk/Sdk.NodeRestore.targets | 74 ++++++++++++++ src/Sdk/Sdk/Sdk.PackageReference.targets | 16 +++ src/Sdk/Sdk/Sdk.targets | 120 ++--------------------- 4 files changed, 133 insertions(+), 113 deletions(-) create mode 100644 src/Sdk/Sdk/Sdk.GitVersioning.targets create mode 100644 src/Sdk/Sdk/Sdk.NodeRestore.targets create mode 100644 src/Sdk/Sdk/Sdk.PackageReference.targets diff --git a/src/Sdk/Sdk/Sdk.GitVersioning.targets b/src/Sdk/Sdk/Sdk.GitVersioning.targets new file mode 100644 index 0000000..ee314c3 --- /dev/null +++ b/src/Sdk/Sdk/Sdk.GitVersioning.targets @@ -0,0 +1,36 @@ + + + + main;master;hotfix/*;release/*; + develop:1; + + + + + + + + diff --git a/src/Sdk/Sdk/Sdk.NodeRestore.targets b/src/Sdk/Sdk/Sdk.NodeRestore.targets new file mode 100644 index 0000000..986f2f6 --- /dev/null +++ b/src/Sdk/Sdk/Sdk.NodeRestore.targets @@ -0,0 +1,74 @@ + + + + <_TALXISDevKitDataverseTasksTargets>$(_TALXISDevKitNuGetPackageRoot)talxis.devkit.build.dataverse.tasks/$(TALXISDevKitDataversePackageVersion)/buildTransitive/TALXIS.DevKit.Build.Dataverse.Tasks.targets + <_TALXISDevKitDataverseTasksAvailable Condition="Exists('$(_TALXISDevKitDataverseTasksTargets)')">true + + + + + + + $(MSBuildProjectDirectory)/TS + $(TypeScriptDir) + + + + + + + + + + + + + diff --git a/src/Sdk/Sdk/Sdk.PackageReference.targets b/src/Sdk/Sdk/Sdk.PackageReference.targets new file mode 100644 index 0000000..1b22882 --- /dev/null +++ b/src/Sdk/Sdk/Sdk.PackageReference.targets @@ -0,0 +1,16 @@ + + + + $(TALXISDevKitDataversePackageBase).$(ProjectType) + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/src/Sdk/Sdk/Sdk.targets b/src/Sdk/Sdk/Sdk.targets index d842b72..42ee20e 100644 --- a/src/Sdk/Sdk/Sdk.targets +++ b/src/Sdk/Sdk/Sdk.targets @@ -1,117 +1,11 @@ - - $(TALXISDevKitDataversePackageBase).$(ProjectType) - - - - - main;master;hotfix/*;release/*; - develop:1; - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - <_TALXISDevKitDataverseTasksTargets>$(_TALXISDevKitNuGetPackageRoot)talxis.devkit.build.dataverse.tasks/$(TALXISDevKitDataversePackageVersion)/buildTransitive/TALXIS.DevKit.Build.Dataverse.Tasks.targets - <_TALXISDevKitDataverseTasksAvailable Condition="Exists('$(_TALXISDevKitDataverseTasksTargets)')">true - - - - - - - $(MSBuildProjectDirectory)/TS - $(TypeScriptDir) - - - - - - - - - - - - - - - - - - + + + + From a96138bccf76245803ca662e18c3164c86e8bc02 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Fri, 31 Jul 2026 14:21:38 +0200 Subject: [PATCH 03/20] Update breadcrumb comments to reference Sdk.NodeRestore.targets Follow-up to the Sdk.targets split - the one-line comments in Pcf/ScriptLibrary/CodeApp's own .targets files pointed at Sdk.targets, which no longer directly contains _NodeRestoreAnchor after the split. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../msbuild/tasks/TALXIS.DevKit.Build.Dataverse.CodeApp.targets | 2 +- .../Pcf/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Pcf.targets | 2 +- .../tasks/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.targets | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 4c9b6a2..32d8e3c 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 @@ -17,7 +17,7 @@ - net472 TALXIS.DevKit.Build.Dataverse - + <_TALXISDevKitBuildSdkDir>$([System.IO.Path]::GetDirectoryName($(MSBuildThisFileDirectory.TrimEnd('\/')))) $([System.IO.Path]::GetFileName($(_TALXISDevKitBuildSdkDir))) From 987002ade639f602448008993952d572bb1d15a0 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 3 Aug 2026 12:25:36 +0200 Subject: [PATCH 05/20] feat: rename TypeScriptDir to NodeRootPath, eliminate NodeRestoreProjectDirectory, scope Rush restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NodeRootPath defined in shared ProjectPaths.props (applies to all Node-based project types: Pcf, ScriptLibrary, CodeApp) - Follows SolutionRootPath pattern: relative path (default .), resolved to NodeRootFullPath via GetFullPath - NodeRestoreProjectDirectory removed — NodeRootFullPath used directly throughout the NodeRestore system (no redundant indirection) - Backward compat: TypeScriptDir still accepted if NodeRootPath is not set - Rush restore scoped conditionally: --to . for project-level restores, unscoped for solution-level restores (detected via SolutionPath) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/BuildProcess.md | 2 +- docs/NodeDependencies.md | 4 +-- ...XIS.DevKit.Build.Dataverse.CodeApp.targets | 22 +++++++------- .../TALXIS.DevKit.Build.Dataverse.Pcf.targets | 2 +- src/Dataverse/ScriptLibrary/README.md | 12 ++++---- ...vKit.Build.Dataverse.ScriptLibrary.targets | 29 +++++++------------ .../msbuild/tasks/Props/ProjectPaths.props | 7 +++++ .../msbuild/tasks/Targets/NodeRestore.targets | 6 ++-- .../Targets/NodeRestore/CustomCommand.targets | 2 +- .../Targets/NodeRestore/Detection.targets | 18 +++++------- .../tasks/Targets/NodeRestore/Generic.targets | 2 +- .../tasks/Targets/NodeRestore/Rush.targets | 16 ++++++---- src/Sdk/Sdk/Sdk.NodeRestore.targets | 8 ----- 13 files changed, 61 insertions(+), 69 deletions(-) diff --git a/docs/BuildProcess.md b/docs/BuildProcess.md index ee2765a..1f01c52 100644 --- a/docs/BuildProcess.md +++ b/docs/BuildProcess.md @@ -234,7 +234,7 @@ Main hooks: - `GetScriptLibraryOutputs` - `GetSuppressedScriptLibraryReferences` -The package expects TypeScript sources under `$(TypeScriptDir)` (default `$(MSBuildProjectDirectory)` itself), hydrates dependencies via the shared `NodeRestore` target (see [NodeDependencies.md](NodeDependencies.md)), builds via Rush delegation or `npm run build` (see [NodeDependencies.md](NodeDependencies.md#build-delegation-to-rush)), copies the selected main JS file to `$(TargetDir)`, and lets Solution builds query which referenced script libraries are `CompileOnly` and therefore should not be deployed as separate web resources. Standalone `npm` packaging of a ScriptLibrary is planned but not yet implemented, so it does not currently set `IsPackable=false`. +The package expects sources under `$(NodeRootFullPath)` (default: project directory itself), hydrates dependencies via the shared `NodeRestore` target (see [NodeDependencies.md](NodeDependencies.md)), builds via Rush delegation or `npm run build` (see [NodeDependencies.md](NodeDependencies.md#build-delegation-to-rush)), copies the selected main JS file to `$(TargetDir)`, and lets Solution builds query which referenced script libraries are `CompileOnly` and therefore should not be deployed as separate web resources. Standalone `npm` packaging of a ScriptLibrary is planned but not yet implemented, so it does not currently set `IsPackable=false`. ### CodeApp diff --git a/docs/NodeDependencies.md b/docs/NodeDependencies.md index fbea5d0..fa841e1 100644 --- a/docs/NodeDependencies.md +++ b/docs/NodeDependencies.md @@ -161,7 +161,7 @@ without the archived-cache performance benefit. ## How detection works Purely via MSBuild's built-in `GetDirectoryNameOfFileAbove`, walking up from the project directory (or -`$(TypeScriptDir)` for ScriptLibrary) looking for the first marker in this order: +`$(NodeRootFullPath)` for ScriptLibrary) looking for the first marker in this order: | Precedence | Marker | Resolved tool | |---|---|---| @@ -182,7 +182,7 @@ a library to track. |----------|---------|-------------| | `NodePackageManager` | _(auto)_ | Leave empty to auto-detect via the table above. Set explicitly to `npm`, `pnpm`, `yarn`, `bun`, or `rush` to skip detection and force a tool (the workspace root is still resolved the same way). Set to `None` to skip Node restore entirely - use this when dependencies are hydrated by something external to the build (a separate CI step, a different orchestrator, etc.). | | `NodeRestoreCommand` | _(empty)_ | Escape hatch: if set, this exact command line is run instead of anything auto-detected or resolved from `NodePackageManager` - for any tool this SDK doesn't know about, or any custom install invocation. Runs every build (no incremental caching, since an arbitrary command's staleness can't be inferred). | -| `NodeRestoreProjectDirectory` | `$(MSBuildProjectDirectory)` | Directory containing `package.json` that detection starts walking up from. ScriptLibrary sets this to `$(TypeScriptDir)` before calling `NodeRestore`; Pcf/CodeApp use the default. | +| `NodeRootPath` | `.` | Relative path to the Node project root (where `package.json` lives), resolved against the project directory. All Node-based project types (Pcf, ScriptLibrary, CodeApp) use this for detection and build operations. | | `IsRunningInCI` | _(auto)_ | Reused as-is from [Versioning.md](Versioning.md) - leave empty to auto-detect CI from environment variables, or set `true`/`false` to override. Selects the frozen/reproducible install variant below. | ## Frozen (CI-safe) installs 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 32d8e3c..80771b3 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 @@ -2,13 +2,13 @@ - true + true false - + - <_NodeBuildProjectDirectory>$(MSBuildProjectDirectory) + <_NodeBuildProjectDirectory>$(NodeRootFullPath) <_NodeBuildModeArgName Condition="'$(_NodeBuildModeArgName)'==''">mode <_NodeBuildExtraArgs> <_NodeBuildRequiredRushParams> @@ -36,7 +36,7 @@ DependsOnTargets="CheckCodeAppPrereqs;_NodeRestoreResolve;_CodeAppSetNodeBuildArgs;_NodeRestoreDelegateBuildToRush;_NodeRestoreBuildDirect" BeforeTargets="Build" Condition="'$(RunNodeBuild)'=='true'"> - + - + - + - + @@ -89,9 +89,9 @@ DependsOnTargets="Build" Returns="@(_CodeAppOutputs)"> - <_CodeAppOutputs Include="$(MSBuildProjectDirectory)/dist"> + <_CodeAppOutputs Include="$(NodeRootFullPath)/dist"> $(AppName) - $(MSBuildProjectDirectory)/power.config.json + $(NodeRootFullPath)/power.config.json @@ -102,10 +102,10 @@ - + - + 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 4e54276..7891c74 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 @@ -62,7 +62,7 @@ the called target, only ones reached through the DependsOnTargets chain are. --> - <_NodeBuildProjectDirectory>$(MSBuildProjectDirectory) + <_NodeBuildProjectDirectory>$(NodeRootFullPath) - $(TypeScriptDir) - <_NodeBuildProjectDirectory>$(TypeScriptDir) + <_NodeBuildProjectDirectory>$(NodeRootFullPath) <_NodeBuildExtraArgs> <_NodeBuildRequiredRushParams> @@ -33,7 +24,7 @@ DependsOnTargets="CheckScriptLibraryPrereqs;_NodeRestoreResolve;_ScriptLibrarySetNodeBuildArgs;_NodeRestoreDelegateBuildToRush;_NodeRestoreBuildDirect" BeforeTargets="Build" Condition="'$(RunNodeBuild)'=='true'"> - + - + - - + + $([System.IO.Path]::GetFullPath($(MSBuildProjectDirectory)/$(SolutionRootPath))) + + + $(TypeScriptDir) + . + $([System.IO.Path]::GetFullPath($(MSBuildProjectDirectory)/$(NodeRootPath))) \ No newline at end of file diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets index e2930e6..5902b17 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets @@ -8,11 +8,11 @@ Consumers call this via from their own anchor target (mirrors how GenerateVersionNumber/ApplyPcfVersionNumber etc. are invoked elsewhere in this - repo), after setting NodeRestoreProjectDirectory if the Node project lives somewhere other - than $(MSBuildProjectDirectory) (e.g. ScriptLibrary's $(TypeScriptDir)). + repo). The Node project directory is resolved from $(NodeRootFullPath) (defined in + ProjectPaths.props, relative path set via NodeRootPath, default: project directory). Properties: - NodeRestoreProjectDirectory Directory containing package.json. Default: $(MSBuildProjectDirectory). + NodeRootPath Relative path to Node project root. Default: "." (project dir). NodePackageManager '' (auto, default) | None | npm | pnpm | yarn | bun | rush. None = dependencies hydrated externally; do nothing. NodeRestoreCommand Raw command override. Wins over everything else below. diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/CustomCommand.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/CustomCommand.targets index b68cd62..7585364 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/CustomCommand.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/CustomCommand.targets @@ -6,7 +6,7 @@ DependsOnTargets="_NodeRestoreResolve" Condition="'$(NodePackageManager)' != 'None' and '$(NodeRestoreCommand)' != ''"> diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Detection.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Detection.targets index 88f270f..723b77f 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Detection.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Detection.targets @@ -26,21 +26,17 @@ - - $(MSBuildProjectDirectory) - - - <_NodeRestoreRushRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRestoreProjectDirectory)', 'rush.json')) - <_NodeRestorePnpmRoot Condition="'$(_NodeRestoreRushRoot)'==''">$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRestoreProjectDirectory)', 'pnpm-lock.yaml')) - <_NodeRestoreYarnRoot Condition="'$(_NodeRestoreRushRoot)'=='' and '$(_NodeRestorePnpmRoot)'==''">$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRestoreProjectDirectory)', 'yarn.lock')) - <_NodeRestoreBunRoot Condition="'$(_NodeRestoreRushRoot)'=='' and '$(_NodeRestorePnpmRoot)'=='' and '$(_NodeRestoreYarnRoot)'==''">$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRestoreProjectDirectory)', 'bun.lockb')) - <_NodeRestoreNpmRoot Condition="'$(_NodeRestoreRushRoot)'=='' and '$(_NodeRestorePnpmRoot)'=='' and '$(_NodeRestoreYarnRoot)'=='' and '$(_NodeRestoreBunRoot)'==''">$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRestoreProjectDirectory)', 'package-lock.json')) + <_NodeRestoreRushRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'rush.json')) + <_NodeRestorePnpmRoot Condition="'$(_NodeRestoreRushRoot)'==''">$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'pnpm-lock.yaml')) + <_NodeRestoreYarnRoot Condition="'$(_NodeRestoreRushRoot)'=='' and '$(_NodeRestorePnpmRoot)'==''">$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'yarn.lock')) + <_NodeRestoreBunRoot Condition="'$(_NodeRestoreRushRoot)'=='' and '$(_NodeRestorePnpmRoot)'=='' and '$(_NodeRestoreYarnRoot)'==''">$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'bun.lockb')) + <_NodeRestoreNpmRoot Condition="'$(_NodeRestoreRushRoot)'=='' and '$(_NodeRestorePnpmRoot)'=='' and '$(_NodeRestoreYarnRoot)'=='' and '$(_NodeRestoreBunRoot)'==''">$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'package-lock.json')) - <_NodeRestorePackageJsonPath>$(NodeRestoreProjectDirectory)/package.json + <_NodeRestorePackageJsonPath>$(NodeRootFullPath)/package.json <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreResolvedTool)'=='pnpm' and '$(_NodeRestoreHasLockfile)'=='true'">$(_NodeRestoreWorkspaceRoot)/pnpm-lock.yaml <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreLockfilePath)'=='' and '$(_NodeRestoreResolvedTool)'=='yarn' and '$(_NodeRestoreHasLockfile)'=='true'">$(_NodeRestoreWorkspaceRoot)/yarn.lock <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreLockfilePath)'=='' and '$(_NodeRestoreResolvedTool)'=='bun' and '$(_NodeRestoreHasLockfile)'=='true'">$(_NodeRestoreWorkspaceRoot)/bun.lockb diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets index 6e1f83a..78ffebe 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets @@ -20,7 +20,7 @@ delegation branch downstream share the same answer. --> <_NodeRestoreRushWorkspaceRootNormalized>$([System.String]::Copy('$(_NodeRestoreWorkspaceRoot)').Replace('\', '/').TrimEnd('/')) - <_NodeRestoreRushProjectDirectoryNormalized>$([System.String]::Copy('$(NodeRestoreProjectDirectory)').Replace('\', '/').TrimEnd('/')) + <_NodeRestoreRushProjectDirectoryNormalized>$([System.String]::Copy('$(NodeRootFullPath)').Replace('\', '/').TrimEnd('/')) <_NodeRestoreRushRelativeDir>$([MSBuild]::MakeRelative('$(_NodeRestoreRushWorkspaceRootNormalized)/', '$(_NodeRestoreRushProjectDirectoryNormalized)')) <_NodeRestoreRushRelativeDir>$([System.String]::Copy('$(_NodeRestoreRushRelativeDir)').Replace('\', '/').TrimEnd('/')) <_NodeRestoreRushJsonText Condition="Exists('$(_NodeRestoreWorkspaceRoot)/rush.json')">$([System.IO.File]::ReadAllText('$(_NodeRestoreWorkspaceRoot)/rush.json')) @@ -29,7 +29,7 @@ + Text="NodeRestore: '$(NodeRootFullPath)' sits under a Rush workspace ('$(_NodeRestoreWorkspaceRoot)/rush.json') but is not yet listed in its 'projects' array - falling back to a direct npm install/build for this project instead of Rush. Add it to rush.json to have Rush manage (and cache) it." /> <_NodeRestoreResolvedTool>npm - <_NodeRestoreWorkspaceRoot>$(NodeRestoreProjectDirectory) + <_NodeRestoreWorkspaceRoot>$(NodeRootFullPath) + <_RushRestoreScope Condition="'$(SolutionPath)' != ''"> + <_RushRestoreScope Condition="'$(SolutionPath)' == ''"> --to . + + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreUseFrozen)'!='true'">node "$(_NodeRestoreWorkspaceRoot)/common/scripts/install-run-rush.js" update$(_RushRestoreScope) + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreUseFrozen)'=='true'">node "$(_NodeRestoreWorkspaceRoot)/common/scripts/install-run-rush.js" install$(_RushRestoreScope) diff --git a/src/Sdk/Sdk/Sdk.NodeRestore.targets b/src/Sdk/Sdk/Sdk.NodeRestore.targets index 986f2f6..a7f11c1 100644 --- a/src/Sdk/Sdk/Sdk.NodeRestore.targets +++ b/src/Sdk/Sdk/Sdk.NodeRestore.targets @@ -35,14 +35,6 @@ - - - $(MSBuildProjectDirectory)/TS - $(TypeScriptDir) - - - <_RushRestoreScope Condition="'$(SolutionPath)' != ''"> - <_RushRestoreScope Condition="'$(SolutionPath)' == ''"> --to . + + <_RushRestoreScope Condition="'$(SolutionPath)' != '' and '$(SolutionPath)' != '*Undefined*'"> + <_RushRestoreScope Condition="'$(SolutionPath)' == '' or '$(SolutionPath)' == '*Undefined*'"> --to . <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreUseFrozen)'!='true'">node "$(_NodeRestoreWorkspaceRoot)/common/scripts/install-run-rush.js" update$(_RushRestoreScope) <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreUseFrozen)'=='true'">node "$(_NodeRestoreWorkspaceRoot)/common/scripts/install-run-rush.js" install$(_RushRestoreScope) From e7fbb62500c7f786812a4e9aa62ebf183089d481 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 3 Aug 2026 17:22:09 +0200 Subject: [PATCH 07/20] Address review: NormalizePath for absolute overrides, fix docs - Use MSBuild::NormalizePath instead of GetFullPath for NodeRootFullPath so absolute-path overrides (from TypeScriptDir or explicit NodeRootPath) are handled correctly. - Remove stale _ScriptLibraryNodeRestore hook from BuildProcess.md (now handled by _NodeRestoreAnchor in Sdk.NodeRestore.targets). - Fix NodeDependencies.md detection description to reflect all project types walk from NodeRootFullPath (not just ScriptLibrary). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/BuildProcess.md | 1 - docs/NodeDependencies.md | 3 +-- src/Dataverse/Tasks/msbuild/tasks/Props/ProjectPaths.props | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/BuildProcess.md b/docs/BuildProcess.md index 1f01c52..04f334a 100644 --- a/docs/BuildProcess.md +++ b/docs/BuildProcess.md @@ -227,7 +227,6 @@ Because `ProjectType=Pcf` is built on `Microsoft.NET.Sdk`, it also sets `EnableD Main hooks: -- `_ScriptLibraryNodeRestore` (`AfterTargets="CollectPackageReferences"`, calls the shared `NodeRestore` target - fires on solution/repo-root `dotnet restore` too, see [NodeDependencies.md](NodeDependencies.md#verb-parity)) - `BuildTypeScript` (`BeforeTargets="Build"` - delegates to Rush's own `build` command when Rush is resolved, otherwise `npm run build` directly, unchanged) - `CleanScriptLibrary` (`AfterTargets="Clean"`, removes the TypeScript output folder only - never `node_modules`) - `CopyScriptLibraryMainToOutput` (`AfterTargets="Build"`) diff --git a/docs/NodeDependencies.md b/docs/NodeDependencies.md index fa841e1..9edcdca 100644 --- a/docs/NodeDependencies.md +++ b/docs/NodeDependencies.md @@ -160,8 +160,7 @@ without the archived-cache performance benefit. ## How detection works -Purely via MSBuild's built-in `GetDirectoryNameOfFileAbove`, walking up from the project directory (or -`$(NodeRootFullPath)` for ScriptLibrary) looking for the first marker in this order: +Purely via MSBuild's built-in `GetDirectoryNameOfFileAbove`, walking up from `$(NodeRootFullPath)` looking for the first marker in this order: | Precedence | Marker | Resolved tool | |---|---|---| diff --git a/src/Dataverse/Tasks/msbuild/tasks/Props/ProjectPaths.props b/src/Dataverse/Tasks/msbuild/tasks/Props/ProjectPaths.props index f6da736..1799337 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Props/ProjectPaths.props +++ b/src/Dataverse/Tasks/msbuild/tasks/Props/ProjectPaths.props @@ -19,6 +19,6 @@ (Pcf, ScriptLibrary, CodeApp). Earlier SDK versions used TypeScriptDir. --> $(TypeScriptDir) . - $([System.IO.Path]::GetFullPath($(MSBuildProjectDirectory)/$(NodeRootPath))) + $([MSBuild]::NormalizePath($(MSBuildProjectDirectory), $(NodeRootPath))) \ No newline at end of file From 98cc00e52fcd9b4a44d1b41c59ffc5247e51b914 Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Mon, 3 Aug 2026 17:42:07 +0200 Subject: [PATCH 08/20] fix: skip restore-time NodeRestore anchor when package.json is absent --- src/Sdk/Sdk/Sdk.NodeRestore.targets | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Sdk/Sdk/Sdk.NodeRestore.targets b/src/Sdk/Sdk/Sdk.NodeRestore.targets index a7f11c1..e1e805d 100644 --- a/src/Sdk/Sdk/Sdk.NodeRestore.targets +++ b/src/Sdk/Sdk/Sdk.NodeRestore.targets @@ -35,16 +35,31 @@ + + + <_NodeRestoreAnchorNodeRoot>$(NodeRootPath) + <_NodeRestoreAnchorNodeRoot Condition="'$(_NodeRestoreAnchorNodeRoot)' == ''">$(TypeScriptDir) + <_NodeRestoreAnchorNodeRoot Condition="'$(_NodeRestoreAnchorNodeRoot)' == ''">. + <_NodeRestoreAnchorHasPackageJson Condition="Exists('$(MSBuildProjectDirectory)/$(_NodeRestoreAnchorNodeRoot)/package.json')">true + + + Condition="'$(_TALXISDevKitDataverseTasksAvailable)'=='true' and '$(RunNodeBuild)' != 'false' and '$(_NodeRestoreAnchorHasPackageJson)' == 'true' and ('$(ProjectType)'=='Pcf' or '$(ProjectType)'=='ScriptLibrary' or '$(ProjectType)'=='CodeApp')"> @@ -57,7 +72,7 @@ packages, when Exists(...) can succeed, and re-invokes the anchor in a fresh nested evaluation to hydrate Node deps within the same restore. --> + Condition="'$(_TALXISDevKitDataverseTasksAvailable)' != 'true' and '$(RunNodeBuild)' != 'false' and '$(_NodeRestoreAnchorHasPackageJson)' == 'true' and ('$(ProjectType)'=='Pcf' or '$(ProjectType)'=='ScriptLibrary' or '$(ProjectType)'=='CodeApp')"> From 2ad04b8d7497a35baaa014f3593fa86ef36e7c21 Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Mon, 3 Aug 2026 17:42:08 +0200 Subject: [PATCH 09/20] fix: hydrate Node deps at build time as solution-restore safety net --- .../tasks/TALXIS.DevKit.Build.Dataverse.CodeApp.targets | 7 +++++-- .../tasks/TALXIS.DevKit.Build.Dataverse.Pcf.targets | 7 +++++-- .../TALXIS.DevKit.Build.Dataverse.ScriptLibrary.targets | 7 +++++-- 3 files changed, 15 insertions(+), 6 deletions(-) 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 80771b3..d87c9a6 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 @@ -18,7 +18,10 @@ + also runs during a bare "dotnet restore". NodeRestore additionally sits in the build + target chain below as a safety net: a solution-scope restore on a cold NuGet cache + cannot hydrate Node deps (NuGet never runs a per-project Restore target there), so the + build hydrates them itself - a no-op via the restore stamp when already hydrated. --> + also runs during a bare "dotnet restore". NodeRestore additionally sits in the build + target chain below as a safety net: a solution-scope restore on a cold NuGet cache + cannot hydrate Node deps (NuGet never runs a per-project Restore target there), so the + build hydrates them itself - a no-op via the restore stamp when already hydrated. --> + also runs during a bare "dotnet restore". NodeRestore additionally sits in the build + target chain below as a safety net: a solution-scope restore on a cold NuGet cache + cannot hydrate Node deps (NuGet never runs a per-project Restore target there), so the + build hydrates them itself - a no-op via the restore stamp when already hydrated. --> + Properties="_NodeRestoreRetryCommand=$(_NodeRestoreRetryCommand);_NodeRestoreRetryWorkingDirectory=$(_NodeRestoreRetryWorkingDirectory);_NodeRestoreRetryEnvironmentVariables=$(_NodeRestoreRetryEnvironmentVariables);_NodeRestoreRetryMutexName=$(_NodeRestoreRushMutexName)" /> diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Retry.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Retry.targets index a1c23fb..97c892b 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Retry.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Retry.targets @@ -44,6 +44,7 @@ + LockMessage="$(_NodeRestoreRushLockMessage)" + MutexName="$(_NodeRestoreRetryMutexName)" /> diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets index cc8f79f..09e4359 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets @@ -48,16 +48,25 @@ <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreIsCI)'=='true'">true <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)'==''">false - - - <_RushRestoreScope Condition="'$(SolutionPath)' != '' and '$(SolutionPath)' != '*Undefined*'"> - <_RushRestoreScope Condition="'$(SolutionPath)' == '' or '$(SolutionPath)' == '*Undefined*'"> --to . + + <_NodeRestoreRushScoped Condition="('$(SolutionPath)' == '' or '$(SolutionPath)' == '*Undefined*') and !Exists('$(_NodeRestoreWorkspaceRoot)/common/temp/last-install.flag')">true + <_NodeRestoreRushScoped Condition="'$(_NodeRestoreRushScoped)' == ''">false + <_NodeRestoreRushRunner>node "$(_NodeRestoreWorkspaceRoot)/common/scripts/install-run-rush.js" - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreUseFrozen)'!='true'">node "$(_NodeRestoreWorkspaceRoot)/common/scripts/install-run-rush.js" update$(_RushRestoreScope) - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreUseFrozen)'=='true'">node "$(_NodeRestoreWorkspaceRoot)/common/scripts/install-run-rush.js" install$(_RushRestoreScope) + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushScoped)'!='true' and '$(_NodeRestoreUseFrozen)'!='true'">$(_NodeRestoreRushRunner) update + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushScoped)'!='true' and '$(_NodeRestoreUseFrozen)'=='true'">$(_NodeRestoreRushRunner) install + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushScoped)'=='true' and '$(_NodeRestoreUseFrozen)'=='true'">$(_NodeRestoreRushRunner) install --to . + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushScoped)'=='true' and '$(_NodeRestoreUseFrozen)'!='true'">$(_NodeRestoreRushRunner) install --to . || $(_NodeRestoreRushRunner) update + + + <_NodeRestoreRushMutexName>TALXIS.Rush.$([System.String]::Copy('$(_NodeRestoreRushWorkspaceRootNormalized)').Replace('/', '_').Replace(':', '_').ToLowerInvariant()) @@ -70,20 +79,56 @@ DependsOnTargets="_NodeRestoreResolveRush" Condition="'$(NodePackageManager)' != 'None' and '$(NodeRestoreCommand)' == '' and '$(_NodeRestoreResolvedTool)' == 'rush'"> - + + + <_NodeRestoreRushFlagPath>$(_NodeRestoreWorkspaceRoot)/common/temp/last-install.flag + <_NodeRestoreRushLockfilePath>$(_NodeRestoreWorkspaceRoot)/common/config/rush/pnpm-lock.yaml + <_NodeRestoreRushFlagTicks Condition="Exists('$(_NodeRestoreRushFlagPath)')">$([System.IO.File]::GetLastWriteTime('$(_NodeRestoreRushFlagPath)').Ticks) + <_NodeRestoreRushPkgJsonTicks Condition="Exists('$(NodeRootFullPath)/package.json')">$([System.IO.File]::GetLastWriteTime('$(NodeRootFullPath)/package.json').Ticks) + <_NodeRestoreRushLockTicks Condition="Exists('$(_NodeRestoreRushLockfilePath)')">$([System.IO.File]::GetLastWriteTime('$(_NodeRestoreRushLockfilePath)').Ticks) + + <_NodeRestoreRushUpToDate Condition="'$(_NodeRestoreRushFlagTicks)' != '' and Exists('$(NodeRootFullPath)/node_modules')">true + <_NodeRestoreRushUpToDate Condition="'$(_NodeRestoreRushUpToDate)' == 'true' and '$(_NodeRestoreRushPkgJsonTicks)' != '' and $(_NodeRestoreRushPkgJsonTicks) > $(_NodeRestoreRushFlagTicks)">false + <_NodeRestoreRushUpToDate Condition="'$(_NodeRestoreRushUpToDate)' == 'true' and '$(_NodeRestoreRushLockTicks)' != '' and $(_NodeRestoreRushLockTicks) > $(_NodeRestoreRushFlagTicks)">false + + + + + + + + <_NodeRestoreRushBootstrapFlag Remove="@(_NodeRestoreRushBootstrapFlag)" /> <_NodeRestoreRushBootstrapFlag Include="$(_NodeRestoreWorkspaceRoot)/common/temp/install-run/*/installed.flag" /> <_NodeRestoreRushBrokenBootstrap Remove="@(_NodeRestoreRushBrokenBootstrap)" /> <_NodeRestoreRushBrokenBootstrap Include="@(_NodeRestoreRushBootstrapFlag->'%(RootDir)%(Directory)')" Condition="!Exists('%(RootDir)%(Directory)node_modules')" /> + <_NodeRestoreRushGuttedEngine Remove="@(_NodeRestoreRushGuttedEngine)" /> + <_NodeRestoreRushGuttedEngine Include="$(_NodeRestoreWorkspaceRoot)/common/temp/install-run/@microsoft+rush*/installed.flag" /> + <_NodeRestoreRushGuttedEngineDir Remove="@(_NodeRestoreRushGuttedEngineDir)" /> + <_NodeRestoreRushGuttedEngineDir Include="@(_NodeRestoreRushGuttedEngine->'%(RootDir)%(Directory)')" + Condition="Exists('%(RootDir)%(Directory)node_modules') and !Exists('%(RootDir)%(Directory)node_modules/@microsoft/rush/package.json')" /> + + <_NodeRestoreRetryCommand>$(_NodeRestoreResolvedCommand) + <_NodeRestoreRetryWorkingDirectory>$(_NodeRestoreWorkspaceRoot) + <_NodeRestoreRetryWorkingDirectory Condition="'$(_NodeRestoreRushScoped)' == 'true'">$(NodeRootFullPath) + Condition="'$(_NodeRestoreRushUpToDate)' != 'true'" + Properties="_NodeRestoreRetryCommand=$(_NodeRestoreRetryCommand);_NodeRestoreRetryWorkingDirectory=$(_NodeRestoreRetryWorkingDirectory);_NodeRestoreRetryMutexName=$(_NodeRestoreRushMutexName)" /> From cf63ef951c5238f4d45ee4c4c49686f32212970b Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Mon, 3 Aug 2026 21:10:32 +0200 Subject: [PATCH 11/20] docs: describe Rush serialization, up-to-date gate and scoping rules --- docs/NodeDependencies.md | 76 +++++++++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/docs/NodeDependencies.md b/docs/NodeDependencies.md index 9edcdca..b119556 100644 --- a/docs/NodeDependencies.md +++ b/docs/NodeDependencies.md @@ -36,11 +36,14 @@ underlying Node tool: the shared detection/dispatch path; the two escape hatches (`NodePackageManager=` for a tool already in the command table, `NodeRestoreCommand=` for anything else) are the intended extension surface for a tool this SDK doesn't ship day-one support for - no plugin/adapter abstraction is introduced. -- **Hand repo-level exclusivity arbitration to the tool that already owns it - don't reinvent it.** Rush already - has its own whole-repo, fail-fast lock for `update`/`install`/`build`. Rather than building a new generic lock - file to serialize MSBuild's parallel solution builds, `NodeRestore` retries the Rush invocation with a bounded, - exponential backoff when it hits Rush's own "already running" condition - narrow, tool-specific resilience for - one confirmed failure mode, not a general-purpose mechanism imposed on every tool. +- **Repo-level exclusivity is enforced by the SDK, informed by the tool.** Rush has its own whole-repo, + fail-fast lock for `update`/`install`/`build`, but several of its phases (the per-user pnpm bootstrap in + `~/.rush`, the lockfile copies into `common/temp`) run before that lock and are not concurrency-safe - a + parallel solution-scope restore corrupts them. `NodeRestore` therefore serializes all Rush invocations for a + workspace behind one named system mutex, and keeps a bounded, exponential-backoff retry on Rush's own + "already running" condition as a second line of defense for invocations that don't come from this SDK - + narrow, tool-specific resilience for confirmed failure modes, not a general-purpose mechanism imposed on + every tool. - **MSBuild stays the top-level orchestrator.** It decides *when*/*whether* each project builds at all and in what order (via project references, `.slnx` build graph, `-m` parallelism). Rush is only ever the primitive that the *Node-specific* portion of that work is delegated to (installing dependencies, and - for Rush @@ -56,10 +59,16 @@ underlying Node tool: | `dotnet clean` | Removes this project's own JS build-output folder only (`dist` for CodeApp, ScriptLibrary's TypeScript output folder). Never touches `node_modules` or any shared workspace state - "clean" and "prune installed deps" are different operations, and removing `node_modules` is a far more expensive, disruptive step than a normal `dotnet clean` should trigger silently. `Pcf` has no new Clean target from this SDK - Microsoft's own `PcfClean` (`npm run clean`) already owns PCF's `out/controls` cleanup. | | `dotnet publish` | Copies JS build output into the publish directory (existing, unaffected by any of the above). | -`dotnet build --no-restore` still triggers `NodeRestore` - `CollectPackageReferences` is a build-time target, not -gated by NuGet's `--no-restore` flag. This is deliberate, not a bug: worst case it's a cheap no-op via the -existing incremental gate (non-Rush) or Rush's own state hash (Rush); it never silently skips Node hydration just -because NuGet's own restore step was skipped. +`dotnet build --no-restore` still triggers `NodeRestore` - it sits in each project type's Node build chain +(`BuildTypeScript`/`PcfBuild`/`BuildCodeApp`), not only behind NuGet's restore. This is deliberate, not a bug: +worst case it's a cheap no-op via the existing incremental gate (non-Rush) or the Rush up-to-date gate; it never +silently skips Node hydration just because NuGet's own restore step was skipped. + +One bootstrapping caveat: the very first **solution-scope** `dotnet restore` on a machine whose NuGet cache does +not yet contain this SDK's packages hydrates NuGet packages only - the target that hydrates Node deps arrives in +one of those packages, and NuGet's solution-scope restore has no per-project hook that runs after download. The +next `dotnet restore`, or the first `dotnet build` (whose Node build chain runs `NodeRestore` itself), hydrates +Node dependencies. Single-project restores do not have this gap. ## Build delegation to Rush @@ -191,7 +200,7 @@ uses the frozen/reproducible install variant instead of the mutable one: | Tool | Local / mutable | CI / frozen | |---|---|---| -| `rush` | `install-run-rush.js update` | `install-run-rush.js install` | +| `rush` | `install-run-rush.js update` (scoped `install --to .` on a never-installed workspace - see [Rush specifics](#rush-specifics)) | `install-run-rush.js install` (same scoping rule) | | `pnpm` | `pnpm install` | `pnpm install --frozen-lockfile` | | `yarn` (Classic) | `yarn install` | `yarn install --frozen-lockfile` | | `yarn` (Berry) | `yarn install` | `yarn install --immutable` | @@ -218,11 +227,45 @@ must never be bypassed. `NodeRestore` always invokes it via the version-pinned b (`/common/scripts/install-run-rush.js`), never a global `rush` binary, so the exact Rush version pinned in `rush.json` is always what runs. -Rush is invoked **unconditionally on every build** rather than being gated by a stamp file: Rush is already -self-idempotent (it compares a state hash against `common/temp/last-install.flag` and exits almost immediately -if nothing changed) and already self-serializing across concurrent invocations (its own -`common/temp/rush#.lock`). A second, custom incrementality mechanism layered on top would duplicate one -Rush already owns and would be a likely source of subtly-wrong "already restored" bugs. +### Serialization + +Rush invocations are serialized behind **one named system mutex per workspace** (held inside the +`ExecWithRetry` task, across all concurrent MSBuild node processes). Rush's own repo lock is fail-fast and +covers only part of its work: the pnpm bootstrap in the per-user `~/.rush` cache and the lockfile copies into +`common/temp` run before that lock and corrupt each other when two invocations overlap - which a parallel +solution-scope restore or build otherwise guarantees. With the mutex, the first invocation does the real work +and every queued one hits Rush's own fast path. The bounded retry on Rush's "already running" message remains +as a second line of defense for invocations that don't come from this SDK. + +### Up-to-date gate + +Rush is only spawned when there is possibly something to do: when `common/temp/last-install.flag` exists, the +project's `node_modules` exist, and neither the project's `package.json` nor +`common/config/rush/pnpm-lock.yaml` is newer than the flag, `NodeRestore` skips the invocation entirely - a +warm solution-scope restore starts zero Rush processes. A fresh clone, deleted `node_modules`, or edited +dependencies all fail the gate and trigger a real, serialized invocation, which then relies on Rush's own state +hash for the finer-grained decision. + +### Self-healing after a node_modules cleanup + +Rush records "installed" in `common/temp/last-install.flag`, which survives a `node_modules` cleanup and would +make Rush skip reinstalling forever. When the flag exists but the restoring project's `node_modules` are gone, +`NodeRestore` clears the flag so the next serialized invocation re-links the project from the intact pnpm +store. Two partially-deleted bootstrap states (the Rush engine under `common/temp/install-run` or the local +pnpm under `common/temp/pnpm-local` gutted while their install flags survive - typically a recursive cleanup +that followed Rush's symlink into `~/.rush`) are detected and fail fast with the exact recovery commands; +they are deliberately not auto-deleted, because destroying shared state from concurrent per-project targets is +precisely what corrupts these caches. + +### Scoped restore + +On a workspace that has never been installed (no `last-install.flag` - a fresh clone or a CI agent), a +project-level restore runs `install --to .` from the project directory, installing only that project's +dependency subtree; outside CI it falls back to a full `update` if the scoped install fails (stale lockfile, or +a pinned Rush too old to know the selector). Everywhere else the invocation is unscoped: `rush update` has no +project selectors at all, and a filtered install rewrites Rush's install state - alternating scoped and +unscoped invocations on an installed workspace forces a full recycle/reinstall on every switch, far slower +than the no-op it replaces. Solution-scope restores are always unscoped. ## Once-per-workspace execution (non-Rush tools) @@ -235,7 +278,8 @@ skips it - the same "once per workspace, not once per project" guarantee `dotnet Known limitation: concurrent multi-proc MSBuild builds (`dotnet build -m`) of independent projects sharing one workspace root can still race to invoke install simultaneously for these tools, since none of npm/pnpm/Yarn/Bun -ship a cross-process lock of their own (Rush does not have this problem - see above). +ship a cross-process lock of their own (Rush does not have this problem - its invocations are serialized behind +the per-workspace mutex, see [Rush specifics](#rush-specifics)). ## No global side effects From 937f343faf9d212a5bf596567642d73440d56396 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Wed, 5 Aug 2026 23:33:13 +0200 Subject: [PATCH 12/20] Harden Rush coordination and resolve subspace topology - bound mutex names with a stable hash and reduce retry duration - parse Rush project/subspace configuration with a typed MSBuild task - support project dependency selection across standard Rush subspaces - preserve the warm gate while tracking shared Rush inputs - rely on Rush to self-heal project links without deleting its state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Dataverse/Tasks/Tasks/ExecWithRetry.cs | 18 +- .../Tasks/Tasks/Node/ResolveRushProject.cs | 227 ++++++++++++++++++ ...ALXIS.DevKit.Build.Dataverse.Tasks.targets | 1 + .../tasks/Targets/NodeRestore/Rush.targets | 89 +++---- 4 files changed, 286 insertions(+), 49 deletions(-) create mode 100644 src/Dataverse/Tasks/Tasks/Node/ResolveRushProject.cs diff --git a/src/Dataverse/Tasks/Tasks/ExecWithRetry.cs b/src/Dataverse/Tasks/Tasks/ExecWithRetry.cs index b5db949..e8a0f6d 100644 --- a/src/Dataverse/Tasks/Tasks/ExecWithRetry.cs +++ b/src/Dataverse/Tasks/Tasks/ExecWithRetry.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Globalization; +using System.Security.Cryptography; using System.Text; using System.Threading; using Microsoft.Build.Framework; @@ -16,7 +17,7 @@ public class ExecWithRetry : Task, ICancelableTask { private static readonly int[] DefaultDelaysMilliseconds = { 1000, 2000, 4000, 8000, 16000 }; - private const int DefaultMaxAttempts = 45; + private const int DefaultMaxAttempts = 10; private const int DefaultFallbackDelayMilliseconds = 30000; private readonly CancellationTokenSource cancellationSource = new(); @@ -88,8 +89,9 @@ public override bool Execute() if (!string.IsNullOrEmpty(MutexName)) { - mutex = new Mutex(initiallyOwned: false, MutexName); - mutexAcquired = AcquireMutex(mutex); + var platformMutexName = CreatePlatformMutexName(MutexName); + mutex = new Mutex(initiallyOwned: false, platformMutexName); + mutexAcquired = AcquireMutex(mutex, platformMutexName); } while (true) @@ -149,7 +151,7 @@ public override bool Execute() } } - private bool AcquireMutex(Mutex mutex) + private bool AcquireMutex(Mutex mutex, string mutexName) { var waitLogged = false; while (true) @@ -171,11 +173,17 @@ private bool AcquireMutex(Mutex mutex) if (!waitLogged) { waitLogged = true; - Log.LogMessage(MessageImportance.Normal, $"Waiting for another serialized command holding mutex '{MutexName}' to finish..."); + Log.LogMessage(MessageImportance.Normal, $"Waiting for another serialized command holding mutex '{mutexName}' to finish..."); } } } + private static string CreatePlatformMutexName(string coordinationKey) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(coordinationKey)); + return $"TALXIS.Node.{Convert.ToHexString(hash)}"; + } + private static int[] ParseDelays(string raw) { if (string.IsNullOrWhiteSpace(raw)) diff --git a/src/Dataverse/Tasks/Tasks/Node/ResolveRushProject.cs b/src/Dataverse/Tasks/Tasks/Node/ResolveRushProject.cs new file mode 100644 index 0000000..dda9a16 --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/Node/ResolveRushProject.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +/// +/// Resolves a Node project against Rush's project and subspace configuration. +/// +public sealed class ResolveRushProject : Task +{ + private static readonly JsonDocumentOptions JsonOptions = new() + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip + }; + + [Required] + public string WorkspaceRoot { get; set; } = string.Empty; + + [Required] + public string ProjectRoot { get; set; } = string.Empty; + + [Output] + public bool IsRegistered { get; private set; } + + [Output] + public bool SubspacesEnabled { get; private set; } + + [Output] + public string SubspaceName { get; private set; } = string.Empty; + + [Output] + public string SubspaceConfigurationRoot { get; private set; } = string.Empty; + + [Output] + public string SubspaceTempRoot { get; private set; } = string.Empty; + + [Output] + public ITaskItem[] InstallPackageJsonPaths { get; private set; } = Array.Empty(); + + public override bool Execute() + { + try + { + var workspaceRoot = NormalizeDirectory(WorkspaceRoot); + var projectRoot = NormalizeDirectory(ProjectRoot); + var rushJsonPath = Path.Combine(workspaceRoot, "rush.json"); + if (!File.Exists(rushJsonPath)) + { + Log.LogError($"Rush configuration was not found at '{rushJsonPath}'."); + return false; + } + + using var rushJson = JsonDocument.Parse(File.ReadAllText(rushJsonPath), JsonOptions); + if (rushJson.RootElement.ValueKind != JsonValueKind.Object) + { + Log.LogError($"Rush configuration '{rushJsonPath}' must contain a JSON object."); + return false; + } + + if (!rushJson.RootElement.TryGetProperty("projects", out var projectsElement) || + projectsElement.ValueKind != JsonValueKind.Array) + { + Log.LogError($"Rush configuration '{rushJsonPath}' does not contain a valid 'projects' array."); + return false; + } + + var projects = ReadProjects(projectsElement, workspaceRoot, rushJsonPath); + if (Log.HasLoggedErrors) + { + return false; + } + + var matchingProjects = projects.Where(project => PathsEqual(project.FullPath, projectRoot)).ToArray(); + if (matchingProjects.Length > 1) + { + Log.LogError($"Rush configuration '{rushJsonPath}' registers project folder '{projectRoot}' more than once."); + return false; + } + + var currentProject = matchingProjects.SingleOrDefault(); + IsRegistered = currentProject != null; + + var subspacesJsonPath = Path.Combine(workspaceRoot, "common", "config", "rush", "subspaces.json"); + var subspaceNames = new HashSet(StringComparer.Ordinal); + if (File.Exists(subspacesJsonPath)) + { + using var subspacesJson = JsonDocument.Parse(File.ReadAllText(subspacesJsonPath), JsonOptions); + if (subspacesJson.RootElement.ValueKind != JsonValueKind.Object) + { + Log.LogError($"Rush subspace configuration '{subspacesJsonPath}' must contain a JSON object."); + return false; + } + + SubspacesEnabled = ReadOptionalBoolean(subspacesJson.RootElement, "subspacesEnabled"); + if (SubspacesEnabled) + { + if (ReadOptionalBoolean(subspacesJson.RootElement, "splitWorkspaceCompatibility")) + { + Log.LogError( + $"Rush subspace configuration '{subspacesJsonPath}' enables deprecated splitWorkspaceCompatibility. " + + "Migrate the repository to standard common/config/subspaces/ configuration."); + return false; + } + + subspaceNames.Add("default"); + if (!subspacesJson.RootElement.TryGetProperty("subspaceNames", out var namesElement) || + namesElement.ValueKind != JsonValueKind.Array) + { + Log.LogError($"Rush subspace configuration '{subspacesJsonPath}' does not contain a valid 'subspaceNames' array."); + return false; + } + + foreach (var nameElement in namesElement.EnumerateArray()) + { + if (nameElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(nameElement.GetString())) + { + Log.LogError($"Rush subspace configuration '{subspacesJsonPath}' contains an invalid subspace name."); + return false; + } + subspaceNames.Add(nameElement.GetString()!); + } + } + } + + if (SubspacesEnabled) + { + foreach (var project in projects) + { + var projectSubspace = string.IsNullOrWhiteSpace(project.SubspaceName) ? "default" : project.SubspaceName; + if (!subspaceNames.Contains(projectSubspace)) + { + Log.LogError( + $"Rush project '{project.RelativePath}' references unknown subspace '{projectSubspace}'. " + + $"Register it in '{subspacesJsonPath}'."); + } + } + if (Log.HasLoggedErrors) + { + return false; + } + } + + if (!IsRegistered) + { + return true; + } + + SubspaceName = SubspacesEnabled + ? string.IsNullOrWhiteSpace(currentProject!.SubspaceName) ? "default" : currentProject.SubspaceName + : string.Empty; + + SubspaceConfigurationRoot = SubspacesEnabled + ? Path.Combine(workspaceRoot, "common", "config", "subspaces", SubspaceName) + : Path.Combine(workspaceRoot, "common", "config", "rush"); + + SubspaceTempRoot = SubspacesEnabled + ? Path.Combine(workspaceRoot, "common", "temp", SubspaceName) + : Path.Combine(workspaceRoot, "common", "temp"); + + InstallPackageJsonPaths = projects + .Select(project => (ITaskItem)new TaskItem(Path.Combine(project.FullPath, "package.json"))) + .ToArray(); + + return true; + } + catch (JsonException ex) + { + Log.LogError($"Invalid Rush JSON configuration: {ex.Message}"); + return false; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + Log.LogError(ex.Message); + return false; + } + } + + private List ReadProjects(JsonElement projectsElement, string workspaceRoot, string rushJsonPath) + { + var projects = new List(); + foreach (var projectElement in projectsElement.EnumerateArray()) + { + if (projectElement.ValueKind != JsonValueKind.Object || + !projectElement.TryGetProperty("projectFolder", out var folderElement) || + folderElement.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(folderElement.GetString())) + { + Log.LogError($"Rush configuration '{rushJsonPath}' contains a project without a valid 'projectFolder'."); + continue; + } + + var relativePath = folderElement.GetString()!; + var subspaceName = projectElement.TryGetProperty("subspaceName", out var subspaceElement) && + subspaceElement.ValueKind == JsonValueKind.String + ? subspaceElement.GetString() ?? string.Empty + : string.Empty; + + projects.Add(new RushProject(relativePath, NormalizeDirectory(Path.Combine(workspaceRoot, relativePath)), subspaceName)); + } + return projects; + } + + private static bool ReadOptionalBoolean(JsonElement root, string propertyName) + { + return root.TryGetProperty(propertyName, out var value) && + value.ValueKind == JsonValueKind.True; + } + + private static string NormalizeDirectory(string path) + { + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + } + + private static bool PathsEqual(string left, string right) + { + return string.Equals( + left, + right, + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + + private sealed record RushProject(string RelativePath, string FullPath, string SubspaceName); +} 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 7772c33..0ff096c 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 @@ -45,4 +45,5 @@ + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets index 09e4359..27d5d9f 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets @@ -8,24 +8,18 @@ DependsOnTargets="_NodeRestoreResolve" Condition="'$(NodePackageManager)' != 'None' and '$(_NodeRestoreResolvedTool)' == 'rush'"> - + + + + + + + + + <_NodeRestoreRushWorkspaceRootNormalized>$([System.String]::Copy('$(_NodeRestoreWorkspaceRoot)').Replace('\', '/').TrimEnd('/')) - <_NodeRestoreRushProjectDirectoryNormalized>$([System.String]::Copy('$(NodeRootFullPath)').Replace('\', '/').TrimEnd('/')) - <_NodeRestoreRushRelativeDir>$([MSBuild]::MakeRelative('$(_NodeRestoreRushWorkspaceRootNormalized)/', '$(_NodeRestoreRushProjectDirectoryNormalized)')) - <_NodeRestoreRushRelativeDir>$([System.String]::Copy('$(_NodeRestoreRushRelativeDir)').Replace('\', '/').TrimEnd('/')) - <_NodeRestoreRushJsonText Condition="Exists('$(_NodeRestoreWorkspaceRoot)/rush.json')">$([System.IO.File]::ReadAllText('$(_NodeRestoreWorkspaceRoot)/rush.json')) - <_NodeRestoreIsRushRegistered Condition="'$(_NodeRestoreRushJsonText)' != '' and $(_NodeRestoreRushJsonText.Contains('"$(_NodeRestoreRushRelativeDir)"'))">true - <_NodeRestoreIsRushRegistered Condition="'$(_NodeRestoreIsRushRegistered)' == ''">false - <_NodeRestoreRushScoped Condition="('$(SolutionPath)' == '' or '$(SolutionPath)' == '*Undefined*') and !Exists('$(_NodeRestoreWorkspaceRoot)/common/temp/last-install.flag')">true + <_NodeRestoreRushScoped Condition="'$(_NodeRestoreRushSubspacesEnabled)' != 'true' and ('$(SolutionPath)' == '' or '$(SolutionPath)' == '*Undefined*') and !Exists('$(_NodeRestoreRushTempRoot)/last-install.flag')">true <_NodeRestoreRushScoped Condition="'$(_NodeRestoreRushScoped)' == ''">false + <_NodeRestoreRushProjectSelected Condition="'$(_NodeRestoreRushSubspacesEnabled)' == 'true' or '$(_NodeRestoreRushScoped)' == 'true'">true + <_NodeRestoreRushProjectSelected Condition="'$(_NodeRestoreRushProjectSelected)' == ''">false <_NodeRestoreRushRunner>node "$(_NodeRestoreWorkspaceRoot)/common/scripts/install-run-rush.js" - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushScoped)'!='true' and '$(_NodeRestoreUseFrozen)'!='true'">$(_NodeRestoreRushRunner) update - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushScoped)'!='true' and '$(_NodeRestoreUseFrozen)'=='true'">$(_NodeRestoreRushRunner) install - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushScoped)'=='true' and '$(_NodeRestoreUseFrozen)'=='true'">$(_NodeRestoreRushRunner) install --to . - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushScoped)'=='true' and '$(_NodeRestoreUseFrozen)'!='true'">$(_NodeRestoreRushRunner) install --to . || $(_NodeRestoreRushRunner) update + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushSubspacesEnabled)'=='true' and '$(_NodeRestoreUseFrozen)'!='true'">$(_NodeRestoreRushRunner) update --to . + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushSubspacesEnabled)'=='true' and '$(_NodeRestoreUseFrozen)'=='true'">$(_NodeRestoreRushRunner) install --to . + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushSubspacesEnabled)'!='true' and '$(_NodeRestoreRushScoped)'!='true' and '$(_NodeRestoreUseFrozen)'!='true'">$(_NodeRestoreRushRunner) update + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushSubspacesEnabled)'!='true' and '$(_NodeRestoreRushScoped)'!='true' and '$(_NodeRestoreUseFrozen)'=='true'">$(_NodeRestoreRushRunner) install + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushSubspacesEnabled)'!='true' and '$(_NodeRestoreRushScoped)'=='true' and '$(_NodeRestoreUseFrozen)'=='true'">$(_NodeRestoreRushRunner) install --to . + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushSubspacesEnabled)'!='true' and '$(_NodeRestoreRushScoped)'=='true' and '$(_NodeRestoreUseFrozen)'!='true'">$(_NodeRestoreRushRunner) install --to . || $(_NodeRestoreRushRunner) update + project node_modules present, and no project/shared Rush configuration input is newer + than the flag. Rush remains authoritative once invoked; this gate only avoids asking it + the same question from every MSBuild project. --> - <_NodeRestoreRushFlagPath>$(_NodeRestoreWorkspaceRoot)/common/temp/last-install.flag - <_NodeRestoreRushLockfilePath>$(_NodeRestoreWorkspaceRoot)/common/config/rush/pnpm-lock.yaml + <_NodeRestoreRushFlagPath>$(_NodeRestoreRushTempRoot)/last-install.flag <_NodeRestoreRushFlagTicks Condition="Exists('$(_NodeRestoreRushFlagPath)')">$([System.IO.File]::GetLastWriteTime('$(_NodeRestoreRushFlagPath)').Ticks) - <_NodeRestoreRushPkgJsonTicks Condition="Exists('$(NodeRootFullPath)/package.json')">$([System.IO.File]::GetLastWriteTime('$(NodeRootFullPath)/package.json').Ticks) - <_NodeRestoreRushLockTicks Condition="Exists('$(_NodeRestoreRushLockfilePath)')">$([System.IO.File]::GetLastWriteTime('$(_NodeRestoreRushLockfilePath)').Ticks) - - <_NodeRestoreRushUpToDate Condition="'$(_NodeRestoreRushFlagTicks)' != '' and Exists('$(NodeRootFullPath)/node_modules')">true - <_NodeRestoreRushUpToDate Condition="'$(_NodeRestoreRushUpToDate)' == 'true' and '$(_NodeRestoreRushPkgJsonTicks)' != '' and $(_NodeRestoreRushPkgJsonTicks) > $(_NodeRestoreRushFlagTicks)">false - <_NodeRestoreRushUpToDate Condition="'$(_NodeRestoreRushUpToDate)' == 'true' and '$(_NodeRestoreRushLockTicks)' != '' and $(_NodeRestoreRushLockTicks) > $(_NodeRestoreRushFlagTicks)">false + <_NodeRestoreRushUpToDate Condition="'$(_NodeRestoreRushSubspacesEnabled)' != 'true' and '$(_NodeRestoreRushFlagTicks)' != '' and Exists('$(NodeRootFullPath)/node_modules')">true - - - + + <_NodeRestoreRushGateInput Remove="@(_NodeRestoreRushGateInput)" /> + <_NodeRestoreRushGateInput Include="@(_NodeRestoreRushInstallPackageJson)" /> + <_NodeRestoreRushGateInput Include="$(_NodeRestoreWorkspaceRoot)/rush.json" /> + <_NodeRestoreRushGateInput Include="$(_NodeRestoreWorkspaceRoot)/common/config/rush/**/*" /> + <_NodeRestoreRushGateInput Include="$(_NodeRestoreWorkspaceRoot)/common/pnpm-patches/**/*" + Condition="'$(_NodeRestoreRushSubspacesEnabled)' != 'true'" /> + <_NodeRestoreRushGateInput Include="$(_NodeRestoreRushConfigurationRoot)/**/*" + Condition="'$(_NodeRestoreRushSubspacesEnabled)' == 'true'" /> + <_NodeRestoreRushNewerInput Remove="@(_NodeRestoreRushNewerInput)" /> + <_NodeRestoreRushNewerInput Include="@(_NodeRestoreRushGateInput)" + Condition="Exists('%(FullPath)') and $([System.IO.File]::GetLastWriteTime('%(FullPath)').Ticks) > $(_NodeRestoreRushFlagTicks)" /> + + + <_NodeRestoreRushUpToDate Condition="'@(_NodeRestoreRushNewerInput)' != ''">false + - + <_NodeRestoreRushBootstrapFlag Remove="@(_NodeRestoreRushBootstrapFlag)" /> <_NodeRestoreRushBootstrapFlag Include="$(_NodeRestoreWorkspaceRoot)/common/temp/install-run/*/installed.flag" /> @@ -116,9 +118,8 @@ <_NodeRestoreRushGuttedEngineDir Include="@(_NodeRestoreRushGuttedEngine->'%(RootDir)%(Directory)')" Condition="Exists('%(RootDir)%(Directory)node_modules') and !Exists('%(RootDir)%(Directory)node_modules/@microsoft/rush/package.json')" /> - - + $(_NodeRestoreResolvedCommand) <_NodeRestoreRetryWorkingDirectory>$(_NodeRestoreWorkspaceRoot) - <_NodeRestoreRetryWorkingDirectory Condition="'$(_NodeRestoreRushScoped)' == 'true'">$(NodeRootFullPath) + <_NodeRestoreRetryWorkingDirectory Condition="'$(_NodeRestoreRushProjectSelected)' == 'true'">$(NodeRootFullPath) + + + - + + + + + + + + + + + - - + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Detection.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Detection.targets index 723b77f..d04ace7 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Detection.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Detection.targets @@ -24,51 +24,15 @@ <_NodeRestoreIsCI Condition="'$(_NodeRestoreIsCI)'==''">false - - - - - <_NodeRestoreRushRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'rush.json')) - <_NodeRestorePnpmRoot Condition="'$(_NodeRestoreRushRoot)'==''">$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'pnpm-lock.yaml')) - <_NodeRestoreYarnRoot Condition="'$(_NodeRestoreRushRoot)'=='' and '$(_NodeRestorePnpmRoot)'==''">$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'yarn.lock')) - <_NodeRestoreBunRoot Condition="'$(_NodeRestoreRushRoot)'=='' and '$(_NodeRestorePnpmRoot)'=='' and '$(_NodeRestoreYarnRoot)'==''">$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'bun.lockb')) - <_NodeRestoreNpmRoot Condition="'$(_NodeRestoreRushRoot)'=='' and '$(_NodeRestorePnpmRoot)'=='' and '$(_NodeRestoreYarnRoot)'=='' and '$(_NodeRestoreBunRoot)'==''">$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'package-lock.json')) - - - - - <_NodeRestoreDetectedTool Condition="'$(_NodeRestoreRushRoot)'!=''">rush - <_NodeRestoreDetectedTool Condition="'$(_NodeRestoreDetectedTool)'=='' and '$(_NodeRestorePnpmRoot)'!=''">pnpm - <_NodeRestoreDetectedTool Condition="'$(_NodeRestoreDetectedTool)'=='' and '$(_NodeRestoreYarnRoot)'!=''">yarn - <_NodeRestoreDetectedTool Condition="'$(_NodeRestoreDetectedTool)'=='' and '$(_NodeRestoreBunRoot)'!=''">bun - <_NodeRestoreDetectedTool Condition="'$(_NodeRestoreDetectedTool)'=='' and '$(_NodeRestoreNpmRoot)'!=''">npm - <_NodeRestoreDetectedTool Condition="'$(_NodeRestoreDetectedTool)'==''">npm - - <_NodeRestoreDetectedRoot Condition="'$(_NodeRestoreDetectedTool)'=='rush'">$(_NodeRestoreRushRoot) - <_NodeRestoreDetectedRoot Condition="'$(_NodeRestoreDetectedTool)'=='pnpm'">$(_NodeRestorePnpmRoot) - <_NodeRestoreDetectedRoot Condition="'$(_NodeRestoreDetectedTool)'=='yarn'">$(_NodeRestoreYarnRoot) - <_NodeRestoreDetectedRoot Condition="'$(_NodeRestoreDetectedTool)'=='bun'">$(_NodeRestoreBunRoot) - <_NodeRestoreDetectedRoot Condition="'$(_NodeRestoreDetectedTool)'=='npm' and '$(_NodeRestoreNpmRoot)'!=''">$(_NodeRestoreNpmRoot) - <_NodeRestoreDetectedRoot Condition="'$(_NodeRestoreDetectedRoot)'==''">$(NodeRootFullPath) - - - - - <_NodeRestoreResolvedTool Condition="'$(NodePackageManager)' != ''">$(NodePackageManager) - <_NodeRestoreResolvedTool Condition="'$(_NodeRestoreResolvedTool)'==''">$(_NodeRestoreDetectedTool) - <_NodeRestoreWorkspaceRoot>$(_NodeRestoreDetectedRoot) + - - <_NodeRestoreYarnBerry Condition="'$(_NodeRestoreResolvedTool)'=='yarn' and Exists('$(_NodeRestoreWorkspaceRoot)/.yarnrc.yml')">true - + + + + @@ -77,11 +41,9 @@ - + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Generic.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Generic.targets index b8c7316..cb965e8 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Generic.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Generic.targets @@ -1,15 +1,68 @@ + + $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreDetectPackageManagers + $(NodeRestoreAdapterConfigureDependsOn);_NodeRestoreResolveGeneric + $(NodeRestoreAdapterRunDependsOn);_NodeRestoreRunGeneric + + + + + <_NodeRestorePnpmRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'pnpm-lock.yaml')) + <_NodeRestoreYarnRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'yarn.lock')) + <_NodeRestoreBunRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'bun.lockb')) + <_NodeRestoreNpmRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'package-lock.json')) + + + + <_NodeRestoreAdapterCandidate Include="pnpm" Condition="'$(_NodeRestorePnpmRoot)' != '' or '$(NodePackageManager)' == 'pnpm'"> + 200 + $(_NodeRestorePnpmRoot) + $(NodeRootFullPath) + $(MSBuildThisFileFullPath) + + <_NodeRestoreAdapterCandidate Include="yarn" Condition="'$(_NodeRestoreYarnRoot)' != '' or '$(NodePackageManager)' == 'yarn'"> + 190 + $(_NodeRestoreYarnRoot) + $(NodeRootFullPath) + $(MSBuildThisFileFullPath) + + <_NodeRestoreAdapterCandidate Include="bun" Condition="'$(_NodeRestoreBunRoot)' != '' or '$(NodePackageManager)' == 'bun'"> + 180 + $(_NodeRestoreBunRoot) + $(NodeRootFullPath) + $(MSBuildThisFileFullPath) + + <_NodeRestoreAdapterCandidate Include="npm"> + 0 + $(_NodeRestoreNpmRoot) + $(NodeRootFullPath) + $(MSBuildThisFileFullPath) + + + + + every plain Node/TypeScript/React project without a monorepo tool on top. Gated on this + adapter's own known tool names rather than merely "not rush": with more than two adapters + registered (e.g. an external one), "not rush" would also match every other adapter's tool + name and make this target race the selected adapter's own configure/run targets to compute + (and execute) an unrelated _NodeRestoreResolvedCommand. --> + Condition="'$(NodePackageManager)' != 'None' and ('$(_NodeRestoreResolvedTool)'=='npm' or '$(_NodeRestoreResolvedTool)'=='pnpm' or '$(_NodeRestoreResolvedTool)'=='yarn' or '$(_NodeRestoreResolvedTool)'=='bun')"> + <_NodeRestoreYarnBerry Condition="'$(_NodeRestoreResolvedTool)'=='yarn' and Exists('$(_NodeRestoreWorkspaceRoot)/.yarnrc.yml')">true <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreResolvedTool)'=='pnpm' and Exists('$(_NodeRestoreWorkspaceRoot)/pnpm-lock.yaml')">true <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)'=='' and '$(_NodeRestoreResolvedTool)'=='yarn' and Exists('$(_NodeRestoreWorkspaceRoot)/yarn.lock')">true <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)'=='' and '$(_NodeRestoreResolvedTool)'=='bun' and Exists('$(_NodeRestoreWorkspaceRoot)/bun.lockb')">true @@ -75,7 +128,7 @@ (unlike Rush, see Rush.targets/Retry.targets). --> + + $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreDetectRush + $(NodeRestoreAdapterConfigureDependsOn);_NodeRestoreResolveRush + $(NodeRestoreAdapterRunDependsOn);_NodeRestoreRunRush + + + + + <_NodeRestoreRushRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'rush.json')) + + + <_NodeRestoreAdapterCandidate Include="rush"> + 300 + $(_NodeRestoreRushRoot) + $(MSBuildThisFileFullPath) + + + + @@ -96,7 +96,7 @@ + DependsOnTargets="_NodeRestoreSelect;NodeRestore;_PcfSetNodeBuildArgs;_NodeBuildDelegateToRush;_NodeBuildDirect"> @@ -53,6 +53,7 @@ development - + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets index 5fdadb8..dbb69f5 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets @@ -2,7 +2,7 @@ - <_NodeBuildDirectCommand Condition="'$(_NodeRestoreResolvedTool)'=='pnpm'">pnpm run build -- diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/RushDelegation.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/RushDelegation.targets index bdacaa0..908ef9b 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/RushDelegation.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/RushDelegation.targets @@ -25,7 +25,7 @@ parameter list when the project type declares a flag name, in which case a Rush-registered project must declare that flag in command-line.json the same way it declares any other custom parameter. --> - + Targets="_NodeExecWithRetry" + Properties="_NodeExecRetryCommand=$(_NodeExecRetryCommand);_NodeExecRetryWorkingDirectory=$(_NodeExecRetryWorkingDirectory);_NodeExecRetryEnvironmentVariables=$(_NodeExecRetryEnvironmentVariables);_NodeExecRetryMutexName=$(_NodeRestoreRushMutexName)" /> diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets index e4c4d45..d097698 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets @@ -18,12 +18,15 @@ NodeRestoreCommand Raw command override. Wins over everything else below. Implementation is split across NodeRestore\*.targets by concern: - Detection.targets Runs adapter detection, then SelectNodeAdapter picks a winner - shared + Selection.targets Runs adapter detection, then SelectToolAdapter picks a winner - shared by every branch below. Rush.targets Rush-specific install/update (Rush is a monorepo orchestrator that delegates its own dependency install to pnpm, not a package manager in its own right). - Generic.targets npm/pnpm/yarn/bun install (used whenever no orchestrator is present). + Npm.targets npm install/ci (always-present fallback adapter). + Pnpm.targets pnpm install (with frozen-lockfile in CI). + Yarn.targets yarn install (Classic and Berry/PnP, with frozen-lockfile in CI). + Bun.targets bun install (with frozen-lockfile in CI). CustomCommand.targets Explicit NodeRestoreCommand override, independent of any tool. Retry.targets Shared retry-with-backoff helper for Rush's lock contention, also used by build delegation (see NodeBuild\RushDelegation.targets). @@ -46,7 +49,7 @@ configuration. An adapter registers itself purely by appending its own target name to these three semicolon-separated property lists (each empty by default, see below) - core here neither - knows nor needs to know any adapter's name. SelectNodeAdapter (Tasks/Node/SelectNodeAdapter.cs) + knows nor needs to know any adapter's name. SelectToolAdapter (Tasks/Node/SelectToolAdapter.cs) is the only place adapter names are compared, and it does so generically by item identity and Priority metadata, not by switching on known names. @@ -64,10 +67,13 @@ - + - + + + + - + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Bun.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Bun.targets new file mode 100644 index 0000000..c0aab8b --- /dev/null +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Bun.targets @@ -0,0 +1,58 @@ + + + $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreBunDetect + $(NodeRestoreAdapterConfigureDependsOn);_NodeRestoreBunResolve + $(NodeRestoreAdapterRunDependsOn);_NodeRestoreBunRun + + + + + <_NodeRestoreBunRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'bun.lockb')) + + + <_NodeRestoreAdapterCandidate Include="bun"> + 180 + $(_NodeRestoreBunRoot) + $(NodeRootFullPath) + $(MSBuildThisFileFullPath) + + + + + + + <_NodeRestoreHasLockfile Condition="Exists('$(_NodeRestoreWorkspaceRoot)/bun.lockb')">true + <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)' == ''">false + <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreIsCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true + <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)' == ''">false + + + + <_NodeRestorePackageJsonPath>$(NodeRootFullPath)/package.json + <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestoreWorkspaceRoot)/bun.lockb + <_NodeRestoreIncrementalInputs>$(_NodeRestorePackageJsonPath) + <_NodeRestoreIncrementalInputs Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestorePackageJsonPath);$(_NodeRestoreLockfilePath) + <_NodeRestoreStampPath>$(_NodeRestoreWorkspaceRoot)/node_modules/.node-restore.stamp + + + + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' != 'true'">bun install + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' == 'true'">bun install --frozen-lockfile + + + + + + + + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/CustomCommand.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/CustomCommand.targets index 7585364..1f3bfed 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/CustomCommand.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/CustomCommand.targets @@ -3,7 +3,7 @@ here - an arbitrary user-supplied command isn't well-defined enough to key an up-to-date check on, so this always runs, mirroring plain "npm install" behavior. --> - - $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreDetectPackageManagers - $(NodeRestoreAdapterConfigureDependsOn);_NodeRestoreResolveGeneric - $(NodeRestoreAdapterRunDependsOn);_NodeRestoreRunGeneric - - - - - <_NodeRestorePnpmRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'pnpm-lock.yaml')) - <_NodeRestoreYarnRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'yarn.lock')) - <_NodeRestoreBunRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'bun.lockb')) - <_NodeRestoreNpmRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'package-lock.json')) - - - - <_NodeRestoreAdapterCandidate Include="pnpm" Condition="'$(_NodeRestorePnpmRoot)' != '' or '$(NodePackageManager)' == 'pnpm'"> - 200 - $(_NodeRestorePnpmRoot) - $(NodeRootFullPath) - $(MSBuildThisFileFullPath) - - <_NodeRestoreAdapterCandidate Include="yarn" Condition="'$(_NodeRestoreYarnRoot)' != '' or '$(NodePackageManager)' == 'yarn'"> - 190 - $(_NodeRestoreYarnRoot) - $(NodeRootFullPath) - $(MSBuildThisFileFullPath) - - <_NodeRestoreAdapterCandidate Include="bun" Condition="'$(_NodeRestoreBunRoot)' != '' or '$(NodePackageManager)' == 'bun'"> - 180 - $(_NodeRestoreBunRoot) - $(NodeRootFullPath) - $(MSBuildThisFileFullPath) - - <_NodeRestoreAdapterCandidate Include="npm"> - 0 - $(_NodeRestoreNpmRoot) - $(NodeRootFullPath) - $(MSBuildThisFileFullPath) - - - - - - - - - - <_NodeRestoreYarnBerry Condition="'$(_NodeRestoreResolvedTool)'=='yarn' and Exists('$(_NodeRestoreWorkspaceRoot)/.yarnrc.yml')">true - <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreResolvedTool)'=='pnpm' and Exists('$(_NodeRestoreWorkspaceRoot)/pnpm-lock.yaml')">true - <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)'=='' and '$(_NodeRestoreResolvedTool)'=='yarn' and Exists('$(_NodeRestoreWorkspaceRoot)/yarn.lock')">true - <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)'=='' and '$(_NodeRestoreResolvedTool)'=='bun' and Exists('$(_NodeRestoreWorkspaceRoot)/bun.lockb')">true - <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)'=='' and '$(_NodeRestoreResolvedTool)'=='npm' and Exists('$(_NodeRestoreWorkspaceRoot)/package-lock.json')">true - <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)'==''">false - - <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreIsCI)'=='true' and '$(_NodeRestoreHasLockfile)'=='true'">true - <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)'==''">false - - - - - <_NodeRestorePackageJsonPath>$(NodeRootFullPath)/package.json - <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreResolvedTool)'=='pnpm' and '$(_NodeRestoreHasLockfile)'=='true'">$(_NodeRestoreWorkspaceRoot)/pnpm-lock.yaml - <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreLockfilePath)'=='' and '$(_NodeRestoreResolvedTool)'=='yarn' and '$(_NodeRestoreHasLockfile)'=='true'">$(_NodeRestoreWorkspaceRoot)/yarn.lock - <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreLockfilePath)'=='' and '$(_NodeRestoreResolvedTool)'=='bun' and '$(_NodeRestoreHasLockfile)'=='true'">$(_NodeRestoreWorkspaceRoot)/bun.lockb - <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreLockfilePath)'=='' and '$(_NodeRestoreResolvedTool)'=='npm' and '$(_NodeRestoreHasLockfile)'=='true'">$(_NodeRestoreWorkspaceRoot)/package-lock.json - <_NodeRestoreIncrementalInputs>$(_NodeRestorePackageJsonPath) - <_NodeRestoreIncrementalInputs Condition="'$(_NodeRestoreHasLockfile)'=='true'">$(_NodeRestorePackageJsonPath);$(_NodeRestoreLockfilePath) - <_NodeRestoreStampPath>$(_NodeRestoreWorkspaceRoot)/node_modules/.node-restore.stamp - <_NodeRestoreStampPath Condition="'$(_NodeRestoreYarnBerry)'=='true'">$(_NodeRestoreWorkspaceRoot)/.node-restore.stamp - - - - - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedTool)'=='pnpm' and '$(_NodeRestoreUseFrozen)'!='true'">pnpm install - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedTool)'=='pnpm' and '$(_NodeRestoreUseFrozen)'=='true'">pnpm install --frozen-lockfile - - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedTool)'=='yarn' and '$(_NodeRestoreUseFrozen)'!='true'">yarn install - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedTool)'=='yarn' and '$(_NodeRestoreUseFrozen)'=='true' and '$(_NodeRestoreYarnBerry)'=='true'">yarn install --immutable - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedTool)'=='yarn' and '$(_NodeRestoreUseFrozen)'=='true' and '$(_NodeRestoreYarnBerry)'!='true'">yarn install --frozen-lockfile - - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedTool)'=='bun' and '$(_NodeRestoreUseFrozen)'!='true'">bun install - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedTool)'=='bun' and '$(_NodeRestoreUseFrozen)'=='true'">bun install --frozen-lockfile - - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedTool)'=='npm' and '$(_NodeRestoreUseFrozen)'!='true'">npm install - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedTool)'=='npm' and '$(_NodeRestoreUseFrozen)'=='true'">npm ci - - - - - - - - - - - - - diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Npm.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Npm.targets new file mode 100644 index 0000000..5f549af --- /dev/null +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Npm.targets @@ -0,0 +1,62 @@ + + + $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreNpmDetect + $(NodeRestoreAdapterConfigureDependsOn);_NodeRestoreNpmResolve + $(NodeRestoreAdapterRunDependsOn);_NodeRestoreNpmRun + + + + + <_NodeRestoreNpmRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'package-lock.json')) + + + + <_NodeRestoreAdapterCandidate Include="npm"> + 0 + $(_NodeRestoreNpmRoot) + $(NodeRootFullPath) + $(MSBuildThisFileFullPath) + + + + + + + <_NodeRestoreHasLockfile Condition="Exists('$(_NodeRestoreWorkspaceRoot)/package-lock.json')">true + <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)' == ''">false + <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreIsCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true + <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)' == ''">false + + + + <_NodeRestorePackageJsonPath>$(NodeRootFullPath)/package.json + <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestoreWorkspaceRoot)/package-lock.json + <_NodeRestoreIncrementalInputs>$(_NodeRestorePackageJsonPath) + <_NodeRestoreIncrementalInputs Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestorePackageJsonPath);$(_NodeRestoreLockfilePath) + <_NodeRestoreStampPath>$(_NodeRestoreWorkspaceRoot)/node_modules/.node-restore.stamp + + + + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' != 'true'">npm install + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' == 'true'">npm ci + + + + + + + + + + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Pnpm.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Pnpm.targets new file mode 100644 index 0000000..9ba87e2 --- /dev/null +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Pnpm.targets @@ -0,0 +1,58 @@ + + + $(NodeRestoreAdapterDetectDependsOn);_NodeRestorePnpmDetect + $(NodeRestoreAdapterConfigureDependsOn);_NodeRestorePnpmResolve + $(NodeRestoreAdapterRunDependsOn);_NodeRestorePnpmRun + + + + + <_NodeRestorePnpmRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'pnpm-lock.yaml')) + + + <_NodeRestoreAdapterCandidate Include="pnpm"> + 200 + $(_NodeRestorePnpmRoot) + $(NodeRootFullPath) + $(MSBuildThisFileFullPath) + + + + + + + <_NodeRestoreHasLockfile Condition="Exists('$(_NodeRestoreWorkspaceRoot)/pnpm-lock.yaml')">true + <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)' == ''">false + <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreIsCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true + <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)' == ''">false + + + + <_NodeRestorePackageJsonPath>$(NodeRootFullPath)/package.json + <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestoreWorkspaceRoot)/pnpm-lock.yaml + <_NodeRestoreIncrementalInputs>$(_NodeRestorePackageJsonPath) + <_NodeRestoreIncrementalInputs Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestorePackageJsonPath);$(_NodeRestoreLockfilePath) + <_NodeRestoreStampPath>$(_NodeRestoreWorkspaceRoot)/node_modules/.node-restore.stamp + + + + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' != 'true'">pnpm install + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' == 'true'">pnpm install --frozen-lockfile + + + + + + + + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Retry.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Retry.targets index 97c892b..a362c11 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Retry.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Retry.targets @@ -19,14 +19,14 @@ the first invocation does the real work, every other invocation gets a fast no-op. --> - <_NodeRestoreRushLockMessage>Another Rush command is already running in this repository + <_NodeExecRushLockMessage>Another Rush command is already running in this repository - - + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets index 5f74215..6400efc 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets @@ -1,11 +1,11 @@ - $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreDetectRush - $(NodeRestoreAdapterConfigureDependsOn);_NodeRestoreResolveRush - $(NodeRestoreAdapterRunDependsOn);_NodeRestoreRunRush + $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreRushDetect + $(NodeRestoreAdapterConfigureDependsOn);_NodeRestoreRushResolve + $(NodeRestoreAdapterRunDependsOn);_NodeRestoreRushRun - + <_NodeRestoreRushRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'rush.json')) @@ -21,10 +21,10 @@ - false - - <_NodeRestoreRetryWorkingDirectory>$(_NodeRestoreWorkspaceRoot) - <_NodeRestoreRetryWorkingDirectory Condition="'$(_NodeRestoreRushProjectSelected)' == 'true'">$(NodeRootFullPath) + <_NodeExecRetryWorkingDirectory>$(_NodeRestoreWorkspaceRoot) + <_NodeExecRetryWorkingDirectory Condition="'$(_NodeRestoreRushProjectSelected)' == 'true'">$(NodeRootFullPath) + Properties="_NodeExecRetryCommand=$(_NodeExecRetryCommand);_NodeExecRetryWorkingDirectory=$(_NodeExecRetryWorkingDirectory);_NodeExecRetryMutexName=$(_NodeRestoreRushMutexName)" /> diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Detection.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Selection.targets similarity index 92% rename from src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Detection.targets rename to src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Selection.targets index d04ace7..54f161c 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Detection.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Selection.targets @@ -24,15 +24,15 @@ <_NodeRestoreIsCI Condition="'$(_NodeRestoreIsCI)'==''">false - - - + @@ -43,7 +43,7 @@ diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Yarn.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Yarn.targets new file mode 100644 index 0000000..b99a297 --- /dev/null +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Yarn.targets @@ -0,0 +1,63 @@ + + + $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreYarnDetect + $(NodeRestoreAdapterConfigureDependsOn);_NodeRestoreYarnResolve + $(NodeRestoreAdapterRunDependsOn);_NodeRestoreYarnRun + + + + + <_NodeRestoreYarnRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'yarn.lock')) + + + <_NodeRestoreAdapterCandidate Include="yarn"> + 190 + $(_NodeRestoreYarnRoot) + $(NodeRootFullPath) + $(MSBuildThisFileFullPath) + + + + + + + <_NodeRestoreYarnBerry Condition="Exists('$(_NodeRestoreWorkspaceRoot)/.yarnrc.yml')">true + <_NodeRestoreHasLockfile Condition="Exists('$(_NodeRestoreWorkspaceRoot)/yarn.lock')">true + <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)' == ''">false + <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreIsCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true + <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)' == ''">false + + + + <_NodeRestorePackageJsonPath>$(NodeRootFullPath)/package.json + <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestoreWorkspaceRoot)/yarn.lock + <_NodeRestoreIncrementalInputs>$(_NodeRestorePackageJsonPath) + <_NodeRestoreIncrementalInputs Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestorePackageJsonPath);$(_NodeRestoreLockfilePath) + + <_NodeRestoreStampPath Condition="'$(_NodeRestoreYarnBerry)' == 'true'">$(_NodeRestoreWorkspaceRoot)/.node-restore.stamp + <_NodeRestoreStampPath Condition="'$(_NodeRestoreYarnBerry)' != 'true'">$(_NodeRestoreWorkspaceRoot)/node_modules/.node-restore.stamp + + + + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' != 'true'">yarn install + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' == 'true' and '$(_NodeRestoreYarnBerry)' == 'true'">yarn install --immutable + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' == 'true' and '$(_NodeRestoreYarnBerry)' != 'true'">yarn install --frozen-lockfile + + + + + + + + + + From 90d212d12a14c82ccbefc0fddea69c6d8f167e99 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Fri, 7 Aug 2026 17:35:55 +0200 Subject: [PATCH 15/20] Simplify adapter pipeline and de-duplicate CI detection - Extract CI detection into Props/CIDetection.props (single source of truth) - Remove NodeRestoreAdapterConfigureDependsOn and RunDependsOn extension points - Remove _NodeRestoreAdapterConfigurePhase and RunPhase aggregator targets - Tool resolve/run targets use AfterTargets="_NodeRestoreSelect" (standard MSBuild) - Built-in and external NuGet adapters now follow the same pattern - Fix Bun lockfile detection: support both bun.lock (v1.2+) and bun.lockb (legacy) - Fix Yarn Berry CI: just 'yarn install' (Berry auto-detects CI, no --immutable needed) - NodeRestore entry point simplifies to DependsOnTargets="_NodeRestoreSelect" Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/MSBuildConventions.md | 15 ++-- docs/NodeDependencies.md | 19 +++-- .../msbuild/tasks/Props/CIDetection.props | 15 ++++ .../msbuild/tasks/Targets/NodeRestore.targets | 80 ++++++------------- .../tasks/Targets/NodeRestore/Bun.targets | 16 ++-- .../Targets/NodeRestore/CustomCommand.targets | 2 +- .../tasks/Targets/NodeRestore/Npm.targets | 10 +-- .../tasks/Targets/NodeRestore/Pnpm.targets | 7 +- .../tasks/Targets/NodeRestore/Rush.targets | 7 +- .../Targets/NodeRestore/Selection.targets | 30 ++----- .../tasks/Targets/NodeRestore/Yarn.targets | 15 ++-- 11 files changed, 91 insertions(+), 125 deletions(-) create mode 100644 src/Dataverse/Tasks/msbuild/tasks/Props/CIDetection.props diff --git a/docs/MSBuildConventions.md b/docs/MSBuildConventions.md index 38aaf15..dd5fba7 100644 --- a/docs/MSBuildConventions.md +++ b/docs/MSBuildConventions.md @@ -72,13 +72,14 @@ A contributor reading an MSBuild log should locate the source file from the targ Every package-manager adapter follows the same three-phase contract: -1. **Detect** — register `_NodeRestoreAdapterCandidate` items (name, priority, workspace root) -2. **Resolve** — compute `_NodeRestoreResolvedCommand` for the selected tool -3. **Run** — execute the command (stamp-gated for incrementality) - -An adapter registers itself by appending target names to the extension-point properties. -No core file needs to be modified. External adapters (shipped as NuGet packages) follow -the same pattern. +1. **Detect** — register `_NodeRestoreAdapterCandidate` items via `NodeRestoreAdapterDetectDependsOn` +2. **Resolve** — compute `_NodeRestoreResolvedCommand` via `AfterTargets="_NodeRestoreSelect"` + tool condition +3. **Run** — execute the command via `AfterTargets="_NodeRestoreSelect"` + tool condition (stamp-gated) + +An adapter registers detection by appending to `NodeRestoreAdapterDetectDependsOn`. +Resolve and run targets hook in via standard MSBuild `AfterTargets` — no other +registration needed. External adapters (shipped as NuGet packages) follow the exact +same pattern as built-in ones. ## Adding a new ecosystem diff --git a/docs/NodeDependencies.md b/docs/NodeDependencies.md index 27da4dd..93957a8 100644 --- a/docs/NodeDependencies.md +++ b/docs/NodeDependencies.md @@ -180,18 +180,17 @@ a library to track. | `NodeRootPath` | `.` | Relative path to the Node project root (where `package.json` lives), resolved against the project directory. All Node-based project types (Pcf, ScriptLibrary, CodeApp) use this for detection and build operations. | | `IsRunningInCI` | _(auto)_ | Reused as-is from [Versioning.md](Versioning.md) - leave empty to auto-detect CI from environment variables, or set `true`/`false` to override. Selects the frozen/reproducible install variant below. | -## Adapter extension contract +An adapter registers with one property and hooks lifecycle via standard MSBuild: -An adapter appends targets to these MSBuild properties: +- **`NodeRestoreAdapterDetectDependsOn`** — append a detection target that populates + `_NodeRestoreAdapterCandidate` items with `Priority`, `WorkspaceRoot`, and `Source` metadata. +- **`AfterTargets="_NodeRestoreSelect"`** — resolve and run targets use this to hook into the + lifecycle after selection. Each target must gate itself on `_NodeRestoreResolvedTool` matching + its adapter name. -- `NodeRestoreAdapterDetectDependsOn` registers one or more `_NodeRestoreAdapterCandidate` items with a unique - name plus `Priority`, `WorkspaceRoot`, and `Source` metadata. -- `NodeRestoreAdapterConfigureDependsOn` produces `_NodeRestoreResolvedCommand` and adapter-specific state when - its candidate is selected. -- `NodeRestoreAdapterRunDependsOn` executes the selected adapter's restore request. - -Configure and run targets must be gated on `_NodeRestoreResolvedTool` matching their adapter name. Selection is -deterministic: duplicate names and equal highest priorities fail with the contributing sources. An explicit +Selection is deterministic: duplicate names and equal highest priorities fail with contributing +sources. An explicit `NodeRestoreCommand` wins — tool resolve/run targets self-gate on +`'$(NodeRestoreCommand)' == ''`. `NodeRestoreCommand` skips every adapter configure/run target and runs only the supplied command. ## Frozen (CI-safe) installs diff --git a/src/Dataverse/Tasks/msbuild/tasks/Props/CIDetection.props b/src/Dataverse/Tasks/msbuild/tasks/Props/CIDetection.props new file mode 100644 index 0000000..317bffd --- /dev/null +++ b/src/Dataverse/Tasks/msbuild/tasks/Props/CIDetection.props @@ -0,0 +1,15 @@ + + + + true + false + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets index d097698..3e2ed0d 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets @@ -6,67 +6,42 @@ files) - no bundled Node script, no extra process, no library dependency to track for updates. - Consumers call this via from their own anchor target - (mirrors how GenerateVersionNumber/ApplyPcfVersionNumber etc. are invoked elsewhere in this - repo). The Node project directory is resolved from $(NodeRootFullPath) (defined in + Consumers call this via from their own anchor target. + The Node project directory is resolved from $(NodeRootFullPath) (defined in ProjectPaths.props, relative path set via NodeRootPath, default: project directory). Properties: NodeRootPath Relative path to Node project root. Default: "." (project dir). NodePackageManager '' (auto, default) | None | npm | pnpm | yarn | bun | rush. None = dependencies hydrated externally; do nothing. - NodeRestoreCommand Raw command override. Wins over everything else below. + NodeRestoreCommand Raw command override. Wins over everything else. Implementation is split across NodeRestore\*.targets by concern: - Selection.targets Runs adapter detection, then SelectToolAdapter picks a winner - shared - by every branch below. - Rush.targets Rush-specific install/update (Rush is a monorepo orchestrator that - delegates its own dependency install to pnpm, not a package manager - in its own right). - Npm.targets npm install/ci (always-present fallback adapter). + Selection.targets Runs adapter detection, then SelectToolAdapter picks a winner. + Rush.targets Rush-specific install/update orchestrator. + Npm.targets npm install/ci (always-present fallback). Pnpm.targets pnpm install (with frozen-lockfile in CI). - Yarn.targets yarn install (Classic and Berry/PnP, with frozen-lockfile in CI). + Yarn.targets yarn install (Classic and Berry/PnP). Bun.targets bun install (with frozen-lockfile in CI). - CustomCommand.targets Explicit NodeRestoreCommand override, independent of any tool. - Retry.targets Shared retry-with-backoff helper for Rush's lock contention, also - used by build delegation (see NodeBuild\RushDelegation.targets). + CustomCommand.targets Explicit NodeRestoreCommand override. + Retry.targets Shared retry-with-backoff helper for Rush's lock contention. - Adapter extension contract (no SDK core edits required to add a tool, built-in or external via - a NuGet package that imports its own *.targets alongside this one): - NodeRestoreAdapterDetectDependsOn Targets that populate the @(_NodeRestoreAdapterCandidate) - item (ItemSpec=adapter name; Priority, WorkspaceRoot, - Source metadata) for every tool the adapter recognizes - in this project. Runs before selection. - NodeRestoreAdapterConfigureDependsOn Targets that compute _NodeRestoreResolvedCommand (and - any other tool-specific state) for the SELECTED - adapter. Each such target must gate itself on - '$(_NodeRestoreResolvedTool)' == '', since every - registered adapter's configure target runs on every - build - only the matching one should do real work. - Runs after selection, before any run target. - NodeRestoreAdapterRunDependsOn Targets that actually invoke the install command for - the selected adapter, gated the same way. Runs after - configuration. - An adapter registers itself purely by appending its own target name to these three - semicolon-separated property lists (each empty by default, see below) - core here neither - knows nor needs to know any adapter's name. SelectToolAdapter (Tasks/Node/SelectToolAdapter.cs) - is the only place adapter names are compared, and it does so generically by item identity - and Priority metadata, not by switching on known names. + Extension contract (no core edits required to add a tool): + NodeRestoreAdapterDetectDependsOn Targets that populate @(_NodeRestoreAdapterCandidate) + items (ItemSpec=adapter name; Priority, WorkspaceRoot, + Source metadata). Runs before selection. - When NodeRestoreCommand is set, core skips all adapter configure/run targets and executes only - the custom command. Detection and selection still run. + After selection sets $(_NodeRestoreResolvedTool), tool-specific resolve/run targets fire + via AfterTargets="_NodeRestoreSelect", each gated on its own tool name. External adapters + shipped as NuGet packages follow the same pattern — no additional registration needed. --> - - - - - + + + - @@ -76,18 +51,9 @@ - - - - - - - + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Bun.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Bun.targets index c0aab8b..acd2c6e 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Bun.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Bun.targets @@ -1,13 +1,13 @@ $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreBunDetect - $(NodeRestoreAdapterConfigureDependsOn);_NodeRestoreBunResolve - $(NodeRestoreAdapterRunDependsOn);_NodeRestoreBunRun - <_NodeRestoreBunRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'bun.lockb')) + + <_NodeRestoreBunRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'bun.lock')) + <_NodeRestoreBunRoot Condition="'$(_NodeRestoreBunRoot)' == ''">$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'bun.lockb')) <_NodeRestoreAdapterCandidate Include="bun"> @@ -20,18 +20,19 @@ - <_NodeRestoreHasLockfile Condition="Exists('$(_NodeRestoreWorkspaceRoot)/bun.lockb')">true + <_NodeRestoreHasLockfile Condition="Exists('$(_NodeRestoreWorkspaceRoot)/bun.lock') or Exists('$(_NodeRestoreWorkspaceRoot)/bun.lockb')">true <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)' == ''">false - <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreIsCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true + <_NodeRestoreUseFrozen Condition="'$(IsRunningInCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)' == ''">false <_NodeRestorePackageJsonPath>$(NodeRootFullPath)/package.json - <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestoreWorkspaceRoot)/bun.lockb + <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreHasLockfile)' == 'true' and Exists('$(_NodeRestoreWorkspaceRoot)/bun.lock')">$(_NodeRestoreWorkspaceRoot)/bun.lock + <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreLockfilePath)' == '' and '$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestoreWorkspaceRoot)/bun.lockb <_NodeRestoreIncrementalInputs>$(_NodeRestorePackageJsonPath) <_NodeRestoreIncrementalInputs Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestorePackageJsonPath);$(_NodeRestoreLockfilePath) <_NodeRestoreStampPath>$(_NodeRestoreWorkspaceRoot)/node_modules/.node-restore.stamp @@ -44,6 +45,7 @@ $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreNpmDetect - $(NodeRestoreAdapterConfigureDependsOn);_NodeRestoreNpmResolve - $(NodeRestoreAdapterRunDependsOn);_NodeRestoreNpmRun @@ -21,12 +19,13 @@ <_NodeRestoreHasLockfile Condition="Exists('$(_NodeRestoreWorkspaceRoot)/package-lock.json')">true <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)' == ''">false - <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreIsCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true + <_NodeRestoreUseFrozen Condition="'$(IsRunningInCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)' == ''">false @@ -43,11 +42,12 @@ <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' == 'true'">npm ci - $(NodeRestoreAdapterDetectDependsOn);_NodeRestorePnpmDetect - $(NodeRestoreAdapterConfigureDependsOn);_NodeRestorePnpmResolve - $(NodeRestoreAdapterRunDependsOn);_NodeRestorePnpmRun @@ -20,12 +18,12 @@ <_NodeRestoreHasLockfile Condition="Exists('$(_NodeRestoreWorkspaceRoot)/pnpm-lock.yaml')">true <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)' == ''">false - <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreIsCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true + <_NodeRestoreUseFrozen Condition="'$(IsRunningInCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)' == ''">false @@ -44,6 +42,7 @@ $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreRushDetect - $(NodeRestoreAdapterConfigureDependsOn);_NodeRestoreRushResolve - $(NodeRestoreAdapterRunDependsOn);_NodeRestoreRushRun @@ -24,7 +22,7 @@ registration and the install/update command - kept separate from the per-tool adapters, which handles package managers proper (npm/pnpm/yarn/bun). --> <_NodeRestoreHasLockfile>true - <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreIsCI)'=='true'">true + <_NodeRestoreUseFrozen Condition="'$(IsRunningInCI)'=='true'">true <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)'==''">false diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Selection.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Selection.targets index 54f161c..513de4b 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Selection.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Selection.targets @@ -1,28 +1,13 @@ - - <_NodeRestoreIsCI Condition="'$(IsRunningInCI)'!=''">$(IsRunningInCI) - <_NodeRestoreIsCI Condition="'$(_NodeRestoreIsCI)'=='' and ( - '$(CI)'=='true' or - '$(TF_BUILD)'=='True' or '$(TF_BUILD)'=='true' or - '$(GITHUB_ACTIONS)'=='true' or - '$(GITLAB_CI)'=='true' or - '$(CIRCLECI)'=='true' or - '$(JENKINS_URL)'!='' or - '$(TEAMCITY_VERSION)'!='')">true - <_NodeRestoreIsCI Condition="'$(_NodeRestoreIsCI)'==''">false - + targets compute, regardless of which one runs. --> <_NodeRestoreResolvedCommand>$(NodeRestoreCommand) - diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Yarn.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Yarn.targets index b99a297..d0f0cce 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Yarn.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Yarn.targets @@ -1,8 +1,6 @@ $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreYarnDetect - $(NodeRestoreAdapterConfigureDependsOn);_NodeRestoreYarnResolve - $(NodeRestoreAdapterRunDependsOn);_NodeRestoreYarnRun @@ -20,13 +18,13 @@ <_NodeRestoreYarnBerry Condition="Exists('$(_NodeRestoreWorkspaceRoot)/.yarnrc.yml')">true <_NodeRestoreHasLockfile Condition="Exists('$(_NodeRestoreWorkspaceRoot)/yarn.lock')">true <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)' == ''">false - <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreIsCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true + <_NodeRestoreUseFrozen Condition="'$(IsRunningInCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)' == ''">false @@ -41,13 +39,16 @@ - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' != 'true'">yarn install - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' == 'true' and '$(_NodeRestoreYarnBerry)' == 'true'">yarn install --immutable - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' == 'true' and '$(_NodeRestoreYarnBerry)' != 'true'">yarn install --frozen-lockfile + + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreYarnBerry)' == 'true'">yarn install + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreYarnBerry)' != 'true' and '$(_NodeRestoreUseFrozen)' != 'true'">yarn install + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreYarnBerry)' != 'true' and '$(_NodeRestoreUseFrozen)' == 'true'">yarn install --frozen-lockfile Date: Mon, 10 Aug 2026 14:41:36 +0200 Subject: [PATCH 16/20] Refactor Node toolchain role resolution Separate package-manager and orchestrator detection from restore and build lifecycles, remove the transitional adapter architecture, and centralize CI detection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- docs/BuildProcess.md | 14 +- docs/MSBuildConventions.md | 147 ++++++++--------- docs/NodeDependencies.md | 86 ++++------ src/Dataverse/CodeApp/README.md | 10 +- ...XIS.DevKit.Build.Dataverse.CodeApp.targets | 9 +- .../TALXIS.DevKit.Build.Dataverse.Pcf.targets | 12 +- src/Dataverse/ScriptLibrary/README.md | 8 +- ...vKit.Build.Dataverse.ScriptLibrary.targets | 17 +- .../Tasks/Tasks/GenerateGitVersion.cs | 60 +------ .../Tasks/Tasks/Node/ResolveNodeToolchain.cs | 148 ++++++++++++++++++ .../Tasks/Tasks/Node/ResolveRushProject.cs | 23 ++- .../Tasks/Tasks/Node/SelectToolAdapter.cs | 110 ------------- .../msbuild/tasks/Props/CIDetection.props | 3 +- .../msbuild/tasks/Props/ProjectPaths.props | 3 + ...ALXIS.DevKit.Build.Dataverse.Tasks.targets | 2 +- .../msbuild/tasks/Targets/NodeBuild.targets | 61 ++------ .../tasks/Targets/NodeBuild/Direct.targets | 16 +- .../{RushDelegation.targets => Rush.targets} | 16 +- .../msbuild/tasks/Targets/NodeRestore.targets | 56 ++----- .../tasks/Targets/NodeRestore/Bun.targets | 49 ++---- .../Targets/NodeRestore/CustomCommand.targets | 6 +- .../tasks/Targets/NodeRestore/Npm.targets | 51 ++---- .../tasks/Targets/NodeRestore/Pnpm.targets | 45 ++---- .../tasks/Targets/NodeRestore/Retry.targets | 17 +- .../tasks/Targets/NodeRestore/Rush.targets | 145 ++++------------- .../Targets/NodeRestore/Selection.targets | 35 ----- .../tasks/Targets/NodeRestore/Yarn.targets | 54 ++----- .../tasks/Targets/NodeToolchain.targets | 35 +++++ .../tasks/Targets/NodeToolchain/Bun.targets | 22 +++ .../tasks/Targets/NodeToolchain/Npm.targets | 20 +++ .../tasks/Targets/NodeToolchain/Pnpm.targets | 19 +++ .../tasks/Targets/NodeToolchain/Rush.targets | 48 ++++++ .../tasks/Targets/NodeToolchain/Yarn.targets | 19 +++ src/Sdk/Sdk/Sdk.NodeRestore.targets | 18 +-- 35 files changed, 586 insertions(+), 800 deletions(-) create mode 100644 src/Dataverse/Tasks/Tasks/Node/ResolveNodeToolchain.cs delete mode 100644 src/Dataverse/Tasks/Tasks/Node/SelectToolAdapter.cs rename src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/{RushDelegation.targets => Rush.targets} (93%) delete mode 100644 src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Selection.targets create mode 100644 src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain.targets create mode 100644 src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Bun.targets create mode 100644 src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Npm.targets create mode 100644 src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Pnpm.targets create mode 100644 src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Rush.targets create mode 100644 src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Yarn.targets diff --git a/README.md b/README.md index f194851..b44ca0f 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Generates version numbers based on Git commit history, applying these versions a Project dependency folders are analyzed for Git changes to be reflected in generated version numbers. See [here](/docs/Versioning.md) for more details. #### Node Dependency Restore -Pcf/ScriptLibrary/CodeApp projects auto-detect and run the right Node package manager (npm, pnpm, Yarn, Bun, or Rush) instead of a hardcoded `npm install`. See [here](/docs/NodeDependencies.md) for more details. +Pcf/ScriptLibrary/CodeApp projects auto-detect the Node package manager (npm, pnpm, Yarn, or Bun) and optional Rush orchestrator instead of a hardcoded `npm install`. See [here](/docs/NodeDependencies.md) for more details. ## Packages diff --git a/docs/BuildProcess.md b/docs/BuildProcess.md index 04f334a..5b44fb5 100644 --- a/docs/BuildProcess.md +++ b/docs/BuildProcess.md @@ -210,8 +210,8 @@ Like the Plugin package, it replaces ILRepack's default auto-hook with a no-op t Main hooks: - imports `Microsoft.PowerApps.VisualStudio.Pcf.props` / `.targets` -- `_PcfNodeRestore` runs `AfterTargets="CollectPackageReferences"` (not `BeforeTargets="BeforeBuild"` - this is what makes a bare `dotnet restore` at the repo/solution root hydrate Node deps too, see [NodeDependencies.md](NodeDependencies.md#verb-parity)) and calls the shared `NodeRestore` target -- `PcfBuild` is overridden (Rush-resolved projects only) to delegate the actual build to Rush's own `build` command instead of Microsoft's own `npm run build` ``, forwarding the build mode as a `--build-mode` Rush custom command-line parameter (reusing Microsoft's own `$(PcfBuildMode)` Debug/Release mapping) - see [NodeDependencies.md](NodeDependencies.md#pcf-specific-forwarding-the-build-mode-as---build-mode) +- the SDK-level `_NodeRestoreAnchor` runs after `CollectPackageReferences`, so a bare repository/solution `dotnet restore` hydrates Node dependencies; `PcfBuild` also depends on `NodeRestore` as a cold-cache safety net +- `PcfBuild` is overridden to invoke the shared `NodeBuild` target; Rush-owned projects delegate to Rush, otherwise the selected package manager runs the build script, forwarding the build mode as a `--build-mode` Rush custom command-line parameter (reusing Microsoft's own `$(PcfBuildMode)` Debug/Release mapping) - see [NodeDependencies.md](NodeDependencies.md#pcf-specific-forwarding-the-build-mode-as---build-mode) - `_ApplyPcfVersionAfterBuild` runs `AfterTargets="PcfBuild"` (after `ControlManifest.xml` actually exists) and applies Git-based versioning - `_EnsurePcfStubAssembly` runs before `Publish` / `GetCopyToPublishDirectoryItems` and creates a stub DLL if needed - `PcfCopyToPublish` runs `AfterTargets="Publish"` and copies PCF output into `out\controls\publish` @@ -227,13 +227,13 @@ Because `ProjectType=Pcf` is built on `Microsoft.NET.Sdk`, it also sets `EnableD Main hooks: -- `BuildTypeScript` (`BeforeTargets="Build"` - delegates to Rush's own `build` command when Rush is resolved, otherwise `npm run build` directly, unchanged) +- `BuildTypeScript` (`BeforeTargets="Build"` - invokes shared `NodeBuild`: Rush when it owns build, otherwise the selected package manager) - `CleanScriptLibrary` (`AfterTargets="Clean"`, removes the TypeScript output folder only - never `node_modules`) - `CopyScriptLibraryMainToOutput` (`AfterTargets="Build"`) - `GetScriptLibraryOutputs` - `GetSuppressedScriptLibraryReferences` -The package expects sources under `$(NodeRootFullPath)` (default: project directory itself), hydrates dependencies via the shared `NodeRestore` target (see [NodeDependencies.md](NodeDependencies.md)), builds via Rush delegation or `npm run build` (see [NodeDependencies.md](NodeDependencies.md#build-delegation-to-rush)), copies the selected main JS file to `$(TargetDir)`, and lets Solution builds query which referenced script libraries are `CompileOnly` and therefore should not be deployed as separate web resources. Standalone `npm` packaging of a ScriptLibrary is planned but not yet implemented, so it does not currently set `IsPackable=false`. +The package expects sources under `$(NodeRootFullPath)` (default: project directory itself), hydrates dependencies via the shared `NodeRestore` target (see [NodeDependencies.md](NodeDependencies.md)), builds through the selected orchestrator or package manager (see [NodeDependencies.md](NodeDependencies.md#build-delegation-to-rush)), copies the selected main JS file to `$(TargetDir)`, and lets Solution builds query which referenced script libraries are `CompileOnly` and therefore should not be deployed as separate web resources. Standalone `npm` packaging of a ScriptLibrary is planned but not yet implemented, so it does not currently set `IsPackable=false`. ### CodeApp @@ -242,14 +242,14 @@ The package expects sources under `$(NodeRootFullPath)` (default: project direct Main hooks: - `CheckCodeAppPrereqs` (Node.js presence only - package manager presence is left to `NodeRestore`) -- `_CodeAppNodeRestore` (`AfterTargets="CollectPackageReferences"`, calls the shared `NodeRestore` target - fires on solution/repo-root `dotnet restore` too, see [NodeDependencies.md](NodeDependencies.md#verb-parity)) -- `BuildCodeApp` (`BeforeTargets="Build"` - delegates to Rush's own `build` command when Rush is resolved, otherwise `npm run build` directly, unchanged) +- the SDK-level `_NodeRestoreAnchor` handles bare restore, while `BuildCodeApp` also depends on `NodeRestore` as a cold-cache safety net +- `BuildCodeApp` (`BeforeTargets="Build"` - invokes shared `NodeBuild`: Rush when it owns build, otherwise the selected package manager) - `CleanCodeApp` (`AfterTargets="Clean"`, removes `dist` only - never `node_modules`) - `CopyCodeAppDist` (`AfterTargets="Build"`) - `GetCodeAppOutputs` - `CopyCodeAppDistPublish` (`AfterTargets="Publish"`) -The package hydrates dependencies via the shared `NodeRestore` target (see [NodeDependencies.md](NodeDependencies.md)), builds via Rush delegation or `npm run build` (see [NodeDependencies.md](NodeDependencies.md#build-delegation-to-rush)), expects output under `dist/`, copies it into `$(OutputPath)$(AppName)/` and `$(PublishDir)$(AppName)/`, and exposes the `dist` folder plus `power.config.json` to Solution packaging. CodeApp projects are not standalone components, so the package sets `IsPackable=false` and hooks `$(BeforePack)` with `_ErrorOnCodeAppPack`, which raises a hard error before any nuspec/nupkg work starts. +The package hydrates dependencies via the shared `NodeRestore` target (see [NodeDependencies.md](NodeDependencies.md)), builds through the selected orchestrator or package manager (see [NodeDependencies.md](NodeDependencies.md#build-delegation-to-rush)), expects output under `dist/`, copies it into `$(OutputPath)$(AppName)/` and `$(PublishDir)$(AppName)/`, and exposes the `dist` folder plus `power.config.json` to Solution packaging. CodeApp projects are not standalone components, so the package sets `IsPackable=false` and hooks `$(BeforePack)` with `_ErrorOnCodeAppPack`, which raises a hard error before any nuspec/nupkg work starts. ### GenPage diff --git a/docs/MSBuildConventions.md b/docs/MSBuildConventions.md index dd5fba7..f400c68 100644 --- a/docs/MSBuildConventions.md +++ b/docs/MSBuildConventions.md @@ -1,102 +1,85 @@ # MSBuild Conventions -This document describes the naming and structural conventions used throughout the -TALXIS DevKit Build SDK. Following these rules makes the codebase predictable for -anyone reading it — even without prior MSBuild or Power Platform experience. +This document describes naming, layout, and extension conventions used by the TALXIS DevKit Build SDK. ## Naming -### Targets - -| Kind | Pattern | Example | -|------|---------|---------| -| Public entry point | `` | `NodeRestore`, `PcfBuild` | -| Private implementation | `_` | `_NodeRestoreRushDetect`, `_NodeBuildDirect` | - -- **Module** matches the entry-point `.targets` file: `NodeRestore`, `NodeBuild`, `Pcf`, etc. -- **Submodule** identifies the tool or adapter: `Rush`, `Npm`, `Pnpm`, `Yarn`, `Bun`. -- **Verb** describes the action: `Detect`, `Resolve`, `Run`, `Select`, `Validate`. - -### Properties - | Kind | Pattern | Example | -|------|---------|---------| -| Public (consumer-facing) | `` | `NodePackageManager`, `NodeBuildConfiguration` | -| Private (internal state) | `_` | `_NodeRestoreRushScoped`, `_NodeRestoreHasLockfile` | -| Extension point | `AdapterDependsOn` | `NodeRestoreAdapterDetectDependsOn` | +|---|---|---| +| Public entry target | `` | `NodeToolchain`, `NodeRestore`, `NodeBuild` | +| Private implementation target | `_` | `_NodeToolchainPnpmDetect`, `_NodeRestoreRushRun` | +| Public property | `` | `NodePackageManager`, `NodeOrchestrator` | +| Private property/item | `_` | `_NodePackageManagerRootPath`, `_NodeToolchainRushTempRoot` | +| Extension dependency property | `DetectDependsOn` | `NodePackageManagerDetectDependsOn` | -### Underscore prefix rule +An underscore marks an implementation detail. Consumers may rely on public targets and properties, but must not call private targets or inspect private state. -`_` means "private implementation detail" — not for consumers to reference, hook into, -or depend on. No underscore means "public API" — stable for `DependsOnTargets`, -`BeforeTargets`, `AfterTargets` usage. This matches Microsoft's own MSBuild SDK convention. +C# task classes use `` and match their `UsingTask` name, for example `ResolveNodeToolchain`, `ResolveRushProject`, and `ExecWithRetry`. -### C# task classes +## Node file structure -Task class names equal their `UsingTask TaskName`. The name uses `` form: -`SelectToolAdapter`, `ResolveRushProject`, `ExecWithRetry`. - -## File structure - -``` +```text Targets/ - NodeRestore.targets → public target: NodeRestore (entry point) + NodeToolchain.targets package-manager/orchestrator selection + NodeToolchain/ + Npm.targets npm candidate detection + Pnpm.targets pnpm candidate detection + Yarn.targets Yarn candidate detection + Bun.targets Bun candidate detection + Rush.targets Rush detection and project topology + NodeRestore.targets public dependency-hydration entry point NodeRestore/ - Selection.targets → _NodeRestoreSelect, _NodeRestoreValidateResolvedCommand - Rush.targets → _NodeRestoreRushDetect, _NodeRestoreRushResolve, _NodeRestoreRushRun - Npm.targets → _NodeRestoreNpmDetect, _NodeRestoreNpmResolve, _NodeRestoreNpmRun - Pnpm.targets → _NodeRestorePnpmDetect, ... - Yarn.targets → _NodeRestoreYarnDetect, ... - Bun.targets → _NodeRestoreBunDetect, ... - CustomCommand.targets → _NodeRestoreRunCustom - Retry.targets → _NodeExecWithRetry (shared by restore + build) - NodeBuild.targets → public target entry (imports below) + Npm.targets npm command and incremental execution + Pnpm.targets pnpm command and incremental execution + Yarn.targets Yarn command and incremental execution + Bun.targets Bun command and incremental execution + Rush.targets Rush install/update, gate, bootstrap checks + CustomCommand.targets NodeRestoreCommand override + Retry.targets shared Rush mutex/retry target + NodeBuild.targets public Node build entry point NodeBuild/ - Direct.targets → _NodeBuildDirect - RushDelegation.targets → _NodeBuildDelegateToRush + Direct.targets build through the selected package manager + Rush.targets build through Rush -Tasks/ - Node/SelectToolAdapter.cs → generic priority-based adapter selection - Node/ResolveRushProject.cs → Rush workspace/subspace topology resolver - ExecWithRetry.cs → command execution with mutex, retry, cancellation +Tasks/Node/ + ResolveNodeToolchain.cs independent role selection + ResolveRushProject.cs Rush registration and subspace topology ``` -### Cross-referencing rule - -A contributor reading an MSBuild log should locate the source file from the target name: -1. **Module prefix** → folder (`_NodeRestore*` → `NodeRestore/`) -2. **Submodule** → file (`Rush` → `Rush.targets`) -3. **Verb** → which target inside that file - -## Adapter extension pattern - -Every package-manager adapter follows the same three-phase contract: - -1. **Detect** — register `_NodeRestoreAdapterCandidate` items via `NodeRestoreAdapterDetectDependsOn` -2. **Resolve** — compute `_NodeRestoreResolvedCommand` via `AfterTargets="_NodeRestoreSelect"` + tool condition -3. **Run** — execute the command via `AfterTargets="_NodeRestoreSelect"` + tool condition (stamp-gated) - -An adapter registers detection by appending to `NodeRestoreAdapterDetectDependsOn`. -Resolve and run targets hook in via standard MSBuild `AfterTargets` — no other -registration needed. External adapters (shipped as NuGet packages) follow the exact -same pattern as built-in ones. +## Node toolchain extension pattern + +Package managers and orchestrators are independent roles. A Rush repository can therefore resolve `pnpm` as its package manager and `rush` as its orchestrator. + +External NuGet packages extend detection by appending targets to: + +- `NodePackageManagerDetectDependsOn` +- `NodeOrchestratorDetectDependsOn` + +A detection target adds `_NodePackageManagerCandidate` or `_NodeOrchestratorCandidate` items. Each item uses its identity as the public value and supplies `Priority`, `RootPath`, and `Source` metadata. Orchestrators may additionally supply `OwnsRestore` and `OwnsBuild`. + +```xml + + + $(NodeOrchestratorDetectDependsOn);_ContosoDetect + + + + + <_NodeOrchestratorCandidate Include="contoso"> + 250 + $(NodeRootFullPath) + true + true + $(MSBuildThisFileFullPath) + + + +``` -## Adding a new ecosystem +Selection rejects duplicate identities, invalid priorities, equal winning priorities, missing roots, and explicit values that do not match a registered candidate. -When Python, Azure Functions, or another ecosystem is added: +Provider execution uses normal `BeforeTargets`/`AfterTargets` hooks on the public `NodeRestore` and `NodeBuild` targets. A provider must gate itself on the selected role and on its ownership metadata. Internal resolve/run ordering should use `DependsOnTargets`; do not create a second lifecycle abstraction. -``` -Targets/ - PythonRestore.targets → same entry-point structure as NodeRestore.targets - PythonRestore/ - Selection.targets → reuses SelectToolAdapter task - Pip.targets → _PythonRestorePipDetect, _PythonRestorePipResolve, _PythonRestorePipRun - Poetry.targets → ... - Uv.targets → ... - -Tasks/ - Python/ResolvePipProject.cs → ecosystem-specific resolver -``` +## Cross-referencing rule -Shared infrastructure (`SelectToolAdapter`, `ExecWithRetry`, `ProjectPaths.props`) is -ecosystem-agnostic and reused as-is. +A target name should identify its source: module prefix selects the folder, provider selects the file, and verb identifies the target within that file. For example `_NodeRestoreRushRun` lives in `NodeRestore/Rush.targets`. diff --git a/docs/NodeDependencies.md b/docs/NodeDependencies.md index 93957a8..0132818 100644 --- a/docs/NodeDependencies.md +++ b/docs/NodeDependencies.md @@ -2,9 +2,8 @@ `Pcf`, `ScriptLibrary`, and `CodeApp` projects need `node_modules` hydrated before their JavaScript/TypeScript build step runs. The SDK does this automatically via a shared `NodeRestore` MSBuild target - it detects the -right package manager for the project (npm, pnpm, Yarn, Bun, or Rush) from the same marker files those tools -themselves already use, and runs the correct install command for the situation (local dev vs. CI, mutable vs. -frozen/reproducible). +package manager (npm, pnpm, Yarn, or Bun) and optional orchestrator (currently Rush) from the same marker +files those tools use, then runs the correct install command for local development or CI. ## Design principles @@ -17,12 +16,10 @@ underlying Node tool: `publish` alone must produce the right outcome, whether a project is pure .NET, pure Node, or both. - **Incremental and full-repo adoption use the same mechanism, not separate code paths.** A single `.csproj` dropped into an otherwise-plain folder (own local `package.json`, no repo-wide orchestrator) and every project - in a repo sharing one Rush/pnpm/npm workspace both fall out of the same marker walk-up - (`GetDirectoryNameOfFileAbove`) - the incremental case simply resolves the workspace root to the project's own + in a repo sharing one Rush/pnpm/npm installation root both fall out of the same marker walk-up + (`GetDirectoryNameOfFileAbove`) - the incremental case simply resolves the package-manager root to the project's own directory because no marker is found above it. -- **Tool resolution is adapter-based.** The shared lifecycle selects one registered adapter and does not switch - over known tool names. This package registers npm, pnpm, Yarn, Bun, and Rush; another package can register an - adapter without changing the SDK core. +- **Toolchain roles resolve independently.** `ResolveNodeToolchain` selects one package manager and, separately, an optional orchestrator. Built-in candidates cover npm, pnpm, Yarn, Bun, and Rush; external NuGet packages can add candidates without changing SDK core. - **Repo-level exclusivity is enforced by the SDK, informed by the tool.** Rush has its own whole-repo, fail-fast lock for `update`/`install`/`build`, but several of its phases (the per-user pnpm bootstrap in `~/.rush`, the lockfile copies into `common/temp`) run before that lock and are not concurrency-safe - a @@ -42,7 +39,7 @@ underlying Node tool: | Verb | Node behavior | |---|---| | `dotnet restore` (project **or solution/repo-root**) | Hydrates Node deps via `NodeRestore`, hooked on `AfterTargets="CollectPackageReferences"` - the one per-project target NuGet's solution-level restore reliably invokes for every project, unlike `AfterTargets="Restore"` which only fires for single-project restore. This is what makes a bare `dotnet restore` at the repo root hydrate Node dependencies for every project, not just NuGet ones. | -| `dotnet build` | Hydrates (implicit restore) + builds. For non-Rush tools, `npm run build` runs directly, unchanged. For Rush-resolved projects (`Pcf`/`ScriptLibrary`/`CodeApp`), the build step itself delegates to Rush's own `build` command instead, so Rush's content-hash incremental skip and build cache apply - see "Build delegation to Rush" below. | +| `dotnet build` | Hydrates (implicit restore) + builds. Without an owning orchestrator, the selected package manager runs the build script. For Rush-owned projects (`Pcf`/`ScriptLibrary`/`CodeApp`), the build step itself delegates to Rush's own `build` command instead, so Rush's content-hash incremental skip and build cache apply - see "Build delegation to Rush" below. | | `dotnet clean` | Removes this project's own JS build-output folder only (`dist` for CodeApp, ScriptLibrary's TypeScript output folder). Never touches `node_modules` or any shared workspace state - "clean" and "prune installed deps" are different operations, and removing `node_modules` is a far more expensive, disruptive step than a normal `dotnet clean` should trigger silently. `Pcf` has no new Clean target from this SDK - Microsoft's own `PcfClean` (`npm run clean`) already owns PCF's `out/controls` cleanup. | | `dotnet publish` | Copies JS build output into the publish directory (existing, unaffected by any of the above). | @@ -51,15 +48,11 @@ underlying Node tool: worst case it's a cheap no-op via the existing incremental gate (non-Rush) or the Rush up-to-date gate; it never silently skips Node hydration just because NuGet's own restore step was skipped. -One bootstrapping caveat: the very first **solution-scope** `dotnet restore` on a machine whose NuGet cache does -not yet contain this SDK's packages hydrates NuGet packages only - the target that hydrates Node deps arrives in -one of those packages, and NuGet's solution-scope restore has no per-project hook that runs after download. The -next `dotnet restore`, or the first `dotnet build` (whose Node build chain runs `NodeRestore` itself), hydrates -Node dependencies. Single-project restores do not have this gap. +On a cold NuGet cache, the SDK re-evaluates each eligible Node project after package restore downloads the Tasks package, so the same `dotnet restore` invocation can run `NodeRestore`. The anchor computes the Node root locally in that cold-cache evaluation using the same `NodeRootPath` > `TypeScriptDir` > `.` precedence as `ProjectPaths.props`. ## Build delegation to Rush -When the resolved tool for a `Pcf`/`ScriptLibrary`/`CodeApp` project is Rush, the *build* step (not just +When `NodeOrchestrator` resolves to Rush for a `Pcf`/`ScriptLibrary`/`CodeApp` project, the *build* step (not just dependency hydration) is delegated to Rush's own `install-run-rush.js build`, instead of calling `npm run build` directly - this is what actually lets Rush's per-project content-hash incremental skip and build cache apply to the Node build step. A direct `npm run build` every time has zero incrementality of its own. @@ -80,8 +73,7 @@ choice, because a fixed choice creates one of two different regressions: A project directory under a Rush marker but not actually listed in `rush.json`'s `projects` array (legitimate incremental adoption - not every project needs to join Rush's graph on day one) is detected proactively before -either restore or build routes through Rush, and falls back to this project's own direct `npm install`/ -`npm run build` with a visible warning instead. +either restore or build routes through Rush, and falls back to the independently selected package manager for restore and build, with a visible warning instead. ### PCF-specific: forwarding the build mode as `--build-mode` @@ -156,46 +148,36 @@ without the archived-cache performance benefit. ## How detection works -Purely via MSBuild's built-in `GetDirectoryNameOfFileAbove`, walking up from `$(NodeRootFullPath)` looking for the first marker in this order: +`NodeToolchain` walks upward from `$(NodeRootFullPath)` with MSBuild's built-in `GetDirectoryNameOfFileAbove`. Package-manager and orchestrator detection are separate: -| Precedence | Marker | Resolved tool | -|---|---|---| -| 1 | `rush.json` | `rush` | -| 2 | `pnpm-lock.yaml` | `pnpm` | -| 3 | `yarn.lock` | `yarn` (Classic or Berry, detected via `.yarnrc.yml`) | -| 4 | `bun.lockb` | `bun` | -| 5 | `package-lock.json` | `npm` | -| _(none found)_ | - | `npm`, run in the project directory itself | +| Role | Marker | Value | Priority | +|---|---|---|---| +| Package manager | `pnpm-lock.yaml` | `pnpm` | 200 | +| Package manager | `yarn.lock` | `yarn` | 190 | +| Package manager | `bun.lock` or `bun.lockb` | `bun` | 180 | +| Package manager | `package-lock.json`, or no stronger marker | `npm` | 0 | +| Orchestrator | `rush.json` | `rush` | 300 | -No bundled Node script, external npm dependency, or extra process is needed - these five filenames have been -stable across all of these tools for years, and the small number of install command variants below don't need -a library to track. +This means a Rush repository normally resolves both its underlying package manager (for example `pnpm`) and `rush`. Rush owns restore/build only for projects registered in `rush.json`; an unregistered project keeps the selected package-manager path. ## Configuration | Property | Default | Description | -|----------|---------|-------------| -| `NodePackageManager` | _(auto)_ | Leave empty to auto-detect via the table above. Set explicitly to `npm`, `pnpm`, `yarn`, `bun`, or `rush` to skip detection and force a tool (the workspace root is still resolved the same way). Set to `None` to skip Node restore entirely - use this when dependencies are hydrated by something external to the build (a separate CI step, a different orchestrator, etc.). | -| `NodeRestoreCommand` | _(empty)_ | Escape hatch: if set, this exact command line is run instead of anything auto-detected or resolved from `NodePackageManager` - for any tool this SDK doesn't know about, or any custom install invocation. Runs every build (no incremental caching, since an arbitrary command's staleness can't be inferred). | -| `NodeRootPath` | `.` | Relative path to the Node project root (where `package.json` lives), resolved against the project directory. All Node-based project types (Pcf, ScriptLibrary, CodeApp) use this for detection and build operations. | -| `IsRunningInCI` | _(auto)_ | Reused as-is from [Versioning.md](Versioning.md) - leave empty to auto-detect CI from environment variables, or set `true`/`false` to override. Selects the frozen/reproducible install variant below. | - -An adapter registers with one property and hooks lifecycle via standard MSBuild: +|---|---|---| +| `NodePackageManager` | _(auto)_ | Package manager: `npm`, `pnpm`, `yarn`, `bun`, or `None`. `None` skips dependency hydration. | +| `NodeOrchestrator` | _(auto)_ | Orchestrator: `rush` or `None`. `None` disables orchestrator ownership while retaining package-manager detection. | +| `NodeRestoreCommand` | _(empty)_ | Exact restore command override. It runs from `NodeRootFullPath` on every invocation and suppresses built-in restore providers. | +| `NodeRootPath` | `.` | Relative Node project root. It takes precedence over legacy `TypeScriptDir`. | +| `TypeScriptDir` | normalized `NodeRootFullPath` | Compatibility property. When only this legacy property is supplied it feeds `NodeRootPath`; after evaluation it contains the normalized absolute Node root. | +| `IsRunningInCI` | _(auto)_ | Selects frozen/reproducible install commands. | -- **`NodeRestoreAdapterDetectDependsOn`** — append a detection target that populates - `_NodeRestoreAdapterCandidate` items with `Priority`, `WorkspaceRoot`, and `Source` metadata. -- **`AfterTargets="_NodeRestoreSelect"`** — resolve and run targets use this to hook into the - lifecycle after selection. Each target must gate itself on `_NodeRestoreResolvedTool` matching - its adapter name. +External package-manager detection targets append to `NodePackageManagerDetectDependsOn` and add `_NodePackageManagerCandidate` items. External orchestrators use `NodeOrchestratorDetectDependsOn` and `_NodeOrchestratorCandidate`. Candidates provide `Priority`, `RootPath`, and `Source`; orchestrators may also set `OwnsRestore` and `OwnsBuild`. Providers hook the public `NodeRestore` or `NodeBuild` target with normal `BeforeTargets`/`AfterTargets` and use explicit dependencies for their own internal ordering. -Selection is deterministic: duplicate names and equal highest priorities fail with contributing -sources. An explicit `NodeRestoreCommand` wins — tool resolve/run targets self-gate on -`'$(NodeRestoreCommand)' == ''`. -`NodeRestoreCommand` skips every adapter configure/run target and runs only the supplied command. +Selection is deterministic: duplicate identities, invalid priorities, equal winning priorities, missing roots, and unmatched explicit values fail with source information. `NodeRestoreCommand` suppresses built-in provider execution and runs only the supplied command. ## Frozen (CI-safe) installs -When `IsRunningInCI` resolves to `true` **and** a lockfile exists at the resolved workspace root, `NodeRestore` +When `IsRunningInCI` resolves to `true` **and** a lockfile exists at the selected provider root, `NodeRestore` uses the frozen/reproducible install variant instead of the mutable one: | Tool | Local / mutable | CI / frozen | @@ -203,7 +185,7 @@ uses the frozen/reproducible install variant instead of the mutable one: | `rush` | `install-run-rush.js update` (scoped `install --to .` on a never-installed workspace - see [Rush specifics](#rush-specifics)) | `install-run-rush.js install` (same scoping rule) | | `pnpm` | `pnpm install` | `pnpm install --frozen-lockfile` | | `yarn` (Classic) | `yarn install` | `yarn install --frozen-lockfile` | -| `yarn` (Berry) | `yarn install` | `yarn install --immutable` | +| `yarn` (Berry) | `yarn install` | `yarn install` (Berry enforces immutable installs automatically in CI) | | `bun` | `bun install` | `bun install --frozen-lockfile` | | `npm` | `npm install` | `npm ci` | @@ -252,17 +234,17 @@ In a conventional workspace that has never been installed, a standalone project the current lockfile. Installed conventional workspaces and solution-scope restores remain unscoped because switching between filtered and full install state forces unnecessary reinstalls. -## Once-per-workspace execution (non-Rush tools) +## Once-per-package-manager-root execution (non-Rush tools) -For npm/pnpm/Yarn/Bun, `NodeRestore` runs at the detected workspace root and is gated by an MSBuild +For npm/pnpm/Yarn/Bun, `NodeRestore` runs at `_NodePackageManagerRootPath` and is gated by an MSBuild Inputs/Outputs check (package.json + lockfile → a `.node-restore.stamp` file inside that root's `node_modules`, so deleting `node_modules` re-triggers the install and the stamp can never be committed; Yarn Berry PnP, which materializes no `node_modules`, keeps the stamp at the root), so the second, -third, ... project in the same build that shares a workspace root sees the install as already up-to-date and -skips it - the same "once per workspace, not once per project" guarantee `dotnet restore` gives per solution. +third, ... project in the same build that shares a package-manager root sees the install as already up-to-date and +skips it - the same "once per installation root, not once per project" guarantee `dotnet restore` gives per solution. Known limitation: concurrent multi-proc MSBuild builds (`dotnet build -m`) of independent projects sharing one -workspace root can still race to invoke install simultaneously for these tools, since none of npm/pnpm/Yarn/Bun +package-manager root can still race to invoke install simultaneously for these tools, since none of npm/pnpm/Yarn/Bun ship a cross-process lock of their own (Rush does not have this problem - its invocations are serialized behind the per-workspace mutex, see [Rush specifics](#rush-specifics)). diff --git a/src/Dataverse/CodeApp/README.md b/src/Dataverse/CodeApp/README.md index 37e2b28..ab57406 100644 --- a/src/Dataverse/CodeApp/README.md +++ b/src/Dataverse/CodeApp/README.md @@ -1,6 +1,6 @@ # TALXIS.DevKit.Build.Dataverse.CodeApp -MSBuild integration for Power Apps code-first canvas app projects. Automates Node dependency restore (auto-detected package manager - npm, pnpm, Yarn, Bun, or Rush; see [NodeDependencies.md](../../../docs/NodeDependencies.md)) followed by `npm run build`, copies the compiled `dist/` output into the correct location, and exposes metadata targets that allow Solution projects to discover, generate `.meta.xml`, and package canvas apps into the solution `.zip`. +MSBuild integration for Power Apps code-first canvas app projects. Automates Node dependency restore (auto-detected package manager (npm, pnpm, Yarn, or Bun) plus optional Rush orchestration; see [NodeDependencies.md](../../../docs/NodeDependencies.md)) followed by the shared `NodeBuild` target, copies the compiled `dist/` output into the correct location, and exposes metadata targets that allow Solution projects to discover, generate `.meta.xml`, and package canvas apps into the solution `.zip`. ## Installation @@ -21,9 +21,9 @@ Or use the SDK approach: ## Prerequisites -- **Node.js** and **npm** must be available in `PATH`. +- **Node.js** and the selected package manager must be available in `PATH`. - A `package.json` must exist in the project root. -- The `npm run build` script must produce output in a `dist/` folder. +- The package `build` script must produce output in a `dist/` folder. - A `power.config.json` file must exist, describing the app schema name and metadata used by `GenerateCodeAppMetaXml`. ## How It Works @@ -31,7 +31,7 @@ Or use the SDK approach: ### Build-time targets 1. **CheckCodeAppPrereqs** -- validates that `package.json` exists and that `node` is available in PATH (package manager presence is checked by `NodeRestore` itself, since it depends on what's detected). Runs only when `RunNodeBuild` is `true` (auto-detected from the presence of `package.json`). -2. **BuildCodeApp** (runs before `Build`, depends on `CheckCodeAppPrereqs`) -- calls the shared `NodeRestore` target (auto-detected package manager) followed by `npm run build` in the project root directory. +2. **BuildCodeApp** (runs before `Build`, depends on `CheckCodeAppPrereqs`) -- calls the shared `NodeRestore` target (auto-detected package manager) followed by the shared `NodeBuild` target 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. 4. **CopyCodeAppDistPublish** (runs after `Publish`) -- same as above, but copies to `$(PublishDir)` instead. @@ -60,6 +60,8 @@ The CodeApp reference is automatically filtered out of the standard `ResolveProj | `ProjectType` | `CodeApp` | Marks the project as a code app for reference discovery. | | `AppName` | _(required)_ | Application name; used as the output folder name and in `.meta.xml` generation. | | `RunNodeBuild` | Auto-detected | Set to `true` if `package.json` exists in project root; set explicitly to override. | +| `NodePackageManager` | Auto-detected | `npm`, `pnpm`, `yarn`, `bun`, or `None`. | +| `NodeOrchestrator` | Auto-detected | `rush` or `None`. | ## power.config.json 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 33325fb..5ebbc8b 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 @@ -10,8 +10,8 @@ Condition="'$(RunNodeBuild)'=='true'"> @@ -28,15 +28,12 @@ the called target, only ones reached through the DependsOnTargets chain are. --> - <_NodeBuildProjectDirectory>$(NodeRootFullPath) <_NodeBuildModeArgName Condition="'$(_NodeBuildModeArgName)'==''">mode - <_NodeBuildExtraArgs> - <_NodeBuildRequiredRushParams> 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 a272e2f..d7960a0 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 @@ -50,22 +50,20 @@ (PcfRebuild) call "PcfBuild" by name via CallTarget, so overriding it here covers both build paths with one definition. - This target has no Condition of its own and always runs both - _NodeBuildDelegateToRush and _NodeBuildDirect through DependsOnTargets - - each of those targets carries its own internal Condition on '$(_NodeRestoreResolvedTool)' - so exactly one of them actually invokes npm/Rush for a given project. A Condition on the + This target has no Condition of its own and invokes the shared NodeBuild lifecycle. Its + provider targets carry conditions on the resolved Node toolchain, so exactly one invokes + the build for a given project. A Condition on the "PcfBuild" target declaration itself would not behave as a per-build branch: MSBuild's "last declaration wins" rule means a false Condition here would make the whole target - including Microsoft's original body - simply not exist, not fall back to it. The actual Rush command construction, command-line.json declaration check and retry - wiring live in the shared _NodeBuildDelegateToRush target (NodeBuild package); + wiring live in the shared _NodeBuildRush target (NodeBuild package); _PcfSetNodeBuildArgs supplies this project's arguments to it via DependsOnTargets, not CallTarget - properties set in a target reached through CallTarget are not visible inside the called target, only ones reached through the DependsOnTargets chain are. --> - <_NodeBuildProjectDirectory>$(NodeRootFullPath) - - - - <_NodeBuildProjectDirectory>$(NodeRootFullPath) - <_NodeBuildExtraArgs> - <_NodeBuildRequiredRushParams> - - - @@ -44,8 +33,8 @@ diff --git a/src/Dataverse/Tasks/Tasks/GenerateGitVersion.cs b/src/Dataverse/Tasks/Tasks/GenerateGitVersion.cs index 0b9e9e2..11c88e5 100644 --- a/src/Dataverse/Tasks/Tasks/GenerateGitVersion.cs +++ b/src/Dataverse/Tasks/Tasks/GenerateGitVersion.cs @@ -26,6 +26,7 @@ public class GenerateGitVersion : Task public string GitVersionNumberBranchPrefixes { get; set; } // e.g. "develop:1;feature/*:3;hotfix/*:4" public string GitVersionNumberProductionBranches { get; set; } // e.g. "main;master;hotfix/*;release/*" public string LocalBuildVersionNumber { get; set; } + [Required] public string IsRunningInCI { get; set; } public string GitVersionBranch { get; set; } @@ -52,7 +53,13 @@ public override bool Execute() return true; } - if (!DetectIsRunningInCI()) + if (!bool.TryParse(IsRunningInCI, out var isRunningInCI)) + { + Log.LogError($"IsRunningInCI value '{IsRunningInCI}' is not a valid boolean."); + return false; + } + + if (!isRunningInCI) { Log.LogMessage(MessageImportance.High, "Not running in CI; using LocalBuildVersionNumber."); VersionOutput = LocalBuildVersionNumber; @@ -337,57 +344,6 @@ private bool TryFindGitRoot(string path, out string gitRoot) gitRoot = null; return false; } - private bool DetectIsRunningInCI() - { - if (!string.IsNullOrEmpty(IsRunningInCI)) - { - if (bool.TryParse(IsRunningInCI, out var overrideValue)) - { - Log.LogMessage(MessageImportance.High, $"IsRunningInCI overridden to: {overrideValue}"); - return overrideValue; - } - Log.LogWarning($"IsRunningInCI value '{IsRunningInCI}' is not a valid boolean; falling back to auto-detection."); - } - - // Boolean-style vars: only treat explicit "true" as CI - var booleanCiVars = new[] - { - "CI", // Generic (GitHub Actions, GitLab, Travis, CircleCI, etc.) - "TF_BUILD", // Azure DevOps - "GITHUB_ACTIONS", // GitHub Actions - "GITLAB_CI", // GitLab CI - "CIRCLECI", // CircleCI - }; - - foreach (var varName in booleanCiVars) - { - var value = Environment.GetEnvironmentVariable(varName); - if (bool.TryParse(value, out var boolValue) && boolValue) - { - Log.LogMessage(MessageImportance.High, $"CI environment detected via {varName}"); - return true; - } - } - - // Non-boolean vars: any non-empty value indicates CI - var presenceCiVars = new[] - { - "JENKINS_URL", // Jenkins - "TEAMCITY_VERSION" // TeamCity - }; - - foreach (var varName in presenceCiVars) - { - if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(varName))) - { - Log.LogMessage(MessageImportance.High, $"CI environment detected via {varName}"); - return true; - } - } - - Log.LogMessage(MessageImportance.High, "No CI environment detected; treating as local build."); - return false; - } private void RetrieveAllProjectReferences(string projectPath, List projects) { var projectFile = ProjectReferenceHelper.FindProjectFile(projectPath); diff --git a/src/Dataverse/Tasks/Tasks/Node/ResolveNodeToolchain.cs b/src/Dataverse/Tasks/Tasks/Node/ResolveNodeToolchain.cs new file mode 100644 index 0000000..a95f5a0 --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/Node/ResolveNodeToolchain.cs @@ -0,0 +1,148 @@ +using System; +using System.Globalization; +using System.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +/// +/// Resolves the package manager and orchestrator that govern a Node project. +/// +public sealed class ResolveNodeToolchain : Task +{ + [Required] + public ITaskItem[] PackageManagerCandidates { get; set; } = Array.Empty(); + + public ITaskItem[] OrchestratorCandidates { get; set; } = Array.Empty(); + + public string RequestedPackageManager { get; set; } = string.Empty; + + public string RequestedOrchestrator { get; set; } = string.Empty; + + [Output] + public ITaskItem PackageManager { get; private set; } + + [Output] + public ITaskItem Orchestrator { get; private set; } + + public override bool Execute() + { + PackageManager = SelectCandidate( + PackageManagerCandidates, + RequestedPackageManager, + "Node package manager", + required: !IsNone(RequestedPackageManager)); + + Orchestrator = SelectCandidate( + OrchestratorCandidates, + RequestedOrchestrator, + "Node orchestrator", + required: false); + + return !Log.HasLoggedErrors; + } + + private ITaskItem SelectCandidate( + ITaskItem[] candidates, + string requestedName, + string roleName, + bool required) + { + if (IsNone(requestedName)) + { + return null; + } + + var duplicate = candidates + .GroupBy(candidate => candidate.ItemSpec, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate != null) + { + Log.LogError( + $"{roleName} '{duplicate.Key}' was registered more than once: " + + string.Join(", ", duplicate.Select(DescribeSource))); + return null; + } + + var eligible = string.IsNullOrWhiteSpace(requestedName) + ? candidates + : candidates.Where(candidate => + string.Equals(candidate.ItemSpec, requestedName, StringComparison.OrdinalIgnoreCase)).ToArray(); + + if (eligible.Length == 0) + { + if (!required && string.IsNullOrWhiteSpace(requestedName)) + { + return null; + } + + var registeredText = candidates.Length == 0 + ? "none" + : string.Join(", ", candidates.Select(candidate => candidate.ItemSpec)); + var requestedText = string.IsNullOrWhiteSpace(requestedName) + ? $"No registered {roleName.ToLowerInvariant()} matched this project." + : $"Requested {roleName.ToLowerInvariant()} '{requestedName}' did not match this project."; + Log.LogError($"{requestedText} Registered candidates: {registeredText}."); + return null; + } + + var ranked = eligible + .Select(candidate => new + { + Candidate = candidate, + Priority = ParsePriority(candidate, roleName) + }) + .OrderByDescending(entry => entry.Priority) + .ToArray(); + if (Log.HasLoggedErrors) + { + return null; + } + + var winner = ranked[0]; + var tied = ranked.Where(entry => entry.Priority == winner.Priority).ToArray(); + if (tied.Length > 1) + { + Log.LogError( + $"Multiple {roleName.ToLowerInvariant()} candidates matched with priority {winner.Priority}: " + + string.Join(", ", tied.Select(entry => $"{entry.Candidate.ItemSpec} ({DescribeSource(entry.Candidate)})"))); + return null; + } + + if (string.IsNullOrWhiteSpace(winner.Candidate.GetMetadata("RootPath"))) + { + Log.LogError( + $"{roleName} '{winner.Candidate.ItemSpec}' did not provide required RootPath metadata " + + $"({DescribeSource(winner.Candidate)})."); + return null; + } + + return winner.Candidate; + } + + private int ParsePriority(ITaskItem candidate, string roleName) + { + var raw = candidate.GetMetadata("Priority"); + if (string.IsNullOrWhiteSpace(raw)) + { + return 0; + } + + if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var priority)) + { + return priority; + } + + Log.LogError( + $"{roleName} '{candidate.ItemSpec}' has invalid Priority '{raw}' ({DescribeSource(candidate)})."); + return 0; + } + + private static bool IsNone(string value) => + string.Equals(value, "None", StringComparison.OrdinalIgnoreCase); + + private static string DescribeSource(ITaskItem candidate) + { + var source = candidate.GetMetadata("Source"); + return string.IsNullOrWhiteSpace(source) ? "unknown source" : source; + } +} diff --git a/src/Dataverse/Tasks/Tasks/Node/ResolveRushProject.cs b/src/Dataverse/Tasks/Tasks/Node/ResolveRushProject.cs index dda9a16..9607b23 100644 --- a/src/Dataverse/Tasks/Tasks/Node/ResolveRushProject.cs +++ b/src/Dataverse/Tasks/Tasks/Node/ResolveRushProject.cs @@ -18,7 +18,7 @@ public sealed class ResolveRushProject : Task }; [Required] - public string WorkspaceRoot { get; set; } = string.Empty; + public string RushRootPath { get; set; } = string.Empty; [Required] public string ProjectRoot { get; set; } = string.Empty; @@ -29,9 +29,6 @@ public sealed class ResolveRushProject : Task [Output] public bool SubspacesEnabled { get; private set; } - [Output] - public string SubspaceName { get; private set; } = string.Empty; - [Output] public string SubspaceConfigurationRoot { get; private set; } = string.Empty; @@ -45,9 +42,9 @@ public override bool Execute() { try { - var workspaceRoot = NormalizeDirectory(WorkspaceRoot); + var rushRootPath = NormalizeDirectory(RushRootPath); var projectRoot = NormalizeDirectory(ProjectRoot); - var rushJsonPath = Path.Combine(workspaceRoot, "rush.json"); + var rushJsonPath = Path.Combine(rushRootPath, "rush.json"); if (!File.Exists(rushJsonPath)) { Log.LogError($"Rush configuration was not found at '{rushJsonPath}'."); @@ -68,7 +65,7 @@ public override bool Execute() return false; } - var projects = ReadProjects(projectsElement, workspaceRoot, rushJsonPath); + var projects = ReadProjects(projectsElement, rushRootPath, rushJsonPath); if (Log.HasLoggedErrors) { return false; @@ -84,7 +81,7 @@ public override bool Execute() var currentProject = matchingProjects.SingleOrDefault(); IsRegistered = currentProject != null; - var subspacesJsonPath = Path.Combine(workspaceRoot, "common", "config", "rush", "subspaces.json"); + var subspacesJsonPath = Path.Combine(rushRootPath, "common", "config", "rush", "subspaces.json"); var subspaceNames = new HashSet(StringComparer.Ordinal); if (File.Exists(subspacesJsonPath)) { @@ -149,17 +146,17 @@ public override bool Execute() return true; } - SubspaceName = SubspacesEnabled + var subspaceName = SubspacesEnabled ? string.IsNullOrWhiteSpace(currentProject!.SubspaceName) ? "default" : currentProject.SubspaceName : string.Empty; SubspaceConfigurationRoot = SubspacesEnabled - ? Path.Combine(workspaceRoot, "common", "config", "subspaces", SubspaceName) - : Path.Combine(workspaceRoot, "common", "config", "rush"); + ? Path.Combine(rushRootPath, "common", "config", "subspaces", subspaceName) + : Path.Combine(rushRootPath, "common", "config", "rush"); SubspaceTempRoot = SubspacesEnabled - ? Path.Combine(workspaceRoot, "common", "temp", SubspaceName) - : Path.Combine(workspaceRoot, "common", "temp"); + ? Path.Combine(rushRootPath, "common", "temp", subspaceName) + : Path.Combine(rushRootPath, "common", "temp"); InstallPackageJsonPaths = projects .Select(project => (ITaskItem)new TaskItem(Path.Combine(project.FullPath, "package.json"))) diff --git a/src/Dataverse/Tasks/Tasks/Node/SelectToolAdapter.cs b/src/Dataverse/Tasks/Tasks/Node/SelectToolAdapter.cs deleted file mode 100644 index 61f97cb..0000000 --- a/src/Dataverse/Tasks/Tasks/Node/SelectToolAdapter.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Globalization; -using System.Linq; -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; - -/// -/// Selects one registered adapter candidate by priority without hardcoding adapter names in the -/// SDK core. Ecosystem-agnostic: used by NodeRestore today, reusable by PythonRestore etc. -/// -public sealed class SelectToolAdapter : Task -{ - [Required] - public ITaskItem[] Candidates { get; set; } = Array.Empty(); - - public string RequestedAdapter { get; set; } = string.Empty; - - [Output] - public string AdapterName { get; private set; } = string.Empty; - - [Output] - public string WorkspaceRoot { get; private set; } = string.Empty; - - public override bool Execute() - { - var duplicate = Candidates - .GroupBy(candidate => candidate.ItemSpec, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(group => group.Count() > 1); - if (duplicate != null) - { - Log.LogError( - $"Adapter '{duplicate.Key}' was registered more than once: " + - string.Join(", ", duplicate.Select(DescribeSource))); - return false; - } - - var eligible = string.IsNullOrWhiteSpace(RequestedAdapter) - ? Candidates - : Candidates.Where(candidate => - string.Equals(candidate.ItemSpec, RequestedAdapter, StringComparison.OrdinalIgnoreCase)).ToArray(); - - if (eligible.Length == 0) - { - var registeredText = Candidates.Length == 0 - ? "none" - : string.Join(", ", Candidates.Select(candidate => candidate.ItemSpec)); - var requestedText = string.IsNullOrWhiteSpace(RequestedAdapter) - ? "No registered adapter matched this project." - : $"Requested adapter '{RequestedAdapter}' did not match this project."; - Log.LogError($"{requestedText} Registered candidates: {registeredText}."); - return false; - } - - var ranked = eligible - .Select(candidate => new - { - Candidate = candidate, - Priority = ParsePriority(candidate) - }) - .OrderByDescending(entry => entry.Priority) - .ToArray(); - if (Log.HasLoggedErrors) - { - return false; - } - - var winner = ranked[0]; - var tied = ranked.Where(entry => entry.Priority == winner.Priority).ToArray(); - if (tied.Length > 1) - { - Log.LogError( - $"Multiple adapters matched with priority {winner.Priority}: " + - string.Join(", ", tied.Select(entry => $"{entry.Candidate.ItemSpec} ({DescribeSource(entry.Candidate)})"))); - return false; - } - - WorkspaceRoot = winner.Candidate.GetMetadata("WorkspaceRoot"); - if (string.IsNullOrWhiteSpace(WorkspaceRoot)) - { - Log.LogError($"Adapter '{winner.Candidate.ItemSpec}' did not provide required WorkspaceRoot metadata."); - return false; - } - - AdapterName = winner.Candidate.ItemSpec; - return true; - } - - private int ParsePriority(ITaskItem candidate) - { - var raw = candidate.GetMetadata("Priority"); - if (string.IsNullOrWhiteSpace(raw)) - { - return 0; - } - - if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var priority)) - { - return priority; - } - - Log.LogError($"Adapter '{candidate.ItemSpec}' has invalid Priority '{raw}' ({DescribeSource(candidate)})."); - return 0; - } - - private static string DescribeSource(ITaskItem candidate) - { - var source = candidate.GetMetadata("Source"); - return string.IsNullOrWhiteSpace(source) ? "unknown source" : source; - } -} diff --git a/src/Dataverse/Tasks/msbuild/tasks/Props/CIDetection.props b/src/Dataverse/Tasks/msbuild/tasks/Props/CIDetection.props index 317bffd..6763917 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Props/CIDetection.props +++ b/src/Dataverse/Tasks/msbuild/tasks/Props/CIDetection.props @@ -1,6 +1,5 @@ - + $(TypeScriptDir) . $([MSBuild]::NormalizePath($(MSBuildProjectDirectory), $(NodeRootPath))) + + $(NodeRootFullPath) \ No newline at end of file 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 24913c0..a2439ea 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 @@ -46,5 +46,5 @@ - + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild.targets index 0093c72..7aa929a 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild.targets @@ -1,59 +1,16 @@ - - + - production - development + production + development + <_NodeBuildProjectDirectory Condition="'$(_NodeBuildProjectDirectory)' == ''">$(NodeRootFullPath) - + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets index dbb69f5..0cf0a4d 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets @@ -1,15 +1,13 @@ - + AfterTargets="NodeBuild" + Condition="'$(_NodeOrchestratorOwnsBuild)' != 'true' and '$(_NodePackageManager)' != ''"> - <_NodeBuildDirectCommand Condition="'$(_NodeRestoreResolvedTool)'=='pnpm'">pnpm run build -- - <_NodeBuildDirectCommand Condition="'$(_NodeRestoreResolvedTool)'=='yarn'">yarn run build - <_NodeBuildDirectCommand Condition="'$(_NodeRestoreResolvedTool)'=='bun'">bun run build - <_NodeBuildDirectCommand Condition="'$(_NodeBuildDirectCommand)'==''">npm run build -- - <_NodeBuildModeArgs Condition="'$(_NodeBuildModeArgName)'!=''">--$(_NodeBuildModeArgName) $(NodeBuildConfiguration) + <_NodeBuildDirectCommand Condition="'$(_NodePackageManager)' == 'pnpm'">pnpm run build -- + <_NodeBuildDirectCommand Condition="'$(_NodePackageManager)' == 'yarn'">yarn run build + <_NodeBuildDirectCommand Condition="'$(_NodePackageManager)' == 'bun'">bun run build + <_NodeBuildDirectCommand Condition="'$(_NodeBuildDirectCommand)' == ''">npm run build -- + <_NodeBuildModeArgs Condition="'$(_NodeBuildModeArgName)' != ''">--$(_NodeBuildModeArgName) $(NodeBuildConfiguration) - - + - <_NodeBuildCommandLineJsonPath>$(_NodeRestoreWorkspaceRoot)/common/config/rush/command-line.json + <_NodeBuildCommandLineJsonPath>$(_NodeOrchestratorRootPath)/common/config/rush/command-line.json <_NodeBuildCommandLineJsonText Condition="Exists('$(_NodeBuildCommandLineJsonPath)')">$([System.IO.File]::ReadAllText('$(_NodeBuildCommandLineJsonPath)')) <_NodeBuildAllRequiredRushParams Condition="'$(_NodeBuildModeArgName)' != ''">--$(_NodeBuildModeArgName) <_NodeBuildAllRequiredRushParams Condition="'$(_NodeBuildRequiredRushParams)' != '' and '$(_NodeBuildAllRequiredRushParams)' != ''">$(_NodeBuildAllRequiredRushParams);$(_NodeBuildRequiredRushParams) @@ -70,8 +72,8 @@ update"/"install". --> <_NodeBuildIsSolutionScope Condition="'$(SolutionPath)' != '' and '$(SolutionPath)' != '*Undefined*'">true - <_NodeBuildRushCommand Condition="'$(_NodeBuildIsSolutionScope)' != 'true'">node "$(_NodeRestoreWorkspaceRoot)/common/scripts/install-run-rush.js" build --to . - <_NodeBuildRushCommand Condition="'$(_NodeBuildIsSolutionScope)' == 'true'">node "$(_NodeRestoreWorkspaceRoot)/common/scripts/install-run-rush.js" build + <_NodeBuildRushCommand Condition="'$(_NodeBuildIsSolutionScope)' != 'true'">node "$(_NodeOrchestratorRootPath)/common/scripts/install-run-rush.js" build --to . + <_NodeBuildRushCommand Condition="'$(_NodeBuildIsSolutionScope)' == 'true'">node "$(_NodeOrchestratorRootPath)/common/scripts/install-run-rush.js" build + Properties="_NodeExecRetryCommand=$(_NodeExecRetryCommand);_NodeExecRetryWorkingDirectory=$(_NodeExecRetryWorkingDirectory);_NodeExecRetryEnvironmentVariables=$(_NodeExecRetryEnvironmentVariables);_NodeExecRetryMutexName=$(_NodeToolchainRushMutexName)" /> diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets index 3e2ed0d..b930d59 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore.targets @@ -1,48 +1,16 @@ - - - - - - - - @@ -51,9 +19,7 @@ - - - + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Bun.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Bun.targets index acd2c6e..4d2dbe2 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Bun.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Bun.targets @@ -1,60 +1,31 @@ - - $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreBunDetect - - - - - - <_NodeRestoreBunRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'bun.lock')) - <_NodeRestoreBunRoot Condition="'$(_NodeRestoreBunRoot)' == ''">$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'bun.lockb')) - - - <_NodeRestoreAdapterCandidate Include="bun"> - 180 - $(_NodeRestoreBunRoot) - $(NodeRootFullPath) - $(MSBuildThisFileFullPath) - - - - + Condition="'$(NodeRestoreCommand)' == '' and '$(_NodeOrchestratorOwnsRestore)' != 'true' and '$(_NodePackageManager)' == 'bun'"> - <_NodeRestoreHasLockfile Condition="Exists('$(_NodeRestoreWorkspaceRoot)/bun.lock') or Exists('$(_NodeRestoreWorkspaceRoot)/bun.lockb')">true - <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)' == ''">false + <_NodeRestoreHasLockfile Condition="Exists('$(_NodePackageManagerRootPath)/bun.lock') or Exists('$(_NodePackageManagerRootPath)/bun.lockb')">true <_NodeRestoreUseFrozen Condition="'$(IsRunningInCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true - <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)' == ''">false - - - <_NodeRestorePackageJsonPath>$(NodeRootFullPath)/package.json - <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreHasLockfile)' == 'true' and Exists('$(_NodeRestoreWorkspaceRoot)/bun.lock')">$(_NodeRestoreWorkspaceRoot)/bun.lock - <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreLockfilePath)' == '' and '$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestoreWorkspaceRoot)/bun.lockb + <_NodeRestoreLockfilePath Condition="Exists('$(_NodePackageManagerRootPath)/bun.lock')">$(_NodePackageManagerRootPath)/bun.lock + <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreLockfilePath)' == '' and Exists('$(_NodePackageManagerRootPath)/bun.lockb')">$(_NodePackageManagerRootPath)/bun.lockb <_NodeRestoreIncrementalInputs>$(_NodeRestorePackageJsonPath) <_NodeRestoreIncrementalInputs Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestorePackageJsonPath);$(_NodeRestoreLockfilePath) - <_NodeRestoreStampPath>$(_NodeRestoreWorkspaceRoot)/node_modules/.node-restore.stamp - - - - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' != 'true'">bun install + <_NodeRestoreStampPath>$(_NodePackageManagerRootPath)/node_modules/.node-restore.stamp <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' == 'true'">bun install --frozen-lockfile + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)' == ''">bun install - + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/CustomCommand.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/CustomCommand.targets index ae9796d..f157739 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/CustomCommand.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/CustomCommand.targets @@ -2,10 +2,10 @@ - - diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Npm.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Npm.targets index a4550bd..e61d731 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Npm.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Npm.targets @@ -1,62 +1,33 @@ - - $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreNpmDetect - - - - - <_NodeRestoreNpmRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'package-lock.json')) - - - - <_NodeRestoreAdapterCandidate Include="npm"> - 0 - $(_NodeRestoreNpmRoot) - $(NodeRootFullPath) - $(MSBuildThisFileFullPath) - - - - + Condition="'$(NodeRestoreCommand)' == '' and '$(_NodeOrchestratorOwnsRestore)' != 'true' and '$(_NodePackageManager)' == 'npm'"> - <_NodeRestoreHasLockfile Condition="Exists('$(_NodeRestoreWorkspaceRoot)/package-lock.json')">true - <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)' == ''">false + <_NodeRestoreHasLockfile Condition="Exists('$(_NodePackageManagerRootPath)/package-lock.json')">true <_NodeRestoreUseFrozen Condition="'$(IsRunningInCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true - <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)' == ''">false - - - <_NodeRestorePackageJsonPath>$(NodeRootFullPath)/package.json - <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestoreWorkspaceRoot)/package-lock.json + <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodePackageManagerRootPath)/package-lock.json <_NodeRestoreIncrementalInputs>$(_NodeRestorePackageJsonPath) <_NodeRestoreIncrementalInputs Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestorePackageJsonPath);$(_NodeRestoreLockfilePath) - <_NodeRestoreStampPath>$(_NodeRestoreWorkspaceRoot)/node_modules/.node-restore.stamp - - - - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' != 'true'">npm install + <_NodeRestoreStampPath>$(_NodePackageManagerRootPath)/node_modules/.node-restore.stamp <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' == 'true'">npm ci + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)' == ''">npm install - + - + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Pnpm.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Pnpm.targets index 4412fa4..a3420e6 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Pnpm.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Pnpm.targets @@ -1,57 +1,30 @@ - - $(NodeRestoreAdapterDetectDependsOn);_NodeRestorePnpmDetect - - - - - <_NodeRestorePnpmRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'pnpm-lock.yaml')) - - - <_NodeRestoreAdapterCandidate Include="pnpm"> - 200 - $(_NodeRestorePnpmRoot) - $(NodeRootFullPath) - $(MSBuildThisFileFullPath) - - - - + Condition="'$(NodeRestoreCommand)' == '' and '$(_NodeOrchestratorOwnsRestore)' != 'true' and '$(_NodePackageManager)' == 'pnpm'"> - <_NodeRestoreHasLockfile Condition="Exists('$(_NodeRestoreWorkspaceRoot)/pnpm-lock.yaml')">true - <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)' == ''">false + <_NodeRestoreHasLockfile Condition="Exists('$(_NodePackageManagerRootPath)/pnpm-lock.yaml')">true <_NodeRestoreUseFrozen Condition="'$(IsRunningInCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true - <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)' == ''">false - - - <_NodeRestorePackageJsonPath>$(NodeRootFullPath)/package.json - <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestoreWorkspaceRoot)/pnpm-lock.yaml + <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodePackageManagerRootPath)/pnpm-lock.yaml <_NodeRestoreIncrementalInputs>$(_NodeRestorePackageJsonPath) <_NodeRestoreIncrementalInputs Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestorePackageJsonPath);$(_NodeRestoreLockfilePath) - <_NodeRestoreStampPath>$(_NodeRestoreWorkspaceRoot)/node_modules/.node-restore.stamp - - - - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' != 'true'">pnpm install + <_NodeRestoreStampPath>$(_NodePackageManagerRootPath)/node_modules/.node-restore.stamp <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreUseFrozen)' == 'true'">pnpm install --frozen-lockfile + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)' == ''">pnpm install - + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Retry.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Retry.targets index a362c11..1db3f1a 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Retry.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Retry.targets @@ -3,20 +3,9 @@ Shared retry-on-lock-contention helper, used by both the Rush install/update branch (Rush.targets) and the Rush build-delegation branch used by Pcf/ScriptLibrary/CodeApp. - Rush's own whole-repo lock (common/temp/rush#.lock) is fail-fast, not queue-based: a - second concurrent Rush invocation - install/update or build, both acquire the same lock - - errors immediately with "Another Rush command is already running in this repository" instead - of waiting its turn. That is exactly the shape multiple MSBuild projects, or a solution-level - parallel build, create once every Rush-registered project's restore and build both route - through Rush. Rather than inventing a separate cross-process lock, this target retries with - backoff whenever it recognizes Rush's own lock message: whichever invocation wins already - does the complete job for the entire Rush workspace, so a retry after the winner finishes is - either an instant no-op (Rush's own incremental-skip already satisfied) or, worst case, just - waits out real work already happening on its behalf. - - One retry budget serves both call sites (install/update and build), since a single unscoped - "rush build"/"rush update" per solution-scope invocation gives both the same risk profile: - the first invocation does the real work, every other invocation gets a fast no-op. + ExecWithRetry serializes SDK-owned Rush commands with one named system mutex per Rush root. + The bounded retry remains for contention with Rush commands started outside this SDK, which + can still trigger Rush's fail-fast "already running" error. --> <_NodeExecRushLockMessage>Another Rush command is already running in this repository diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets index e813d1d..a1474bd 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Rush.targets @@ -1,119 +1,41 @@ - - $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreRushDetect - - - - - <_NodeRestoreRushRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'rush.json')) - - - <_NodeRestoreAdapterCandidate Include="rush"> - 300 - $(_NodeRestoreRushRoot) - $(MSBuildThisFileFullPath) - - - - - - - - - - - - - - - + DependsOnTargets="_NodeToolchainRushResolve" + Condition="'$(NodeRestoreCommand)' == '' and '$(_NodeOrchestrator)' == 'rush' and '$(_NodeOrchestratorOwnsRestore)' == 'true'"> - <_NodeRestoreRushWorkspaceRootNormalized>$([System.String]::Copy('$(_NodeRestoreWorkspaceRoot)').Replace('\', '/').TrimEnd('/')) - - - - - - - <_NodeRestoreResolvedTool>npm - <_NodeRestoreWorkspaceRoot>$(NodeRootFullPath) - + <_NodeRestoreUseFrozen Condition="'$(IsRunningInCI)' == 'true'">true + <_NodeRestoreRushScoped Condition="'$(_NodeToolchainRushSubspacesEnabled)' != 'true' and ('$(SolutionPath)' == '' or '$(SolutionPath)' == '*Undefined*') and !Exists('$(_NodeToolchainRushTempRoot)/last-install.flag')">true + <_NodeRestoreRushProjectSelected Condition="'$(_NodeToolchainRushSubspacesEnabled)' == 'true' or '$(_NodeRestoreRushScoped)' == 'true'">true + <_NodeRestoreRushRunner>node "$(_NodeOrchestratorRootPath)/common/scripts/install-run-rush.js" - - - <_NodeRestoreHasLockfile>true - <_NodeRestoreUseFrozen Condition="'$(IsRunningInCI)'=='true'">true - <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)'==''">false - - - <_NodeRestoreRushScoped Condition="'$(_NodeRestoreRushSubspacesEnabled)' != 'true' and ('$(SolutionPath)' == '' or '$(SolutionPath)' == '*Undefined*') and !Exists('$(_NodeRestoreRushTempRoot)/last-install.flag')">true - <_NodeRestoreRushScoped Condition="'$(_NodeRestoreRushScoped)' == ''">false - <_NodeRestoreRushProjectSelected Condition="'$(_NodeRestoreRushSubspacesEnabled)' == 'true' or '$(_NodeRestoreRushScoped)' == 'true'">true - <_NodeRestoreRushProjectSelected Condition="'$(_NodeRestoreRushProjectSelected)' == ''">false - <_NodeRestoreRushRunner>node "$(_NodeRestoreWorkspaceRoot)/common/scripts/install-run-rush.js" - - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushSubspacesEnabled)'=='true' and '$(_NodeRestoreUseFrozen)'!='true'">$(_NodeRestoreRushRunner) update --to . - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushSubspacesEnabled)'=='true' and '$(_NodeRestoreUseFrozen)'=='true'">$(_NodeRestoreRushRunner) install --to . - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushSubspacesEnabled)'!='true' and '$(_NodeRestoreRushScoped)'!='true' and '$(_NodeRestoreUseFrozen)'!='true'">$(_NodeRestoreRushRunner) update - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushSubspacesEnabled)'!='true' and '$(_NodeRestoreRushScoped)'!='true' and '$(_NodeRestoreUseFrozen)'=='true'">$(_NodeRestoreRushRunner) install - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushSubspacesEnabled)'!='true' and '$(_NodeRestoreRushScoped)'=='true' and '$(_NodeRestoreUseFrozen)'=='true'">$(_NodeRestoreRushRunner) install --to . - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)'=='' and '$(_NodeRestoreRushSubspacesEnabled)'!='true' and '$(_NodeRestoreRushScoped)'=='true' and '$(_NodeRestoreUseFrozen)'!='true'">$(_NodeRestoreRushRunner) install --to . || $(_NodeRestoreRushRunner) update - - - <_NodeRestoreRushMutexName>TALXIS.Rush.$([System.String]::Copy('$(_NodeRestoreRushWorkspaceRootNormalized)').Replace('/', '_').Replace(':', '_').ToLowerInvariant()) + <_NodeRestoreResolvedCommand Condition="'$(_NodeToolchainRushSubspacesEnabled)' == 'true' and '$(_NodeRestoreUseFrozen)' != 'true'">$(_NodeRestoreRushRunner) update --to . + <_NodeRestoreResolvedCommand Condition="'$(_NodeToolchainRushSubspacesEnabled)' == 'true' and '$(_NodeRestoreUseFrozen)' == 'true'">$(_NodeRestoreRushRunner) install --to . + <_NodeRestoreResolvedCommand Condition="'$(_NodeToolchainRushSubspacesEnabled)' != 'true' and '$(_NodeRestoreRushScoped)' != 'true' and '$(_NodeRestoreUseFrozen)' != 'true'">$(_NodeRestoreRushRunner) update + <_NodeRestoreResolvedCommand Condition="'$(_NodeToolchainRushSubspacesEnabled)' != 'true' and '$(_NodeRestoreRushScoped)' != 'true' and '$(_NodeRestoreUseFrozen)' == 'true'">$(_NodeRestoreRushRunner) install + <_NodeRestoreResolvedCommand Condition="'$(_NodeToolchainRushSubspacesEnabled)' != 'true' and '$(_NodeRestoreRushScoped)' == 'true' and '$(_NodeRestoreUseFrozen)' == 'true'">$(_NodeRestoreRushRunner) install --to . + <_NodeRestoreResolvedCommand Condition="'$(_NodeToolchainRushSubspacesEnabled)' != 'true' and '$(_NodeRestoreRushScoped)' == 'true' and '$(_NodeRestoreUseFrozen)' != 'true'">$(_NodeRestoreRushRunner) install --to . || $(_NodeRestoreRushRunner) update - - - + Condition="'$(NodeRestoreCommand)' == '' and '$(_NodeOrchestrator)' == 'rush' and '$(_NodeOrchestratorOwnsRestore)' == 'true'"> - <_NodeRestoreRushFlagPath>$(_NodeRestoreRushTempRoot)/last-install.flag + <_NodeRestoreRushFlagPath>$(_NodeToolchainRushTempRoot)/last-install.flag <_NodeRestoreRushFlagTicks Condition="Exists('$(_NodeRestoreRushFlagPath)')">$([System.IO.File]::GetLastWriteTime('$(_NodeRestoreRushFlagPath)').Ticks) - <_NodeRestoreRushUpToDate Condition="'$(_NodeRestoreRushSubspacesEnabled)' != 'true' and '$(_NodeRestoreRushFlagTicks)' != '' and Exists('$(NodeRootFullPath)/node_modules')">true + <_NodeRestoreRushUpToDate Condition="'$(_NodeToolchainRushSubspacesEnabled)' != 'true' and '$(_NodeRestoreRushFlagTicks)' != '' and Exists('$(NodeRootFullPath)/node_modules')">true <_NodeRestoreRushGateInput Remove="@(_NodeRestoreRushGateInput)" /> - <_NodeRestoreRushGateInput Include="@(_NodeRestoreRushInstallPackageJson)" /> - <_NodeRestoreRushGateInput Include="$(_NodeRestoreWorkspaceRoot)/rush.json" /> - <_NodeRestoreRushGateInput Include="$(_NodeRestoreWorkspaceRoot)/common/config/rush/**/*" /> - <_NodeRestoreRushGateInput Include="$(_NodeRestoreWorkspaceRoot)/common/pnpm-patches/**/*" - Condition="'$(_NodeRestoreRushSubspacesEnabled)' != 'true'" /> - <_NodeRestoreRushGateInput Include="$(_NodeRestoreRushConfigurationRoot)/**/*" - Condition="'$(_NodeRestoreRushSubspacesEnabled)' == 'true'" /> + <_NodeRestoreRushGateInput Include="@(_NodeToolchainRushInstallPackageJson)" /> + <_NodeRestoreRushGateInput Include="$(_NodeOrchestratorRootPath)/rush.json" /> + <_NodeRestoreRushGateInput Include="$(_NodeOrchestratorRootPath)/common/config/rush/**/*" /> + <_NodeRestoreRushGateInput Include="$(_NodeOrchestratorRootPath)/common/pnpm-patches/**/*" + Condition="'$(_NodeToolchainRushSubspacesEnabled)' != 'true'" /> + <_NodeRestoreRushGateInput Include="$(_NodeToolchainRushConfigurationRoot)/**/*" + Condition="'$(_NodeToolchainRushSubspacesEnabled)' == 'true'" /> <_NodeRestoreRushNewerInput Remove="@(_NodeRestoreRushNewerInput)" /> <_NodeRestoreRushNewerInput Include="@(_NodeRestoreRushGateInput)" Condition="Exists('%(FullPath)') and $([System.IO.File]::GetLastWriteTime('%(FullPath)').Ticks) > $(_NodeRestoreRushFlagTicks)" /> @@ -122,16 +44,14 @@ <_NodeRestoreRushUpToDate Condition="'@(_NodeRestoreRushNewerInput)' != ''">false - <_NodeRestoreRushBootstrapFlag Remove="@(_NodeRestoreRushBootstrapFlag)" /> - <_NodeRestoreRushBootstrapFlag Include="$(_NodeRestoreWorkspaceRoot)/common/temp/install-run/*/installed.flag" /> + <_NodeRestoreRushBootstrapFlag Include="$(_NodeOrchestratorRootPath)/common/temp/install-run/*/installed.flag" /> <_NodeRestoreRushBrokenBootstrap Remove="@(_NodeRestoreRushBrokenBootstrap)" /> <_NodeRestoreRushBrokenBootstrap Include="@(_NodeRestoreRushBootstrapFlag->'%(RootDir)%(Directory)')" Condition="!Exists('%(RootDir)%(Directory)node_modules')" /> <_NodeRestoreRushGuttedEngine Remove="@(_NodeRestoreRushGuttedEngine)" /> - <_NodeRestoreRushGuttedEngine Include="$(_NodeRestoreWorkspaceRoot)/common/temp/install-run/@microsoft+rush*/installed.flag" /> + <_NodeRestoreRushGuttedEngine Include="$(_NodeOrchestratorRootPath)/common/temp/install-run/@microsoft+rush*/installed.flag" /> <_NodeRestoreRushGuttedEngineDir Remove="@(_NodeRestoreRushGuttedEngineDir)" /> <_NodeRestoreRushGuttedEngineDir Include="@(_NodeRestoreRushGuttedEngine->'%(RootDir)%(Directory)')" Condition="Exists('%(RootDir)%(Directory)node_modules') and !Exists('%(RootDir)%(Directory)node_modules/@microsoft/rush/package.json')" /> @@ -139,23 +59,18 @@ - + Text="NodeRestore: the Rush engine at '@(_NodeRestoreRushGuttedEngineDir)' is partially deleted (installed.flag present, engine package missing). Delete that folder, then run: node "$(_NodeOrchestratorRootPath)/common/scripts/install-run-rush.js" purge" /> + <_NodeExecRetryCommand>$(_NodeRestoreResolvedCommand) - - <_NodeExecRetryWorkingDirectory>$(_NodeRestoreWorkspaceRoot) + <_NodeExecRetryWorkingDirectory>$(_NodeOrchestratorRootPath) <_NodeExecRetryWorkingDirectory Condition="'$(_NodeRestoreRushProjectSelected)' == 'true'">$(NodeRootFullPath) - + Properties="_NodeExecRetryCommand=$(_NodeExecRetryCommand);_NodeExecRetryWorkingDirectory=$(_NodeExecRetryWorkingDirectory);_NodeExecRetryMutexName=$(_NodeToolchainRushMutexName)" /> diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Selection.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Selection.targets deleted file mode 100644 index 513de4b..0000000 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Selection.targets +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - <_NodeRestoreResolvedCommand>$(NodeRestoreCommand) - - - - - - - diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Yarn.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Yarn.targets index d0f0cce..2e8bb40 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Yarn.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeRestore/Yarn.targets @@ -1,64 +1,34 @@ - - $(NodeRestoreAdapterDetectDependsOn);_NodeRestoreYarnDetect - - - - - <_NodeRestoreYarnRoot>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'yarn.lock')) - - - <_NodeRestoreAdapterCandidate Include="yarn"> - 190 - $(_NodeRestoreYarnRoot) - $(NodeRootFullPath) - $(MSBuildThisFileFullPath) - - - - + Condition="'$(NodeRestoreCommand)' == '' and '$(_NodeOrchestratorOwnsRestore)' != 'true' and '$(_NodePackageManager)' == 'yarn'"> - <_NodeRestoreYarnBerry Condition="Exists('$(_NodeRestoreWorkspaceRoot)/.yarnrc.yml')">true - <_NodeRestoreHasLockfile Condition="Exists('$(_NodeRestoreWorkspaceRoot)/yarn.lock')">true - <_NodeRestoreHasLockfile Condition="'$(_NodeRestoreHasLockfile)' == ''">false + <_NodeRestoreYarnBerry Condition="Exists('$(_NodePackageManagerRootPath)/.yarnrc.yml')">true + <_NodeRestoreHasLockfile Condition="Exists('$(_NodePackageManagerRootPath)/yarn.lock')">true <_NodeRestoreUseFrozen Condition="'$(IsRunningInCI)' == 'true' and '$(_NodeRestoreHasLockfile)' == 'true'">true - <_NodeRestoreUseFrozen Condition="'$(_NodeRestoreUseFrozen)' == ''">false - - - <_NodeRestorePackageJsonPath>$(NodeRootFullPath)/package.json - <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestoreWorkspaceRoot)/yarn.lock + <_NodeRestoreLockfilePath Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodePackageManagerRootPath)/yarn.lock <_NodeRestoreIncrementalInputs>$(_NodeRestorePackageJsonPath) <_NodeRestoreIncrementalInputs Condition="'$(_NodeRestoreHasLockfile)' == 'true'">$(_NodeRestorePackageJsonPath);$(_NodeRestoreLockfilePath) - - <_NodeRestoreStampPath Condition="'$(_NodeRestoreYarnBerry)' == 'true'">$(_NodeRestoreWorkspaceRoot)/.node-restore.stamp - <_NodeRestoreStampPath Condition="'$(_NodeRestoreYarnBerry)' != 'true'">$(_NodeRestoreWorkspaceRoot)/node_modules/.node-restore.stamp - - - - + <_NodeRestoreStampPath Condition="'$(_NodeRestoreYarnBerry)' == 'true'">$(_NodePackageManagerRootPath)/.node-restore.stamp + <_NodeRestoreStampPath Condition="'$(_NodeRestoreYarnBerry)' != 'true'">$(_NodePackageManagerRootPath)/node_modules/.node-restore.stamp <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreYarnBerry)' == 'true'">yarn install - <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreYarnBerry)' != 'true' and '$(_NodeRestoreUseFrozen)' != 'true'">yarn install <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreYarnBerry)' != 'true' and '$(_NodeRestoreUseFrozen)' == 'true'">yarn install --frozen-lockfile + <_NodeRestoreResolvedCommand Condition="'$(_NodeRestoreResolvedCommand)' == ''">yarn install - - + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain.targets new file mode 100644 index 0000000..6a802c9 --- /dev/null +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain.targets @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + <_NodePackageManager>@(_NodeSelectedPackageManager->'%(Identity)') + <_NodePackageManagerRootPath>@(_NodeSelectedPackageManager->'%(RootPath)') + <_NodeOrchestrator>@(_NodeSelectedOrchestrator->'%(Identity)') + <_NodeOrchestratorRootPath>@(_NodeSelectedOrchestrator->'%(RootPath)') + <_NodeOrchestratorOwnsRestore>@(_NodeSelectedOrchestrator->'%(OwnsRestore)') + <_NodeOrchestratorOwnsBuild>@(_NodeSelectedOrchestrator->'%(OwnsBuild)') + + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Bun.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Bun.targets new file mode 100644 index 0000000..6df93ad --- /dev/null +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Bun.targets @@ -0,0 +1,22 @@ + + + $(NodePackageManagerDetectDependsOn);_NodeToolchainBunDetect + + + + + <_NodeToolchainBunLockRootPath>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'bun.lock')) + <_NodeToolchainBunLockbRootPath>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'bun.lockb')) + <_NodeToolchainBunRootPath>$(_NodeToolchainBunLockRootPath) + <_NodeToolchainBunRootPath Condition="'$(_NodeToolchainBunLockbRootPath)' != '' and ('$(_NodeToolchainBunLockRootPath)' == '' or $([System.String]::Copy('$(_NodeToolchainBunLockbRootPath)').Length) > $([System.String]::Copy('$(_NodeToolchainBunLockRootPath)').Length))">$(_NodeToolchainBunLockbRootPath) + + + <_NodePackageManagerCandidate Include="bun"> + 180 + $(_NodeToolchainBunRootPath) + $(NodeRootFullPath) + $(MSBuildThisFileFullPath) + + + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Npm.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Npm.targets new file mode 100644 index 0000000..a52e856 --- /dev/null +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Npm.targets @@ -0,0 +1,20 @@ + + + $(NodePackageManagerDetectDependsOn);_NodeToolchainNpmDetect + + + + + <_NodeToolchainNpmRootPath>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'package-lock.json')) + + + + <_NodePackageManagerCandidate Include="npm"> + 0 + $(_NodeToolchainNpmRootPath) + $(NodeRootFullPath) + $(MSBuildThisFileFullPath) + + + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Pnpm.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Pnpm.targets new file mode 100644 index 0000000..662cd03 --- /dev/null +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Pnpm.targets @@ -0,0 +1,19 @@ + + + $(NodePackageManagerDetectDependsOn);_NodeToolchainPnpmDetect + + + + + <_NodeToolchainPnpmRootPath>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'pnpm-lock.yaml')) + + + <_NodePackageManagerCandidate Include="pnpm"> + 200 + $(_NodeToolchainPnpmRootPath) + $(NodeRootFullPath) + $(MSBuildThisFileFullPath) + + + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Rush.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Rush.targets new file mode 100644 index 0000000..199ee4e --- /dev/null +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Rush.targets @@ -0,0 +1,48 @@ + + + $(NodeOrchestratorDetectDependsOn);_NodeToolchainRushDetect + + + + + <_NodeToolchainRushDetectedRootPath>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'rush.json')) + + + <_NodeOrchestratorCandidate Include="rush"> + 300 + $(_NodeToolchainRushDetectedRootPath) + true + true + $(MSBuildThisFileFullPath) + + + + + + + + + + + + + + + + + <_NodeToolchainRushRootPathNormalized>$([System.String]::Copy('$(_NodeOrchestratorRootPath)').Replace('\', '/').TrimEnd('/')) + <_NodeToolchainRushMutexName>TALXIS.Rush.$([System.String]::Copy('$(_NodeToolchainRushRootPathNormalized)').Replace('/', '_').Replace(':', '_').ToLowerInvariant()) + + + + <_NodeOrchestrator> + <_NodeOrchestratorRootPath> + <_NodeOrchestratorOwnsRestore>false + <_NodeOrchestratorOwnsBuild>false + + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Yarn.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Yarn.targets new file mode 100644 index 0000000..8cdb8e7 --- /dev/null +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Yarn.targets @@ -0,0 +1,19 @@ + + + $(NodePackageManagerDetectDependsOn);_NodeToolchainYarnDetect + + + + + <_NodeToolchainYarnRootPath>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'yarn.lock')) + + + <_NodePackageManagerCandidate Include="yarn"> + 190 + $(_NodeToolchainYarnRootPath) + $(NodeRootFullPath) + $(MSBuildThisFileFullPath) + + + + diff --git a/src/Sdk/Sdk/Sdk.NodeRestore.targets b/src/Sdk/Sdk/Sdk.NodeRestore.targets index e1e805d..7424a12 100644 --- a/src/Sdk/Sdk/Sdk.NodeRestore.targets +++ b/src/Sdk/Sdk/Sdk.NodeRestore.targets @@ -35,16 +35,16 @@ - + - <_NodeRestoreAnchorNodeRoot>$(NodeRootPath) - <_NodeRestoreAnchorNodeRoot Condition="'$(_NodeRestoreAnchorNodeRoot)' == ''">$(TypeScriptDir) - <_NodeRestoreAnchorNodeRoot Condition="'$(_NodeRestoreAnchorNodeRoot)' == ''">. - <_NodeRestoreAnchorHasPackageJson Condition="Exists('$(MSBuildProjectDirectory)/$(_NodeRestoreAnchorNodeRoot)/package.json')">true + <_NodeRestoreAnchorNodeRootFullPath>$(NodeRootFullPath) + <_NodeRestoreAnchorNodeRoot Condition="'$(_NodeRestoreAnchorNodeRootFullPath)' == ''">$(NodeRootPath) + <_NodeRestoreAnchorNodeRoot Condition="'$(_NodeRestoreAnchorNodeRootFullPath)' == '' and '$(_NodeRestoreAnchorNodeRoot)' == ''">$(TypeScriptDir) + <_NodeRestoreAnchorNodeRoot Condition="'$(_NodeRestoreAnchorNodeRootFullPath)' == '' and '$(_NodeRestoreAnchorNodeRoot)' == ''">. + <_NodeRestoreAnchorNodeRootFullPath Condition="'$(_NodeRestoreAnchorNodeRootFullPath)' == ''">$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(_NodeRestoreAnchorNodeRoot)')) + <_NodeRestoreAnchorHasPackageJson Condition="Exists('$(_NodeRestoreAnchorNodeRootFullPath)/package.json')">true + their provider on @(NodeSelectedOrchestrator); OwnsBuild metadata suppresses Direct. --> production development @@ -12,5 +12,5 @@ - + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets index 0cf0a4d..cc86427 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets @@ -1,12 +1,12 @@ + Condition="'$(_NodeOrchestratorOwnsBuild)' != 'true' and ('$(_NodePackageManager)' == 'npm' or '$(_NodePackageManager)' == 'pnpm' or '$(_NodePackageManager)' == 'yarn' or '$(_NodePackageManager)' == 'bun')"> <_NodeBuildDirectCommand Condition="'$(_NodePackageManager)' == 'pnpm'">pnpm run build -- <_NodeBuildDirectCommand Condition="'$(_NodePackageManager)' == 'yarn'">yarn run build <_NodeBuildDirectCommand Condition="'$(_NodePackageManager)' == 'bun'">bun run build - <_NodeBuildDirectCommand Condition="'$(_NodeBuildDirectCommand)' == ''">npm run build -- + <_NodeBuildDirectCommand Condition="'$(_NodePackageManager)' == 'npm'">npm run build -- <_NodeBuildModeArgs Condition="'$(_NodeBuildModeArgName)' != ''">--$(_NodeBuildModeArgName) $(NodeBuildConfiguration) diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain.targets index 6a802c9..24c3c5e 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain.targets @@ -3,9 +3,11 @@ Resolves the Node package manager and build orchestrator independently. External NuGet packages register detection targets by appending to - NodePackageManagerDetectDependsOn or NodeOrchestratorDetectDependsOn. Candidates must provide + NodePackageManagerDetectDependsOn or NodeOrchestratorDetectDependsOn and add public + NodePackageManagerCandidate or NodeOrchestratorCandidate items. Candidates must provide Priority, RootPath, and Source metadata. Orchestrators may additionally declare OwnsRestore - and OwnsBuild. + and OwnsBuild. The selected items remain public so lifecycle providers can consume their + identity and metadata without depending on private SDK properties. --> @@ -15,21 +17,27 @@ - + + + + - - + + - <_NodePackageManager>@(_NodeSelectedPackageManager->'%(Identity)') - <_NodePackageManagerRootPath>@(_NodeSelectedPackageManager->'%(RootPath)') - <_NodeOrchestrator>@(_NodeSelectedOrchestrator->'%(Identity)') - <_NodeOrchestratorRootPath>@(_NodeSelectedOrchestrator->'%(RootPath)') - <_NodeOrchestratorOwnsRestore>@(_NodeSelectedOrchestrator->'%(OwnsRestore)') - <_NodeOrchestratorOwnsBuild>@(_NodeSelectedOrchestrator->'%(OwnsBuild)') + <_NodePackageManager>@(NodeSelectedPackageManager->'%(Identity)') + <_NodePackageManagerRootPath>@(NodeSelectedPackageManager->'%(RootPath)') + <_NodeOrchestrator>@(NodeSelectedOrchestrator->'%(Identity)') + <_NodeOrchestratorRootPath>@(NodeSelectedOrchestrator->'%(RootPath)') + <_NodeOrchestratorOwnsRestore>@(NodeSelectedOrchestrator->'%(OwnsRestore)') + <_NodeOrchestratorOwnsBuild>@(NodeSelectedOrchestrator->'%(OwnsBuild)') + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Bun.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Bun.targets index 6df93ad..963e75a 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Bun.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Bun.targets @@ -11,12 +11,12 @@ <_NodeToolchainBunRootPath Condition="'$(_NodeToolchainBunLockbRootPath)' != '' and ('$(_NodeToolchainBunLockRootPath)' == '' or $([System.String]::Copy('$(_NodeToolchainBunLockbRootPath)').Length) > $([System.String]::Copy('$(_NodeToolchainBunLockRootPath)').Length))">$(_NodeToolchainBunLockbRootPath) - <_NodePackageManagerCandidate Include="bun"> + 180 $(_NodeToolchainBunRootPath) $(NodeRootFullPath) $(MSBuildThisFileFullPath) - + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Npm.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Npm.targets index a52e856..e64a36a 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Npm.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Npm.targets @@ -9,12 +9,12 @@ - <_NodePackageManagerCandidate Include="npm"> + 0 $(_NodeToolchainNpmRootPath) $(NodeRootFullPath) $(MSBuildThisFileFullPath) - + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Pnpm.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Pnpm.targets index 662cd03..63e260a 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Pnpm.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Pnpm.targets @@ -8,12 +8,12 @@ <_NodeToolchainPnpmRootPath>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'pnpm-lock.yaml')) - <_NodePackageManagerCandidate Include="pnpm"> + 200 $(_NodeToolchainPnpmRootPath) $(NodeRootFullPath) $(MSBuildThisFileFullPath) - + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Rush.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Rush.targets index 199ee4e..69faa44 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Rush.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Rush.targets @@ -8,13 +8,13 @@ <_NodeToolchainRushDetectedRootPath>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'rush.json')) - <_NodeOrchestratorCandidate Include="rush"> + 300 $(_NodeToolchainRushDetectedRootPath) true true $(MSBuildThisFileFullPath) - + @@ -44,5 +44,8 @@ <_NodeOrchestratorOwnsRestore>false <_NodeOrchestratorOwnsBuild>false + + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Yarn.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Yarn.targets index 8cdb8e7..75a7aca 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Yarn.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeToolchain/Yarn.targets @@ -8,12 +8,12 @@ <_NodeToolchainYarnRootPath>$([MSBuild]::GetDirectoryNameOfFileAbove('$(NodeRootFullPath)', 'yarn.lock')) - <_NodePackageManagerCandidate Include="yarn"> + 190 $(_NodeToolchainYarnRootPath) $(NodeRootFullPath) $(MSBuildThisFileFullPath) - + From 4f7f042cd727dfb2f9db2ba816e1b0cc668ab70f Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 10 Aug 2026 16:51:06 +0200 Subject: [PATCH 18/20] Clarify TypeScriptDir compatibility contract Keep existing ScriptLibrary projects on TypeScriptDir without migration and document its unchanged normalized path semantics across normal and cold-cache restore evaluation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/BuildProcess.md | 2 +- docs/NodeDependencies.md | 6 +++--- src/Dataverse/ScriptLibrary/README.md | 14 +++++++------- .../Tasks/msbuild/tasks/Props/ProjectPaths.props | 10 +++++----- src/Sdk/Sdk/Sdk.NodeRestore.targets | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/BuildProcess.md b/docs/BuildProcess.md index 5b44fb5..dc00837 100644 --- a/docs/BuildProcess.md +++ b/docs/BuildProcess.md @@ -233,7 +233,7 @@ Main hooks: - `GetScriptLibraryOutputs` - `GetSuppressedScriptLibraryReferences` -The package expects sources under `$(NodeRootFullPath)` (default: project directory itself), hydrates dependencies via the shared `NodeRestore` target (see [NodeDependencies.md](NodeDependencies.md)), builds through the selected orchestrator or package manager (see [NodeDependencies.md](NodeDependencies.md#build-delegation-to-rush)), copies the selected main JS file to `$(TargetDir)`, and lets Solution builds query which referenced script libraries are `CompileOnly` and therefore should not be deployed as separate web resources. Standalone `npm` packaging of a ScriptLibrary is planned but not yet implemented, so it does not currently set `IsPackable=false`. +The package expects sources under the Node root configured by `TypeScriptDir` or `NodeRootPath` (default: project directory itself). Existing `TypeScriptDir` project settings remain supported without migration. It hydrates dependencies via the shared `NodeRestore` target (see [NodeDependencies.md](NodeDependencies.md)), builds through the selected orchestrator or package manager (see [NodeDependencies.md](NodeDependencies.md#build-delegation-to-rush)), copies the selected main JS file to `$(TargetDir)`, and lets Solution builds query which referenced script libraries are `CompileOnly` and therefore should not be deployed as separate web resources. Standalone `npm` packaging of a ScriptLibrary is planned but not yet implemented, so it does not currently set `IsPackable=false`. ### CodeApp diff --git a/docs/NodeDependencies.md b/docs/NodeDependencies.md index 1b40c0b..7aec25b 100644 --- a/docs/NodeDependencies.md +++ b/docs/NodeDependencies.md @@ -48,7 +48,7 @@ underlying Node tool: worst case it's a cheap no-op via the existing incremental gate (non-Rush) or the Rush up-to-date gate; it never silently skips Node hydration just because NuGet's own restore step was skipped. -On a cold NuGet cache, the SDK re-evaluates each eligible Node project after package restore downloads the Tasks package, so the same `dotnet restore` invocation can run `NodeRestore`. The anchor computes the Node root locally in that cold-cache evaluation using the same `NodeRootPath` > `TypeScriptDir` > `.` precedence as `ProjectPaths.props`. +On a cold NuGet cache, the SDK re-evaluates each eligible Node project after package restore downloads the Tasks package, so the same `dotnet restore` invocation can run `NodeRestore`. The anchor computes the Node root locally in that cold-cache evaluation using the same `NodeRootPath` > `TypeScriptDir` > `.` precedence as `ProjectPaths.props`. Existing ScriptLibrary projects that set only `TypeScriptDir` continue to work unchanged. ## Build delegation to Rush @@ -167,8 +167,8 @@ This means a Rush repository normally resolves both its underlying package manag | `NodePackageManager` | _(auto)_ | Package manager: `npm`, `pnpm`, `yarn`, `bun`, or `None`. `None` skips dependency hydration. | | `NodeOrchestrator` | _(auto)_ | Orchestrator: `rush` or `None`. `None` disables orchestrator ownership while retaining package-manager detection. | | `NodeRestoreCommand` | _(empty)_ | Exact restore command override. It runs from `NodeRootFullPath` on every invocation and suppresses built-in restore providers. | -| `NodeRootPath` | `.` | Relative Node project root. It takes precedence over legacy `TypeScriptDir`. | -| `TypeScriptDir` | normalized `NodeRootFullPath` | Compatibility property. When only this legacy property is supplied it feeds `NodeRootPath`; after evaluation it contains the normalized absolute Node root. | +| `TypeScriptDir` | project directory | Fully supported ScriptLibrary Node-root setting. Existing projects do not need to rename it. Relative values resolve against the project directory, and the evaluated property remains the normalized absolute path as before. | +| `NodeRootPath` | `TypeScriptDir`, then `.` | Cross-project Node-root setting for Pcf, ScriptLibrary, and CodeApp. It takes precedence only when both properties are explicitly supplied. | | `IsRunningInCI` | _(auto)_ | Selects frozen/reproducible install commands. | External package-manager detection targets append to `NodePackageManagerDetectDependsOn` and add `NodePackageManagerCandidate` items. External orchestrators use `NodeOrchestratorDetectDependsOn` and `NodeOrchestratorCandidate`. Candidates provide `Priority`, `RootPath`, and `Source`; orchestrators may also set `OwnsRestore` and `OwnsBuild`. The winning items are exposed as `NodeSelectedPackageManager` and `NodeSelectedOrchestrator`, with custom metadata preserved. Providers hook the public `NodeRestore` or `NodeBuild` target with normal `BeforeTargets`/`AfterTargets`, gate on those selected items, and use explicit dependencies for their own internal ordering. diff --git a/src/Dataverse/ScriptLibrary/README.md b/src/Dataverse/ScriptLibrary/README.md index 38b1958..fdc3cdb 100644 --- a/src/Dataverse/ScriptLibrary/README.md +++ b/src/Dataverse/ScriptLibrary/README.md @@ -20,7 +20,7 @@ Or use the SDK approach: ## Prerequisites -When `RunNodeBuild` is `true` (auto-detected from the presence of `package.json` in `NodeRootPath`): +When `RunNodeBuild` is `true` (auto-detected from the presence of `package.json` in the configured Node root): - **Node.js** must be available on `PATH` @@ -32,9 +32,9 @@ The package sets `ProjectType` to `ScriptLibrary` and disables `GenerateAssembly ### Build-time targets -1. **CheckScriptLibraryPrereqs** -- validates that `NodeRootPath` exists, `package.json` is present, and `node` is on `PATH` (package manager presence is checked by `NodeRestore` itself, since it depends on what's detected). -2. **BuildTypeScript** (runs before `Build`) -- calls the shared `NodeRestore` target (auto-detected package manager) followed by the shared `NodeBuild` target in `NodeRootPath`. -3. **CopyScriptLibraryMainToOutput** (runs after `Build`) -- copies the main JS file from `NodeRootPath/build/` to the output directory. +1. **CheckScriptLibraryPrereqs** -- validates that the configured Node root exists, `package.json` is present, and `node` is on `PATH` (package manager presence is checked by `NodeRestore` itself, since it depends on what's detected). +2. **BuildTypeScript** (runs before `Build`) -- calls the shared `NodeRestore` target (auto-detected package manager) followed by the shared `NodeBuild` target in the configured Node root. +3. **CopyScriptLibraryMainToOutput** (runs after `Build`) -- copies the main JS file from the configured Node root's `build/` directory to the output directory. ### Integration targets @@ -71,10 +71,11 @@ CompileOnly removes the referenced project from the Solution's standalone-deploy | Property | Default | Description | |----------|---------|-------------| | `ProjectType` | `ScriptLibrary` | Marks the project for reference discovery by Solution projects. | -| `RunNodeBuild` | Auto-detected | Set to `true` to restore Node dependencies via `NodeRestore` and run `NodeBuild`. Defaults to `true` if `package.json` exists in `NodeRootPath`. | +| `RunNodeBuild` | Auto-detected | Set to `true` to restore Node dependencies via `NodeRestore` and run `NodeBuild`. Defaults to `true` if `package.json` exists in the configured Node root. | | `NodePackageManager` | Auto-detected | `npm`, `pnpm`, `yarn`, `bun`, or `None`. | | `NodeOrchestrator` | Auto-detected | `rush` or `None`. | -| `NodeRootPath` | `.` | Relative path to the Node project root (`package.json`, sources). Resolved against the project directory. Projects with sources in a subdirectory set e.g. `src`. | +| `TypeScriptDir` | project directory | Existing ScriptLibrary setting for the Node/TypeScript project root. It remains fully supported; no project migration is required. Relative values such as `TS` resolve against the project directory and evaluate to the normalized absolute path. | +| `NodeRootPath` | `TypeScriptDir`, then `.` | Equivalent cross-project Node-root setting. If both are explicitly set, `NodeRootPath` wins. | | `ScriptLibraryMainFile` | _(none)_ | Main script file path used by consuming targets. | | `` metadata `ScriptLibraryMode` | `Separate` | Controls the relationship to another referenced ScriptLibrary project: `Separate` or `CompileOnly`. See [Cross-ScriptLibrary references](#cross-scriptlibrary-references). | | `LangVersion` | `latest` | C# language version for the project. | @@ -84,4 +85,3 @@ CompileOnly removes the referenced project from the Solution's standalone-deploy - **Depends on**: `TALXIS.DevKit.Build.Dataverse.Tasks` - **Consumed by**: `TALXIS.DevKit.Build.Dataverse.Solution` projects via `ProjectReference` - diff --git a/src/Dataverse/Tasks/msbuild/tasks/Props/ProjectPaths.props b/src/Dataverse/Tasks/msbuild/tasks/Props/ProjectPaths.props index 085a87e..bd6a180 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Props/ProjectPaths.props +++ b/src/Dataverse/Tasks/msbuild/tasks/Props/ProjectPaths.props @@ -14,14 +14,14 @@ $([System.IO.Path]::GetFullPath($(MSBuildProjectDirectory)/$(SolutionRootPath))) - + $(TypeScriptDir) . $([MSBuild]::NormalizePath($(MSBuildProjectDirectory), $(NodeRootPath))) - + $(NodeRootFullPath) \ No newline at end of file diff --git a/src/Sdk/Sdk/Sdk.NodeRestore.targets b/src/Sdk/Sdk/Sdk.NodeRestore.targets index 7424a12..ade5523 100644 --- a/src/Sdk/Sdk/Sdk.NodeRestore.targets +++ b/src/Sdk/Sdk/Sdk.NodeRestore.targets @@ -37,7 +37,7 @@ + precedence locally: NodeRootPath > TypeScriptDir > ".". --> <_NodeRestoreAnchorNodeRootFullPath>$(NodeRootFullPath) <_NodeRestoreAnchorNodeRoot Condition="'$(_NodeRestoreAnchorNodeRootFullPath)' == ''">$(NodeRootPath) From 737893baf2c0261bf0dc5c496e0e5b068150786d Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 10 Aug 2026 16:55:17 +0200 Subject: [PATCH 19/20] Hide TypeScriptDir compatibility from docs Document NodeRootPath as the sole configuration surface while retaining a time-bounded compatibility bridge in implementation comments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/BuildProcess.md | 2 +- docs/NodeDependencies.md | 5 ++--- src/Dataverse/ScriptLibrary/README.md | 3 +-- .../Tasks/msbuild/tasks/Props/ProjectPaths.props | 10 +++++----- src/Sdk/Sdk/Sdk.NodeRestore.targets | 3 ++- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/docs/BuildProcess.md b/docs/BuildProcess.md index dc00837..e1d0cb2 100644 --- a/docs/BuildProcess.md +++ b/docs/BuildProcess.md @@ -233,7 +233,7 @@ Main hooks: - `GetScriptLibraryOutputs` - `GetSuppressedScriptLibraryReferences` -The package expects sources under the Node root configured by `TypeScriptDir` or `NodeRootPath` (default: project directory itself). Existing `TypeScriptDir` project settings remain supported without migration. It hydrates dependencies via the shared `NodeRestore` target (see [NodeDependencies.md](NodeDependencies.md)), builds through the selected orchestrator or package manager (see [NodeDependencies.md](NodeDependencies.md#build-delegation-to-rush)), copies the selected main JS file to `$(TargetDir)`, and lets Solution builds query which referenced script libraries are `CompileOnly` and therefore should not be deployed as separate web resources. Standalone `npm` packaging of a ScriptLibrary is planned but not yet implemented, so it does not currently set `IsPackable=false`. +The package expects sources under `$(NodeRootPath)` (default: project directory itself), hydrates dependencies via the shared `NodeRestore` target (see [NodeDependencies.md](NodeDependencies.md)), builds through the selected orchestrator or package manager (see [NodeDependencies.md](NodeDependencies.md#build-delegation-to-rush)), copies the selected main JS file to `$(TargetDir)`, and lets Solution builds query which referenced script libraries are `CompileOnly` and therefore should not be deployed as separate web resources. Standalone `npm` packaging of a ScriptLibrary is planned but not yet implemented, so it does not currently set `IsPackable=false`. ### CodeApp diff --git a/docs/NodeDependencies.md b/docs/NodeDependencies.md index 7aec25b..ac76e36 100644 --- a/docs/NodeDependencies.md +++ b/docs/NodeDependencies.md @@ -48,7 +48,7 @@ underlying Node tool: worst case it's a cheap no-op via the existing incremental gate (non-Rush) or the Rush up-to-date gate; it never silently skips Node hydration just because NuGet's own restore step was skipped. -On a cold NuGet cache, the SDK re-evaluates each eligible Node project after package restore downloads the Tasks package, so the same `dotnet restore` invocation can run `NodeRestore`. The anchor computes the Node root locally in that cold-cache evaluation using the same `NodeRootPath` > `TypeScriptDir` > `.` precedence as `ProjectPaths.props`. Existing ScriptLibrary projects that set only `TypeScriptDir` continue to work unchanged. +On a cold NuGet cache, the SDK re-evaluates each eligible Node project after package restore downloads the Tasks package, so the same `dotnet restore` invocation can run `NodeRestore`. ## Build delegation to Rush @@ -167,8 +167,7 @@ This means a Rush repository normally resolves both its underlying package manag | `NodePackageManager` | _(auto)_ | Package manager: `npm`, `pnpm`, `yarn`, `bun`, or `None`. `None` skips dependency hydration. | | `NodeOrchestrator` | _(auto)_ | Orchestrator: `rush` or `None`. `None` disables orchestrator ownership while retaining package-manager detection. | | `NodeRestoreCommand` | _(empty)_ | Exact restore command override. It runs from `NodeRootFullPath` on every invocation and suppresses built-in restore providers. | -| `TypeScriptDir` | project directory | Fully supported ScriptLibrary Node-root setting. Existing projects do not need to rename it. Relative values resolve against the project directory, and the evaluated property remains the normalized absolute path as before. | -| `NodeRootPath` | `TypeScriptDir`, then `.` | Cross-project Node-root setting for Pcf, ScriptLibrary, and CodeApp. It takes precedence only when both properties are explicitly supplied. | +| `NodeRootPath` | `.` | Relative Node project root for Pcf, ScriptLibrary, and CodeApp. | | `IsRunningInCI` | _(auto)_ | Selects frozen/reproducible install commands. | External package-manager detection targets append to `NodePackageManagerDetectDependsOn` and add `NodePackageManagerCandidate` items. External orchestrators use `NodeOrchestratorDetectDependsOn` and `NodeOrchestratorCandidate`. Candidates provide `Priority`, `RootPath`, and `Source`; orchestrators may also set `OwnsRestore` and `OwnsBuild`. The winning items are exposed as `NodeSelectedPackageManager` and `NodeSelectedOrchestrator`, with custom metadata preserved. Providers hook the public `NodeRestore` or `NodeBuild` target with normal `BeforeTargets`/`AfterTargets`, gate on those selected items, and use explicit dependencies for their own internal ordering. diff --git a/src/Dataverse/ScriptLibrary/README.md b/src/Dataverse/ScriptLibrary/README.md index fdc3cdb..4a5d2de 100644 --- a/src/Dataverse/ScriptLibrary/README.md +++ b/src/Dataverse/ScriptLibrary/README.md @@ -74,8 +74,7 @@ CompileOnly removes the referenced project from the Solution's standalone-deploy | `RunNodeBuild` | Auto-detected | Set to `true` to restore Node dependencies via `NodeRestore` and run `NodeBuild`. Defaults to `true` if `package.json` exists in the configured Node root. | | `NodePackageManager` | Auto-detected | `npm`, `pnpm`, `yarn`, `bun`, or `None`. | | `NodeOrchestrator` | Auto-detected | `rush` or `None`. | -| `TypeScriptDir` | project directory | Existing ScriptLibrary setting for the Node/TypeScript project root. It remains fully supported; no project migration is required. Relative values such as `TS` resolve against the project directory and evaluate to the normalized absolute path. | -| `NodeRootPath` | `TypeScriptDir`, then `.` | Equivalent cross-project Node-root setting. If both are explicitly set, `NodeRootPath` wins. | +| `NodeRootPath` | `.` | Relative path to the Node project root (`package.json`, sources). Resolved against the project directory. Projects with sources in a subdirectory set e.g. `src`. | | `ScriptLibraryMainFile` | _(none)_ | Main script file path used by consuming targets. | | `` metadata `ScriptLibraryMode` | `Separate` | Controls the relationship to another referenced ScriptLibrary project: `Separate` or `CompileOnly`. See [Cross-ScriptLibrary references](#cross-scriptlibrary-references). | | `LangVersion` | `latest` | C# language version for the project. | diff --git a/src/Dataverse/Tasks/msbuild/tasks/Props/ProjectPaths.props b/src/Dataverse/Tasks/msbuild/tasks/Props/ProjectPaths.props index bd6a180..7c345f0 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Props/ProjectPaths.props +++ b/src/Dataverse/Tasks/msbuild/tasks/Props/ProjectPaths.props @@ -14,14 +14,14 @@ $([System.IO.Path]::GetFullPath($(MSBuildProjectDirectory)/$(SolutionRootPath))) - + $(TypeScriptDir) . $([MSBuild]::NormalizePath($(MSBuildProjectDirectory), $(NodeRootPath))) - + $(NodeRootFullPath) \ No newline at end of file diff --git a/src/Sdk/Sdk/Sdk.NodeRestore.targets b/src/Sdk/Sdk/Sdk.NodeRestore.targets index ade5523..1409a96 100644 --- a/src/Sdk/Sdk/Sdk.NodeRestore.targets +++ b/src/Sdk/Sdk/Sdk.NodeRestore.targets @@ -37,7 +37,8 @@ + precedence locally, including TypeScriptDir compatibility for the next couple of + releases: NodeRootPath > TypeScriptDir > ".". --> <_NodeRestoreAnchorNodeRootFullPath>$(NodeRootFullPath) <_NodeRestoreAnchorNodeRoot Condition="'$(_NodeRestoreAnchorNodeRootFullPath)' == ''">$(NodeRootPath) From 54d1c458ac3207915f4884cdc6fa8bd8dc8f6ba9 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 10 Aug 2026 23:37:39 +0200 Subject: [PATCH 20/20] Unify built-in and external Node providers Split package-manager build providers, expose structured build arguments, and move Rush lifecycle eligibility into detection so every provider consumes the same selected-item contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/MSBuildConventions.md | 27 +++++++--- docs/NodeDependencies.md | 6 ++- ...XIS.DevKit.Build.Dataverse.CodeApp.targets | 9 ++-- .../TALXIS.DevKit.Build.Dataverse.Pcf.targets | 7 ++- .../msbuild/tasks/Targets/NodeBuild.targets | 11 ++-- .../tasks/Targets/NodeBuild/Bun.targets | 9 ++++ .../tasks/Targets/NodeBuild/Direct.targets | 16 ------ .../tasks/Targets/NodeBuild/Npm.targets | 9 ++++ .../tasks/Targets/NodeBuild/Pnpm.targets | 9 ++++ .../tasks/Targets/NodeBuild/Rush.targets | 52 +++++-------------- .../tasks/Targets/NodeBuild/Yarn.targets | 9 ++++ .../tasks/Targets/NodeRestore/Bun.targets | 17 +++--- .../tasks/Targets/NodeRestore/Npm.targets | 17 +++--- .../tasks/Targets/NodeRestore/Pnpm.targets | 15 +++--- .../tasks/Targets/NodeRestore/Rush.targets | 26 +++++----- .../tasks/Targets/NodeRestore/Yarn.targets | 21 ++++---- .../tasks/Targets/NodeToolchain.targets | 8 --- .../tasks/Targets/NodeToolchain/Rush.targets | 40 +++++--------- 18 files changed, 156 insertions(+), 152 deletions(-) create mode 100644 src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Bun.targets delete mode 100644 src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets create mode 100644 src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Npm.targets create mode 100644 src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Pnpm.targets create mode 100644 src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Yarn.targets diff --git a/docs/MSBuildConventions.md b/docs/MSBuildConventions.md index 8b0be7f..a96ecb4 100644 --- a/docs/MSBuildConventions.md +++ b/docs/MSBuildConventions.md @@ -9,8 +9,8 @@ This document describes naming, layout, and extension conventions used by the TA | Public entry target | `` | `NodeToolchain`, `NodeRestore`, `NodeBuild` | | Private implementation target | `_` | `_NodeToolchainPnpmDetect`, `_NodeRestoreRushRun` | | Public property | `` | `NodePackageManager`, `NodeOrchestrator` | -| Private property/item | `_` | `_NodePackageManagerRootPath`, `_NodeToolchainRushTempRoot` | -| Public extension item | `` | `NodePackageManagerCandidate`, `NodeSelectedOrchestrator` | +| Private property/item | `_` | `_NodeRestoreNpmRootPath`, `_NodeToolchainRushTempRoot` | +| Public extension item | `` | `NodePackageManagerCandidate`, `NodeSelectedOrchestrator`, `NodeBuildArgument` | | Extension dependency property | `DetectDependsOn` | `NodePackageManagerDetectDependsOn` | An underscore marks an implementation detail. Consumers may rely on public targets, properties, and extension items, but must not call private targets or inspect private state. @@ -39,7 +39,10 @@ Targets/ Retry.targets shared Rush mutex/retry target NodeBuild.targets public Node build entry point NodeBuild/ - Direct.targets build through the selected package manager + Npm.targets npm build provider + Pnpm.targets pnpm build provider + Yarn.targets Yarn build provider + Bun.targets Bun build provider Rush.targets build through Rush Tasks/Node/ @@ -56,7 +59,7 @@ External NuGet packages extend detection by appending targets to: - `NodePackageManagerDetectDependsOn` - `NodeOrchestratorDetectDependsOn` -A detection target adds `NodePackageManagerCandidate` or `NodeOrchestratorCandidate` items. Each item uses its identity as the public value and supplies `Priority`, `RootPath`, and `Source` metadata. Orchestrators may additionally supply `OwnsRestore` and `OwnsBuild`. +A detection target adds `NodePackageManagerCandidate` or `NodeOrchestratorCandidate` items. Each item uses its identity as the public value and supplies `Priority`, `RootPath`, and `Source` metadata. Orchestrators set `OwnsRestore` and `OwnsBuild` for the current project; a selected orchestrator with both values `false` is detected but does not own either lifecycle. ```xml @@ -79,14 +82,26 @@ A detection target adds `NodePackageManagerCandidate` or `NodeOrchestratorCandid Selection rejects duplicate identities, invalid priorities, equal winning priorities, missing roots, and explicit values that do not match a registered candidate. -The public `NodeToolchain` target performs resolution. The selected candidates are exposed as read-only `NodeSelectedPackageManager` and `NodeSelectedOrchestrator` items, with all candidate metadata preserved. Providers consume these items but do not add or remove them. Provider execution uses normal `BeforeTargets`/`AfterTargets` hooks on the public `NodeRestore` and `NodeBuild` targets: +The public `NodeToolchain` target performs resolution. The selected candidates are exposed as read-only `NodeSelectedPackageManager` and `NodeSelectedOrchestrator` items, with all candidate metadata preserved. Providers consume these items but do not add or remove them. Built-in providers use the same normal `BeforeTargets`/`AfterTargets` hooks as external packages. + +`NodeBuildArgument` items carry project-type build arguments for every provider: + +```xml + + + $(NodeBuildConfiguration) + + +``` + +An argument forwarded through Rush additionally supplies `RushParameterName`, the exact custom parameter declared in Rush `command-line.json`. Other providers ignore that metadata. ```xml + Command="contoso build @(NodeBuildArgument->'%(Identity) %(Value)', ' ')" /> ``` diff --git a/docs/NodeDependencies.md b/docs/NodeDependencies.md index ac76e36..8f358e6 100644 --- a/docs/NodeDependencies.md +++ b/docs/NodeDependencies.md @@ -158,7 +158,7 @@ without the archived-cache performance benefit. | Package manager | `package-lock.json`, or no stronger marker | `npm` | 0 | | Orchestrator | `rush.json` | `rush` | 300 | -This means a Rush repository normally resolves both its underlying package manager (for example `pnpm`) and `rush`. Rush owns restore/build only for projects registered in `rush.json`; an unregistered project keeps the selected package-manager path. +This means a Rush repository normally resolves both its underlying package manager (for example `pnpm`) and `rush`. Rush owns restore/build only for projects registered in `rush.json`; an unregistered project remains a non-owning selected orchestrator and uses the selected package-manager path. ## Configuration @@ -170,7 +170,9 @@ This means a Rush repository normally resolves both its underlying package manag | `NodeRootPath` | `.` | Relative Node project root for Pcf, ScriptLibrary, and CodeApp. | | `IsRunningInCI` | _(auto)_ | Selects frozen/reproducible install commands. | -External package-manager detection targets append to `NodePackageManagerDetectDependsOn` and add `NodePackageManagerCandidate` items. External orchestrators use `NodeOrchestratorDetectDependsOn` and `NodeOrchestratorCandidate`. Candidates provide `Priority`, `RootPath`, and `Source`; orchestrators may also set `OwnsRestore` and `OwnsBuild`. The winning items are exposed as `NodeSelectedPackageManager` and `NodeSelectedOrchestrator`, with custom metadata preserved. Providers hook the public `NodeRestore` or `NodeBuild` target with normal `BeforeTargets`/`AfterTargets`, gate on those selected items, and use explicit dependencies for their own internal ordering. +External package-manager detection targets append to `NodePackageManagerDetectDependsOn` and add `NodePackageManagerCandidate` items. External orchestrators use `NodeOrchestratorDetectDependsOn` and `NodeOrchestratorCandidate`. Candidates provide `Priority`, `RootPath`, and `Source`; orchestrators set `OwnsRestore` and `OwnsBuild` for the current project. The winning items are exposed as `NodeSelectedPackageManager` and `NodeSelectedOrchestrator`, with custom metadata preserved. Providers hook the public `NodeRestore` or `NodeBuild` target with normal `BeforeTargets`/`AfterTargets`, gate on those selected items, and use explicit dependencies for their own internal ordering. Built-in npm, pnpm, Yarn, Bun, and Rush providers use this same contract. + +`NodeBuildArgument` items carry arguments from the project type to every build provider. Each item identity is an argument name and its `Value` metadata is the value. A Rush-forwarded argument also sets `RushParameterName` to the matching custom parameter in `command-line.json`; other providers ignore it. Selection is deterministic: duplicate identities, invalid priorities, equal winning priorities, missing roots, and unmatched explicit values fail with source information. `NodeRestoreCommand` suppresses built-in provider execution and runs only the supplied command. 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 5ebbc8b..b61ef6a 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 @@ -27,9 +27,12 @@ CallTarget - properties set in a target reached through CallTarget are not visible inside the called target, only ones reached through the DependsOnTargets chain are. --> - - <_NodeBuildModeArgName Condition="'$(_NodeBuildModeArgName)'==''">mode - + + + $(NodeBuildConfiguration) + --mode + + - <_NodeBuildModeArgName>build-mode $(PcfBuildMode) + + + $(NodeBuildConfiguration) + --build-mode + + + NodeToolchain. Project types add public NodeBuildArgument items before invoking NodeBuild. + Built-in and external providers hook this public target and consume selected items. --> production development - <_NodeBuildProjectDirectory Condition="'$(_NodeBuildProjectDirectory)' == ''">$(NodeRootFullPath) - + + + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Bun.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Bun.targets new file mode 100644 index 0000000..472d85a --- /dev/null +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Bun.targets @@ -0,0 +1,9 @@ + + + + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets deleted file mode 100644 index cc86427..0000000 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Direct.targets +++ /dev/null @@ -1,16 +0,0 @@ - - - - <_NodeBuildDirectCommand Condition="'$(_NodePackageManager)' == 'pnpm'">pnpm run build -- - <_NodeBuildDirectCommand Condition="'$(_NodePackageManager)' == 'yarn'">yarn run build - <_NodeBuildDirectCommand Condition="'$(_NodePackageManager)' == 'bun'">bun run build - <_NodeBuildDirectCommand Condition="'$(_NodePackageManager)' == 'npm'">npm run build -- - <_NodeBuildModeArgs Condition="'$(_NodeBuildModeArgName)' != ''">--$(_NodeBuildModeArgName) $(NodeBuildConfiguration) - - - - diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Npm.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Npm.targets new file mode 100644 index 0000000..141686d --- /dev/null +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Npm.targets @@ -0,0 +1,9 @@ + + + + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Pnpm.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Pnpm.targets new file mode 100644 index 0000000..5037bf6 --- /dev/null +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Pnpm.targets @@ -0,0 +1,9 @@ + + + + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Rush.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Rush.targets index 0fef608..0b3a52b 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Rush.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/NodeBuild/Rush.targets @@ -4,31 +4,12 @@ invoking the build tool directly) lets Rush's own content-hash incremental skip and build cache apply - a direct "npm run build" every time has no incrementality of its own. - Callers set, before depending on this target: - _NodeBuildProjectDirectory This project's own directory (used for the scoped rush build, - Rush invocation and as the working directory). - _NodeBuildModeArgName (see NodeBuild.targets) the mode flag's long-form name, - without a leading dash pair. Defaults to empty (no - flag); project types that opt in set it explicitly - (PCF uses "build-mode", CodeApp uses "mode"). - _NodeBuildExtraArgs Extra CLI args this project type needs forwarded to its - own build script beyond the mode flag. Empty string if - none. - _NodeBuildRequiredRushParams Semicolon-separated Rush custom-parameter longNames - (beyond the mode flag, which is required whenever the - project type declares one) that must be declared in - command-line.json for _NodeBuildExtraArgs to actually - reach the build script. Empty string if none. - - NodeBuildConfiguration (see NodeBuild.targets) always reaches the build as NODE_ENV=; - it additionally travels as a $(_NodeBuildModeArgName) flag on the Rush custom command-line - parameter list when the project type declares a flag name, in which case a Rush-registered - project must declare that flag in command-line.json the same way it declares any other - custom parameter. --> + Project types and external packages add public NodeBuildArgument items before NodeBuild. + The same items are appended to direct package-manager and Rush invocations. Arguments that + Rush must forward through command-line.json provide RushParameterName metadata. --> + Condition="'@(NodeSelectedOrchestrator)' == 'rush' and '@(NodeSelectedOrchestrator->'%(OwnsBuild)')' == 'true'"> - <_NodeBuildCommandLineJsonPath>$(_NodeOrchestratorRootPath)/common/config/rush/command-line.json + <_NodeBuildRushRootPath>@(NodeSelectedOrchestrator->'%(RootPath)') + <_NodeBuildCommandLineJsonPath>$(_NodeBuildRushRootPath)/common/config/rush/command-line.json <_NodeBuildCommandLineJsonText Condition="Exists('$(_NodeBuildCommandLineJsonPath)')">$([System.IO.File]::ReadAllText('$(_NodeBuildCommandLineJsonPath)')) - <_NodeBuildAllRequiredRushParams Condition="'$(_NodeBuildModeArgName)' != ''">--$(_NodeBuildModeArgName) - <_NodeBuildAllRequiredRushParams Condition="'$(_NodeBuildRequiredRushParams)' != '' and '$(_NodeBuildAllRequiredRushParams)' != ''">$(_NodeBuildAllRequiredRushParams);$(_NodeBuildRequiredRushParams) - <_NodeBuildAllRequiredRushParams Condition="'$(_NodeBuildAllRequiredRushParams)' == ''">$(_NodeBuildRequiredRushParams) <_NodeBuildRequiredRushParam Remove="@(_NodeBuildRequiredRushParam)" /> - <_NodeBuildRequiredRushParam Include="$(_NodeBuildAllRequiredRushParams)" /> + <_NodeBuildRequiredRushParam Include="@(NodeBuildArgument)" + Condition="'%(NodeBuildArgument.RushParameterName)' != ''" /> <_NodeBuildRequiredRushParam> - true + true <_NodeBuildMissingRushParam Remove="@(_NodeBuildMissingRushParam)" /> <_NodeBuildMissingRushParam Include="@(_NodeBuildRequiredRushParam)" Condition="'%(_NodeBuildRequiredRushParam.IsDeclared)' != 'true'" /> @@ -72,21 +52,15 @@ update"/"install". --> <_NodeBuildIsSolutionScope Condition="'$(SolutionPath)' != '' and '$(SolutionPath)' != '*Undefined*'">true - <_NodeBuildRushCommand Condition="'$(_NodeBuildIsSolutionScope)' != 'true'">node "$(_NodeOrchestratorRootPath)/common/scripts/install-run-rush.js" build --to . - <_NodeBuildRushCommand Condition="'$(_NodeBuildIsSolutionScope)' == 'true'">node "$(_NodeOrchestratorRootPath)/common/scripts/install-run-rush.js" build + <_NodeBuildRushCommand Condition="'$(_NodeBuildIsSolutionScope)' != 'true'">node "$(_NodeBuildRushRootPath)/common/scripts/install-run-rush.js" build --to . + <_NodeBuildRushCommand Condition="'$(_NodeBuildIsSolutionScope)' == 'true'">node "$(_NodeBuildRushRootPath)/common/scripts/install-run-rush.js" build - - <_NodeBuildRushCommand Condition="'$(_NodeBuildModeArgName)' != ''">$(_NodeBuildRushCommand) --$(_NodeBuildModeArgName) $(NodeBuildConfiguration) - <_NodeBuildRushCommand Condition="'$(_NodeBuildExtraArgs)' != ''">$(_NodeBuildRushCommand) $(_NodeBuildExtraArgs) + <_NodeBuildRushCommand>$(_NodeBuildRushCommand) @(NodeBuildArgument->'%(Identity) %(Value)', ' ') <_NodeExecRetryCommand>$(_NodeBuildRushCommand) - <_NodeExecRetryWorkingDirectory>$(_NodeBuildProjectDirectory) + <_NodeExecRetryWorkingDirectory>$(NodeRootFullPath) <_NodeExecRetryEnvironmentVariables>NODE_ENV=$(NodeBuildConfiguration)