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.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..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;
@@ -53,6 +54,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 +90,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 +639,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");
@@ -649,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)
@@ -1218,6 +1268,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 +1297,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()
);
}