Skip to content

Commit 282a264

Browse files
authored
fix: Fix dnx not being used for .NET 10+ target frameworks (#43)
1 parent bef8dbc commit 282a264

6 files changed

Lines changed: 277 additions & 18 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"isRoot": true,
44
"tools": {
55
"erikej.efcorepowertools.cli": {
6-
"version": "10.1.1055",
6+
"version": "10.1.1094",
77
"commands": [
88
"efcpt"
99
],

src/JD.Efcpt.Build.Tasks/RunEfcpt.cs

Lines changed: 132 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ namespace JD.Efcpt.Build.Tasks;
2929
/// </item>
3030
/// <item>
3131
/// <description>
32-
/// On .NET 10.0 or later, if dnx is available, the task runs <c>dnx &lt;ToolPackageId&gt;</c>
33-
/// to execute the tool without requiring installation.
32+
/// When the project targets .NET 10.0 or later, the .NET 10+ SDK is installed, and dnx is available,
33+
/// the task runs <c>dnx &lt;ToolPackageId&gt;</c> to execute the tool without requiring installation.
3434
/// </description>
3535
/// </item>
3636
/// <item>
@@ -77,6 +77,11 @@ namespace JD.Efcpt.Build.Tasks;
7777
/// </remarks>
7878
public sealed class RunEfcpt : Task
7979
{
80+
/// <summary>
81+
/// Timeout in milliseconds for external process operations (SDK checks, dnx availability).
82+
/// </summary>
83+
private const int ProcessTimeoutMs = 5000;
84+
8085
/// <summary>
8186
/// Controls how the efcpt dotnet tool is resolved.
8287
/// </summary>
@@ -119,9 +124,10 @@ public sealed class RunEfcpt : Task
119124
/// </value>
120125
/// <remarks>
121126
/// <para>
122-
/// On .NET 10.0 or later, tool restoration is skipped even when this property is <c>true</c>
123-
/// because the <c>dnx</c> command handles tool execution directly without requiring prior
124-
/// installation. The tool is fetched and run on-demand by the dotnet SDK.
127+
/// When the project targets .NET 10.0 or later and the .NET 10+ SDK is installed, tool restoration
128+
/// is skipped even when this property is <c>true</c> because the <c>dnx</c> command handles tool
129+
/// execution directly without requiring prior installation. The tool is fetched and run on-demand
130+
/// by the dotnet SDK.
125131
/// </para>
126132
/// </remarks>
127133
public string ToolRestore { get; set; } = "true";
@@ -224,6 +230,15 @@ public sealed class RunEfcpt : Task
224230
/// </value>
225231
public string Provider { get; set; } = "mssql";
226232

233+
/// <summary>
234+
/// Target framework of the project being built (e.g., "net8.0", "net9.0", "net10.0").
235+
/// </summary>
236+
/// <value>
237+
/// Used to determine whether to use dnx for tool execution on .NET 10+ projects.
238+
/// If empty or not specified, falls back to runtime version detection.
239+
/// </value>
240+
public string TargetFramework { get; set; } = "";
241+
227242
private readonly record struct ToolResolutionContext(
228243
string ToolPath,
229244
string ToolMode,
@@ -234,6 +249,7 @@ private readonly record struct ToolResolutionContext(
234249
string ToolPackageId,
235250
string WorkingDir,
236251
string Args,
252+
string TargetFramework,
237253
BuildLog Log
238254
);
239255

@@ -255,6 +271,7 @@ private readonly record struct ToolRestoreContext(
255271
string ToolPath,
256272
string ToolPackageId,
257273
string ToolVersion,
274+
string TargetFramework,
258275
BuildLog Log
259276
);
260277

@@ -267,7 +284,7 @@ BuildLog Log
267284
Args: ctx.Args,
268285
Cwd: ctx.WorkingDir,
269286
UseManifest: false))
270-
.When((in ctx) => IsDotNet10OrLater() && IsDnxAvailable(ctx.DotNetExe))
287+
.When((in ctx) => IsDotNet10OrLater(ctx.TargetFramework) && IsDotNet10SdkInstalled(ctx.DotNetExe) && IsDnxAvailable(ctx.DotNetExe))
271288
.Then((in ctx)
272289
=> new ToolInvocation(
273290
Exe: ctx.DotNetExe,
@@ -297,29 +314,30 @@ private static bool ToolIsAutoOrManifest(ToolResolutionContext ctx) =>
297314
private static readonly Lazy<ActionStrategy<ToolRestoreContext>> ToolRestoreStrategy = new(() =>
298315
ActionStrategy<ToolRestoreContext>.Create()
299316
// Manifest restore: restore tools from local manifest
300-
// Skip on .NET 10+ because dnx handles tool execution without installation
301-
.When(static (in ctx) => ctx is { UseManifest: true, ShouldRestore: true } && !IsDotNet10OrLater())
317+
// Skip when: dnx will be used OR no manifest directory exists
318+
.When((in ctx) => ctx is { UseManifest: true, ShouldRestore: true, ManifestDir: not null }
319+
&& !(IsDotNet10OrLater(ctx.TargetFramework) && IsDotNet10SdkInstalled(ctx.DotNetExe) && IsDnxAvailable(ctx.DotNetExe)))
302320
.Then((in ctx) =>
303321
{
304322
var restoreCwd = ctx.ManifestDir ?? ctx.WorkingDir;
305323
ProcessRunner.RunOrThrow(ctx.Log, ctx.DotNetExe, "tool restore", restoreCwd);
306324
})
307325
// Global restore: update global tool package
308-
// Skip on .NET 10+ because dnx handles tool execution without installation
309-
.When(static (in ctx)
326+
// Skip only when dnx will be used (all three conditions: .NET 10+ target, SDK installed, dnx available)
327+
.When((in ctx)
310328
=> ctx is
311329
{
312330
UseManifest: false,
313331
ShouldRestore: true,
314332
HasExplicitPath: false,
315333
HasPackageId: true
316-
} && !IsDotNet10OrLater())
334+
} && !(IsDotNet10OrLater(ctx.TargetFramework) && IsDotNet10SdkInstalled(ctx.DotNetExe) && IsDnxAvailable(ctx.DotNetExe)))
317335
.Then((in ctx) =>
318336
{
319337
var versionArg = string.IsNullOrWhiteSpace(ctx.ToolVersion) ? "" : $" --version \"{ctx.ToolVersion}\"";
320338
ProcessRunner.RunOrThrow(ctx.Log, ctx.DotNetExe, $"tool update --global {ctx.ToolPackageId}{versionArg}", ctx.WorkingDir);
321339
})
322-
// Default: no restoration needed (includes .NET 10+ with dnx)
340+
// Default: no restoration needed (dnx will be used OR no manifest for manifest mode)
323341
.Default(static (in _) => { })
324342
.Build());
325343

@@ -392,7 +410,7 @@ private bool ExecuteCore(TaskExecutionContext ctx)
392410
// Use the Strategy pattern to resolve tool invocation
393411
var context = new ToolResolutionContext(
394412
ToolPath, mode, manifestDir, forceManifestOnNonWindows,
395-
DotNetExe, ToolCommand, ToolPackageId, workingDir, args, log);
413+
DotNetExe, ToolCommand, ToolPackageId, workingDir, args, TargetFramework, log);
396414

397415
var invocation = ToolResolutionStrategy.Value.Execute(in context);
398416

@@ -418,6 +436,7 @@ private bool ExecuteCore(TaskExecutionContext ctx)
418436
ToolPath: ToolPath,
419437
ToolPackageId: ToolPackageId,
420438
ToolVersion: ToolVersion,
439+
TargetFramework: TargetFramework,
421440
Log: log
422441
);
423442

@@ -429,12 +448,106 @@ private bool ExecuteCore(TaskExecutionContext ctx)
429448
}
430449

431450

432-
private static bool IsDotNet10OrLater()
451+
/// <summary>
452+
/// Checks if the target framework is .NET 10.0 or later.
453+
/// </summary>
454+
/// <param name="targetFramework">The target framework string (e.g., "net8.0", "net10.0").</param>
455+
/// <returns>True if the target framework is .NET 10.0 or later; otherwise false.</returns>
456+
private static bool IsDotNet10OrLater(string targetFramework)
457+
{
458+
if (string.IsNullOrWhiteSpace(targetFramework))
459+
return false;
460+
461+
try
462+
{
463+
// Parse target framework to get major version (e.g., "net8.0" -> 8, "net10.0" -> 10)
464+
if (!targetFramework.StartsWith("net", StringComparison.OrdinalIgnoreCase))
465+
return false;
466+
467+
var versionPart = targetFramework[3..];
468+
469+
// Trim at the first '.' or '-' after "net" to handle formats like:
470+
// - "net10.0" -> "10"
471+
// - "net10.0-windows" -> "10"
472+
// - "net10-windows" -> "10"
473+
var dotIndex = versionPart.IndexOf('.');
474+
var hyphenIndex = versionPart.IndexOf('-');
475+
476+
var cutIndex = (dotIndex >= 0, hyphenIndex >= 0) switch
477+
{
478+
(true, true) => Math.Min(dotIndex, hyphenIndex),
479+
(true, false) => dotIndex,
480+
(false, true) => hyphenIndex,
481+
_ => -1
482+
};
483+
484+
if (cutIndex > 0)
485+
versionPart = versionPart[..cutIndex];
486+
487+
if (int.TryParse(versionPart, out var version))
488+
return version >= 10;
489+
490+
return false;
491+
}
492+
catch
493+
{
494+
return false;
495+
}
496+
}
497+
498+
/// <summary>
499+
/// Checks if .NET SDK version 10 or later is installed.
500+
/// </summary>
501+
/// <param name="dotnetExe">Path to the dotnet executable.</param>
502+
/// <returns>True if .NET 10+ SDK is installed; otherwise false.</returns>
503+
private static bool IsDotNet10SdkInstalled(string dotnetExe)
433504
{
434505
try
435506
{
436-
var version = Environment.Version;
437-
return version.Major >= 10;
507+
var psi = new ProcessStartInfo
508+
{
509+
FileName = dotnetExe,
510+
Arguments = "--list-sdks",
511+
RedirectStandardOutput = true,
512+
RedirectStandardError = true,
513+
UseShellExecute = false,
514+
CreateNoWindow = true
515+
};
516+
517+
using var p = Process.Start(psi);
518+
if (p is null) return false;
519+
520+
// Check if process completed within timeout
521+
if (!p.WaitForExit(ProcessTimeoutMs))
522+
return false;
523+
524+
if (p.ExitCode != 0)
525+
return false;
526+
527+
var output = p.StandardOutput.ReadToEnd();
528+
529+
// Parse output like "10.0.100 [C:\Program Files\dotnet\sdk]"
530+
// Check if any line starts with "10." or higher
531+
foreach (var line in output.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries))
532+
{
533+
var trimmed = line.Trim();
534+
if (string.IsNullOrEmpty(trimmed))
535+
continue;
536+
537+
// Extract version number (first part before space or bracket)
538+
var spaceIndex = trimmed.IndexOf(' ');
539+
var versionStr = spaceIndex >= 0 ? trimmed.Substring(0, spaceIndex) : trimmed;
540+
541+
// Parse major version
542+
var dotIndex = versionStr.IndexOf('.');
543+
if (dotIndex > 0 && int.TryParse(versionStr.Substring(0, dotIndex), out var major))
544+
{
545+
if (major >= 10)
546+
return true;
547+
}
548+
}
549+
550+
return false;
438551
}
439552
catch
440553
{
@@ -459,7 +572,9 @@ private static bool IsDnxAvailable(string dotnetExe)
459572
using var p = Process.Start(psi);
460573
if (p is null) return false;
461574

462-
p.WaitForExit(5000); // 5 second timeout
575+
if (!p.WaitForExit(ProcessTimeoutMs))
576+
return false;
577+
463578
return p.ExitCode == 0;
464579
}
465580
catch

src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.targets

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@
416416
RenamingPath="$(_EfcptStagedRenaming)"
417417
TemplateDir="$(_EfcptStagedTemplateDir)"
418418
OutputDir="$(EfcptGeneratedDir)"
419+
TargetFramework="$(TargetFramework)"
419420
LogVerbosity="$(EfcptLogVerbosity)" />
420421
<RenameGeneratedFiles
421422
GeneratedDir="$(EfcptGeneratedDir)"

tests/JD.Efcpt.Build.Tests/RunEfcptTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,4 +382,34 @@ await Given("inputs with tool manifest", SetupWithToolManifest)
382382
.Finally(r => r.Setup.Folder.Dispose())
383383
.AssertPassed();
384384
}
385+
386+
[Scenario("Accepts target framework parameter")]
387+
[Fact]
388+
public async Task Accepts_target_framework_parameter()
389+
{
390+
await Given("inputs for DACPAC mode", SetupForDacpacMode)
391+
.When("task executes with target framework", s =>
392+
ExecuteTaskWithFakeMode(s, t => t.TargetFramework = "net10.0"))
393+
.Then("task succeeds", r => r.Success)
394+
.Finally(r => r.Setup.Folder.Dispose())
395+
.AssertPassed();
396+
}
397+
398+
[Scenario("Handles various target framework formats")]
399+
[Theory]
400+
[InlineData("net8.0")]
401+
[InlineData("net9.0")]
402+
[InlineData("net10.0")]
403+
[InlineData("net10.0-windows")]
404+
[InlineData("net10-windows")]
405+
[InlineData("")]
406+
public async Task Handles_various_target_framework_formats(string targetFramework)
407+
{
408+
await Given("inputs for DACPAC mode", SetupForDacpacMode)
409+
.When("task executes with target framework", s =>
410+
ExecuteTaskWithFakeMode(s, t => t.TargetFramework = targetFramework))
411+
.Then("task succeeds", r => r.Success)
412+
.Finally(r => r.Setup.Folder.Dispose())
413+
.AssertPassed();
414+
}
385415
}

tests/JD.Efcpt.Sdk.IntegrationTests/TemplateTests.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ public async Task Template_CreatedProjectBuildsSuccessfully()
154154
}}";
155155
await File.WriteAllTextAsync(Path.Combine(_testDirectory, "global.json"), globalJson);
156156

157+
// Create tool manifest and restore tools for tool-manifest mode support
158+
await CreateToolManifestAndRestoreAsync(_testDirectory);
159+
157160
// Act - Restore
158161
var restoreResult = await RunDotnetCommandAsync(_testDirectory, projectName, "restore");
159162
restoreResult.Success.Should().BeTrue($"Restore should succeed.\n{restoreResult}");
@@ -318,6 +321,13 @@ public async Task Template_FrameworkVariant_BuildsSuccessfully(string framework,
318321
await File.WriteAllTextAsync(globalJsonPath, globalJson);
319322
}
320323

324+
// Create tool manifest and restore tools for tool-manifest mode support
325+
var toolManifestPath = Path.Combine(_testDirectory, ".config", "dotnet-tools.json");
326+
if (!File.Exists(toolManifestPath))
327+
{
328+
await CreateToolManifestAndRestoreAsync(_testDirectory);
329+
}
330+
321331
// Act - Restore
322332
var restoreResult = await RunDotnetCommandAsync(_testDirectory, projectName, "restore");
323333
restoreResult.Success.Should().BeTrue($"Restore for {framework} should succeed.\n{restoreResult}");
@@ -398,6 +408,46 @@ private static void CopyDirectory(string sourceDir, string destDir)
398408
}
399409
}
400410

411+
/// <summary>
412+
/// Creates a .config/dotnet-tools.json manifest and restores tools.
413+
/// Required for tool-manifest mode to find the efcpt tool.
414+
/// </summary>
415+
private static async Task CreateToolManifestAndRestoreAsync(string testDirectory)
416+
{
417+
var configDir = Path.Combine(testDirectory, ".config");
418+
Directory.CreateDirectory(configDir);
419+
420+
var toolManifest = @"{
421+
""version"": 1,
422+
""isRoot"": true,
423+
""tools"": {
424+
""erikej.efcorepowertools.cli"": {
425+
""version"": ""10.1.1055"",
426+
""commands"": [
427+
""efcpt""
428+
],
429+
""rollForward"": false
430+
}
431+
}
432+
}";
433+
await File.WriteAllTextAsync(Path.Combine(configDir, "dotnet-tools.json"), toolManifest);
434+
435+
// Restore tools so they're available for both tool-manifest and dnx modes
436+
var psi = new System.Diagnostics.ProcessStartInfo
437+
{
438+
FileName = "dotnet",
439+
Arguments = "tool restore",
440+
WorkingDirectory = testDirectory,
441+
RedirectStandardOutput = true,
442+
RedirectStandardError = true,
443+
UseShellExecute = false,
444+
CreateNoWindow = true
445+
};
446+
447+
using var process = System.Diagnostics.Process.Start(psi)!;
448+
await process.WaitForExitAsync();
449+
}
450+
401451
private static async Task<TestUtilities.CommandResult> RunDotnetCommandAsync(string workingDirectory, string projectName, string arguments)
402452
{
403453
var psi = new System.Diagnostics.ProcessStartInfo

0 commit comments

Comments
 (0)