From ee834be13ea9ad51098390bc8493fcc62e91cfc4 Mon Sep 17 00:00:00 2001 From: Shannon Pamperl Date: Thu, 28 May 2026 23:42:27 -0500 Subject: [PATCH 1/2] Add recipeInstallDir to CSharpRewriteRpc so callers control the recipe project location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recipe project — where `dotnet add package` resolves recipe NuGet packages — was always created under a private temp directory. Because dotnet discovers NuGet.config by walking up from the project directory, a caller had no way to inject a package feed via a generated NuGet.config. Mirror the JavaScript RPC's recipeInstallDir/--recipe-install-dir pattern: the C# server now creates Recipes.csproj in a caller-supplied directory when one is provided (otherwise the previous temp-dir behavior), so a co-located NuGet.config is honored by dotnet's config discovery. - CSharpRewriteRpc.Builder.recipeInstallDir(Path) appends --recipe-install-dir= to the server command (tool-path, csproj, and explicit-entry paths) - Program.cs parses --recipe-install-dir= and threads it through RunAsync - RewriteRpcServer creates the recipe project under that directory when set --- .../csharp/OpenRewrite.Tool/Program.cs | 5 +++- .../CSharp/Rpc/RewriteRpcServer.cs | 13 +++++++--- .../csharp/rpc/CSharpRewriteRpc.java | 24 ++++++++++++++++--- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/rewrite-csharp/csharp/OpenRewrite.Tool/Program.cs b/rewrite-csharp/csharp/OpenRewrite.Tool/Program.cs index 1d3ef922e65..cdb1011a684 100644 --- a/rewrite-csharp/csharp/OpenRewrite.Tool/Program.cs +++ b/rewrite-csharp/csharp/OpenRewrite.Tool/Program.cs @@ -21,6 +21,9 @@ var logFile = args.FirstOrDefault(a => a.StartsWith("--log-file=")) ?.Substring("--log-file=".Length); +var recipeInstallDir = args.FirstOrDefault(a => a.StartsWith("--recipe-install-dir=")) + ?.Substring("--recipe-install-dir=".Length); + var loggerConfig = new LoggerConfiguration(); if (logFile != null) { @@ -43,5 +46,5 @@ Log.Debug("<< Parser warmup ({Elapsed})", sw.Elapsed); Log.Information("Starting RPC server"); -await RewriteRpcServer.RunAsync(); +await RewriteRpcServer.RunAsync(recipeInstallDir: recipeInstallDir); Log.Information("RPC server exited"); diff --git a/rewrite-csharp/csharp/OpenRewrite/CSharp/Rpc/RewriteRpcServer.cs b/rewrite-csharp/csharp/OpenRewrite/CSharp/Rpc/RewriteRpcServer.cs index c81b4eae878..29c2e92ef49 100644 --- a/rewrite-csharp/csharp/OpenRewrite/CSharp/Rpc/RewriteRpcServer.cs +++ b/rewrite-csharp/csharp/OpenRewrite/CSharp/Rpc/RewriteRpcServer.cs @@ -53,6 +53,7 @@ public class RewriteRpcServer private readonly ConcurrentDictionary _recipeAccumulators = new(); private readonly ConcurrentDictionary _executionContexts = new(); private string? _recipesProjectDir; + private readonly string? _recipeInstallDir; private JsonRpc? _jsonRpc; private DotNetBuildContext? _buildContext; @@ -88,9 +89,10 @@ public void Connect(JsonRpc jsonRpc) jsonRpc.StartListening(); } - public RewriteRpcServer(RecipeMarketplace marketplace) + public RewriteRpcServer(RecipeMarketplace marketplace, string? recipeInstallDir = null) { _marketplace = marketplace; + _recipeInstallDir = recipeInstallDir; // Register type name overrides for nagoya types that don't match Java names RpcSendQueue.RegisterJavaTypeName(typeof(CsLambda), @@ -636,7 +638,11 @@ private string EnsureRecipesProject() return existing; } - _recipesProjectDir = Path.Combine(Path.GetTempPath(), "rewrite-recipes", Guid.NewGuid().ToString("N")[..8]); + // Use the caller-supplied recipe install directory when provided (so a + // co-located NuGet.config is found by dotnet's project-directory config + // walk); otherwise fall back to a unique temp directory. + _recipesProjectDir = _recipeInstallDir + ?? Path.Combine(Path.GetTempPath(), "rewrite-recipes", Guid.NewGuid().ToString("N")[..8]); Directory.CreateDirectory(_recipesProjectDir); var csprojPath = Path.Combine(_recipesProjectDir, "Recipes.csproj"); @@ -1218,6 +1224,7 @@ public override Marker VisitMarker(Marker marker, int p) } public static async Task RunAsync(RecipeMarketplace? marketplace = null, + string? recipeInstallDir = null, CancellationToken cancellationToken = default) { marketplace ??= new RecipeMarketplace(); @@ -1246,7 +1253,7 @@ public static async Task RunAsync(RecipeMarketplace? marketplace = null, var handler = new HeaderDelimitedMessageHandler(outputStream, inputStream, formatter); using var jsonRpc = new StringErrorDataJsonRpc(handler); - var server = new RewriteRpcServer(marketplace); + var server = new RewriteRpcServer(marketplace, recipeInstallDir); server._jsonRpc = jsonRpc; _current = server; // Allow concurrent request dispatch so reentrant callbacks don't deadlock. diff --git a/rewrite-csharp/src/main/java/org/openrewrite/csharp/rpc/CSharpRewriteRpc.java b/rewrite-csharp/src/main/java/org/openrewrite/csharp/rpc/CSharpRewriteRpc.java index 0232f4f921a..055b46a70a2 100644 --- a/rewrite-csharp/src/main/java/org/openrewrite/csharp/rpc/CSharpRewriteRpc.java +++ b/rewrite-csharp/src/main/java/org/openrewrite/csharp/rpc/CSharpRewriteRpc.java @@ -191,6 +191,7 @@ public static class Builder implements Supplier { private Duration timeout = Duration.ofSeconds(60); private boolean traceRpcMessages; private @Nullable Path workingDirectory; + private @Nullable Path recipeInstallDir; private @Nullable Path profileOutputPath; public Builder marketplace(RecipeMarketplace marketplace) { @@ -278,6 +279,20 @@ public Builder workingDirectory(@Nullable Path workingDirectory) { return this; } + /** + * Directory in which the server creates its recipe project (where + * {@code dotnet add package} resolves recipe NuGet packages). Set this so a + * caller-written {@code NuGet.config} co-located in the directory is honored + * by dotnet's project-directory config discovery. + * + * @param recipeInstallDir The directory to create the recipe project in + * @return This builder + */ + public Builder recipeInstallDir(@Nullable Path recipeInstallDir) { + this.recipeInstallDir = recipeInstallDir; + return this; + } + /** * Enable .NET EventPipe profiling on the C# process. Captures CPU sampling, * GC events, sampled allocations, and runtime counters into a {@code .nettrace} @@ -310,7 +325,8 @@ public CSharpRewriteRpc get() { dotnetPath.toString(), csharpServerEntry.toAbsolutePath().normalize().toString(), log == null ? null : "--log-file=" + log.toAbsolutePath().normalize(), - traceRpcMessages ? "--trace-rpc-messages" : null + traceRpcMessages ? "--trace-rpc-messages" : null, + recipeInstallDir == null ? null : "--recipe-install-dir=" + recipeInstallDir.toAbsolutePath().normalize() ); } } else { @@ -367,7 +383,8 @@ private CSharpRewriteRpc startProcess(Stream<@Nullable String> cmd) { "--project", csproj.toAbsolutePath().normalize().toString(), "--framework", "net10.0", log == null ? null : "--log-file=" + log.toAbsolutePath().normalize(), - traceRpcMessages ? "--trace-rpc-messages" : null + traceRpcMessages ? "--trace-rpc-messages" : null, + recipeInstallDir == null ? null : "--recipe-install-dir=" + recipeInstallDir.toAbsolutePath().normalize() ); } @@ -393,7 +410,8 @@ private CSharpRewriteRpc startProcess(Stream<@Nullable String> cmd) { return Stream.of( toolExecutable.toAbsolutePath().normalize().toString(), log == null ? null : "--log-file=" + log.toAbsolutePath().normalize(), - traceRpcMessages ? "--trace-rpc-messages" : null + traceRpcMessages ? "--trace-rpc-messages" : null, + recipeInstallDir == null ? null : "--recipe-install-dir=" + recipeInstallDir.toAbsolutePath().normalize() ); } From aaad1f1eae518d93695e03365c689d94fc4d7c6f Mon Sep 17 00:00:00 2001 From: Shannon Pamperl Date: Fri, 29 May 2026 11:49:04 -0500 Subject: [PATCH 2/2] nuget: make the local dev feed additive to a caller-written nuget.config EnsureRecipesProject unconditionally wrote a nuget.config (nuget.org + local-feed) into the recipe project dir when ~/.nuget/local-feed exists. Now that the project dir can be a caller-supplied recipeInstallDir, that overwrote a caller's config (e.g. an exclusive configured feed). Make the local feed additive instead: BuildRecipesNuGetConfig creates the public + local-feed config only when none exists, and otherwise appends only the local feed to the existing (idempotently, preserving the caller's sources and any ). Local-dev-only; no-ops in production where local-feed is absent. --- .../Rpc/RecipesNuGetConfigTests.cs | 82 +++++++++++++++++++ .../CSharp/Rpc/RewriteRpcServer.cs | 56 +++++++++++-- 2 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 rewrite-csharp/csharp/OpenRewrite.Tests/Rpc/RecipesNuGetConfigTests.cs diff --git a/rewrite-csharp/csharp/OpenRewrite.Tests/Rpc/RecipesNuGetConfigTests.cs b/rewrite-csharp/csharp/OpenRewrite.Tests/Rpc/RecipesNuGetConfigTests.cs new file mode 100644 index 00000000000..14b0b9cd1fb --- /dev/null +++ b/rewrite-csharp/csharp/OpenRewrite.Tests/Rpc/RecipesNuGetConfigTests.cs @@ -0,0 +1,82 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using OpenRewrite.CSharp.Rpc; + +namespace OpenRewrite.Tests.Rpc; + +///

+/// When a local NuGet feed (~/.nuget/local-feed) exists for cross-repo +/// development, the server makes it additive to the recipe project's +/// nuget.config: it creates one (public + local feed) when none exists, +/// and otherwise only appends the local feed — preserving a caller-written +/// config (e.g. an exclusive configured feed) untouched. +/// +public class RecipesNuGetConfigTests +{ + private const string LocalFeed = "/home/dev/.nuget/local-feed"; + + [Fact] + public void CreatesPublicPlusLocalFeedWhenNoConfigExists() + { + string xml = RewriteRpcServer.BuildRecipesNuGetConfig(null, LocalFeed); + + Assert.Contains("api.nuget.org", xml); + Assert.Contains(LocalFeed, xml); + } + + [Fact] + public void AppendsOnlyLocalFeedToExistingConfig() + { + // A caller-written exclusive config: nuget.org cleared, one configured feed. + string existing = """ + + + + + + + + """; + + string xml = RewriteRpcServer.BuildRecipesNuGetConfig(existing, LocalFeed); + + // Caller's exclusivity and feed are preserved... + Assert.Contains(" + + + + + + """; + + string xml = RewriteRpcServer.BuildRecipesNuGetConfig(existing, LocalFeed); + + int occurrences = xml.Split(LocalFeed).Length - 1; + Assert.Equal(1, occurrences); + } +} diff --git a/rewrite-csharp/csharp/OpenRewrite/CSharp/Rpc/RewriteRpcServer.cs b/rewrite-csharp/csharp/OpenRewrite/CSharp/Rpc/RewriteRpcServer.cs index 29c2e92ef49..d3235f81e4c 100644 --- a/rewrite-csharp/csharp/OpenRewrite/CSharp/Rpc/RewriteRpcServer.cs +++ b/rewrite-csharp/csharp/OpenRewrite/CSharp/Rpc/RewriteRpcServer.cs @@ -17,6 +17,7 @@ using System.Diagnostics; using System.Reflection; using System.Runtime.Loader; +using System.Xml.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Linq; @@ -655,26 +656,69 @@ private string EnsureRecipesProject() """); - // Add local NuGet feed as a package source if it exists, so that - // locally-published SDK snapshots are discovered alongside nuget.org + // For local cross-repo development, make the local NuGet feed additive to + // whatever config already lives in the project dir. A caller (e.g. the Moderne + // CLI) may have written its own nuget.config there — possibly an exclusive + // configured feed — so we must not clobber it: append only the local feed when + // a config is present, and create the standalone dev default (public + local + // feed) only when none exists. No-ops in production, where local-feed is absent. var localFeed = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "local-feed"); if (Directory.Exists(localFeed)) { var nugetConfig = Path.Combine(_recipesProjectDir, "nuget.config"); - File.WriteAllText(nugetConfig, $""" + var existing = File.Exists(nugetConfig) ? File.ReadAllText(nugetConfig) : null; + File.WriteAllText(nugetConfig, BuildRecipesNuGetConfig(existing, localFeed)); + } + + return csprojPath; + } + + /// + /// Produces the recipe project's nuget.config with the local development + /// feed present. When is null/empty, creates a + /// standalone config with nuget.org + the local feed. Otherwise the caller already + /// wrote a config (possibly an exclusive configured feed): only the local feed is + /// appended to <packageSources>, preserving the caller's sources and any + /// <clear/>, and idempotently (no duplicate if already present). + /// + internal static string BuildRecipesNuGetConfig(string? existingConfigXml, string localFeedPath) + { + if (string.IsNullOrWhiteSpace(existingConfigXml)) + { + return $""" - + - """); + """; } - return csprojPath; + var doc = XDocument.Parse(existingConfigXml); + var configuration = doc.Element("configuration") + ?? throw new InvalidOperationException("nuget.config is missing its root"); + var packageSources = configuration.Element("packageSources"); + if (packageSources == null) + { + packageSources = new XElement("packageSources"); + configuration.Add(packageSources); + } + + bool alreadyPresent = packageSources.Elements("add").Any(e => + string.Equals((string?)e.Attribute("value"), localFeedPath, StringComparison.OrdinalIgnoreCase)); + if (!alreadyPresent) + { + packageSources.Add(new XElement("add", + new XAttribute("key", "local-feed"), + new XAttribute("value", localFeedPath))); + } + + var declaration = doc.Declaration ?? new XDeclaration("1.0", "utf-8", null); + return declaration + Environment.NewLine + doc.Root!.ToString(); } private static void RunDotnet(string arguments)