Forge
csharpc88715b7
1using System.ComponentModel;
2using System.Diagnostics;
3using System.Runtime.InteropServices;
4using Ookii.CommandLine;
5
6namespace AIGuiders.DotnetTools.PublishFixedTarget;
7
8[GeneratedParser]
9[ApplicationFriendlyName("aid-publish")]
10[Description("dotnet publish wrapper: mirror to fixed target, optional kill-locking process, and proof timestamps.")]
11partial class PublishArgs
12{
13 [CommandLineArgument]
14 [Description("Path to the .csproj to publish.")]
15 public required string Project { get; set; }
16
17 [CommandLineArgument]
18 [Description("Target directory to mirror publish output into.")]
19 public required string Target { get; set; }
20
21 [CommandLineArgument]
22 [Description("Runtime identifier (RID).")]
23 public string Runtime { get; set; } = "win-x64";
24
25 [CommandLineArgument]
26 [Description("Configuration: Debug or Release.")]
27 public string Configuration { get; set; } = "Debug";
28
29 [CommandLineArgument]
30 [Description("Publish as self-contained.")]
31 public bool SelfContained { get; set; }
32
33 [CommandLineArgument]
34 [Description("Publish output directory. Default: publish-{configuration-lower}.")]
35 public string? OutDir { get; set; }
36
37 [CommandLineArgument]
38 [Description("Executable/process name if it differs from the project file name.")]
39 public string? AppExeName { get; set; }
40
41 [CommandLineArgument]
42 [Description("Kill the app if it is running from the target path (avoids file locks).")]
43 public bool KillRunning { get; set; }
44
45 [CommandLineArgument]
46 [Description("Repeatable MSBuild property arguments (e.g. /p:GenerateIdeProtocolDocs=false).")]
47 public string[]? MsbuildProp { get; set; }
48
49 [CommandLineArgument]
50 [Description("Repeatable extra arguments appended to dotnet publish.")]
51 public string[]? DotnetArg { get; set; }
52
53 [CommandLineArgument]
54 [Description("After mirror: each path must exist as a file under Target (relative; / or \\\\ ok). Typical: roslyn MCP + MSBuild Workspace — BuildHost-netcore/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.dll.")]
55 public string[]? RequireMirrorFile { get; set; }
56}
57
58internal static class Program
59{
60 public static int Main(string[] args)
61 {
62 var a = PublishArgs.Parse(args);
63 if (a is null)
64 return 1;
65
66 try
67 {
68 var projectPath = Path.GetFullPath(a.Project);
69 if (!File.Exists(projectPath))
70 throw new FileNotFoundException("Project not found.", projectPath);
71
72 var projectDir = Path.GetDirectoryName(projectPath)!;
73 var configuration = NormalizeConfiguration(a.Configuration);
74
75 var outDir = string.IsNullOrWhiteSpace(a.OutDir)
76 ? Path.Combine(projectDir, $"publish-{configuration.ToLowerInvariant()}")
77 : Path.GetFullPath(Path.IsPathRooted(a.OutDir) ? a.OutDir : Path.Combine(projectDir, a.OutDir));
78
79 var targetDir = Path.GetFullPath(a.Target);
80 Directory.CreateDirectory(outDir);
81 Directory.CreateDirectory(targetDir);
82
83 var exeName = !string.IsNullOrWhiteSpace(a.AppExeName)
84 ? a.AppExeName!.Trim()
85 : Path.GetFileNameWithoutExtension(projectPath);
86
87 var expectedTargetExe = Path.Combine(targetDir, $"{exeName}.exe");
88 StopIfRunningFromTarget(exeName, expectedTargetExe, a.KillRunning);
89
90 RunDotnetPublish(projectPath, outDir, a.Runtime, configuration, a.SelfContained, a.MsbuildProp, a.DotnetArg);
91
92 MirrorDirectory(outDir, targetDir);
93
94 RequireMirrorArtifacts(targetDir, a.RequireMirrorFile);
95
96 var publishExe = Path.Combine(outDir, $"{exeName}.exe");
97 var targetExe = expectedTargetExe;
98
99 if (File.Exists(publishExe) && File.Exists(targetExe))
100 {
101 var publishTs = File.GetLastWriteTimeUtc(publishExe);
102 var targetTs = File.GetLastWriteTimeUtc(targetExe);
103 Console.WriteLine();
104 Console.WriteLine($"OK: {targetExe} (UTC {targetTs:O})");
105 Console.WriteLine($" publish: {publishTs:O}");
106 Console.WriteLine($" target: {targetTs:O}");
107 Console.WriteLine();
108 }
109 else
110 {
111 Console.WriteLine();
112 Console.WriteLine($"OK: mirrored to {targetDir}");
113 Console.WriteLine();
114 }
115
116 return 0;
117 }
118 catch (Exception ex)
119 {
120 Console.Error.WriteLine(ex.Message);
121 return 1;
122 }
123 }
124
125 private static string NormalizeConfiguration(string configuration)
126 {
127 var c = configuration.Trim();
128 return string.Equals(c, "Release", StringComparison.OrdinalIgnoreCase) ? "Release" : "Debug";
129 }
130
131 private static void RunDotnetPublish(
132 string projectPath,
133 string outDir,
134 string runtime,
135 string configuration,
136 bool selfContained,
137 string[]? msbuildProps,
138 string[]? extraArgs)
139 {
140 var args = new List<string>
141 {
142 "publish",
143 QuoteIfNeeded(projectPath),
144 "-c", configuration,
145 "-r", runtime,
146 "-o", QuoteIfNeeded(outDir),
147 "-v", "minimal",
148 };
149
150 if (selfContained)
151 {
152 args.Add("--self-contained");
153 args.Add("true");
154 }
155
156 if (msbuildProps is { Length: > 0 })
157 args.AddRange(msbuildProps.Where(p => !string.IsNullOrWhiteSpace(p)));
158
159 if (extraArgs is { Length: > 0 })
160 args.AddRange(extraArgs.Where(p => !string.IsNullOrWhiteSpace(p)));
161
162 var psi = new ProcessStartInfo
163 {
164 FileName = "dotnet",
165 Arguments = string.Join(' ', args),
166 UseShellExecute = false,
167 };
168
169 using var p = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start dotnet.");
170 p.WaitForExit();
171 if (p.ExitCode != 0)
172 throw new InvalidOperationException($"dotnet publish failed with exit code {p.ExitCode}.");
173 }
174
175 private static void StopIfRunningFromTarget(string processName, string expectedExePath, bool killRunning)
176 {
177 if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
178 return;
179
180 var expected = Path.GetFullPath(expectedExePath);
181 var procs = Process.GetProcessesByName(processName);
182 foreach (var p in procs)
183 {
184 string? path = null;
185 try { path = p.MainModule?.FileName; } catch { path = null; }
186 if (string.IsNullOrWhiteSpace(path))
187 continue;
188
189 if (!string.Equals(Path.GetFullPath(path), expected, StringComparison.OrdinalIgnoreCase))
190 continue;
191
192 if (!killRunning)
193 throw new InvalidOperationException($"{processName} is running from target path and will lock publish output. Close it or re-run with --kill-running.");
194
195 try
196 {
197 Console.WriteLine($"Stopping {processName} PID {p.Id} from {path}");
198 p.Kill(entireProcessTree: true);
199 p.WaitForExit(10_000);
200 }
201 catch (Exception ex)
202 {
203 throw new InvalidOperationException($"Failed to stop {processName} (PID {p.Id}): {ex.Message}");
204 }
205 }
206 }
207
208 private static void MirrorDirectory(string sourceDir, string targetDir)
209 {
210 sourceDir = Path.GetFullPath(sourceDir);
211 targetDir = Path.GetFullPath(targetDir);
212
213 if (!Directory.Exists(sourceDir))
214 throw new DirectoryNotFoundException(sourceDir);
215
216 Directory.CreateDirectory(targetDir);
217
218 // Copy/update files and directories.
219 foreach (var srcPath in Directory.EnumerateFileSystemEntries(sourceDir, "*", SearchOption.AllDirectories))
220 {
221 var rel = Path.GetRelativePath(sourceDir, srcPath);
222 var dstPath = Path.Combine(targetDir, rel);
223
224 if (Directory.Exists(srcPath))
225 {
226 Directory.CreateDirectory(dstPath);
227 continue;
228 }
229
230 Directory.CreateDirectory(Path.GetDirectoryName(dstPath)!);
231 File.Copy(srcPath, dstPath, overwrite: true);
232
233 try
234 {
235 File.SetLastWriteTimeUtc(dstPath, File.GetLastWriteTimeUtc(srcPath));
236 }
237 catch
238 {
239 // ignore
240 }
241 }
242
243 // Delete extras in target.
244 foreach (var dstPath in Directory.EnumerateFileSystemEntries(targetDir, "*", SearchOption.AllDirectories)
245 .OrderByDescending(p => p.Length))
246 {
247 var rel = Path.GetRelativePath(targetDir, dstPath);
248 var srcPath = Path.Combine(sourceDir, rel);
249 if (File.Exists(dstPath))
250 {
251 if (!File.Exists(srcPath))
252 File.Delete(dstPath);
253 }
254 else if (Directory.Exists(dstPath))
255 {
256 if (!Directory.Exists(srcPath) && IsDirectoryEmpty(dstPath))
257 Directory.Delete(dstPath, recursive: true);
258 }
259 }
260 }
261
262 private static bool IsDirectoryEmpty(string path) =>
263 !Directory.EnumerateFileSystemEntries(path).Any();
264
265 /// <summary>Fail fast when publish output misses known-critical assets (Roslyn MCP: MSBuild Workspace BuildHost).</summary>
266 private static void RequireMirrorArtifacts(string targetDir, string[]? relativeFilePaths)
267 {
268 if (relativeFilePaths is not { Length: > 0 })
269 return;
270
271 var targetRoot = Path.GetFullPath(targetDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
272 var boundary = targetRoot + Path.DirectorySeparatorChar;
273
274 foreach (var raw in relativeFilePaths.Where(p => !string.IsNullOrWhiteSpace(p)))
275 {
276 var rel = NormalizeRelativeRequirement(raw.Trim());
277 var resolved = Path.GetFullPath(Path.Combine(targetRoot, rel));
278 if (!resolved.StartsWith(boundary, StringComparison.OrdinalIgnoreCase)
279 && !string.Equals(resolved, targetRoot, StringComparison.OrdinalIgnoreCase))
280 {
281 throw new ArgumentException($"RequireMirrorFile must stay under Target: `{raw}`");
282 }
283
284 if (!File.Exists(resolved))
285 {
286 throw new FileNotFoundException(
287 $"RequireMirrorFile not found after mirror (incomplete dotnet publish?): {rel}",
288 resolved);
289 }
290 }
291 }
292
293 private static string NormalizeRelativeRequirement(string raw) =>
294 raw.Replace('/', Path.DirectorySeparatorChar).Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar).Trim('\\');
295
296 private static string QuoteIfNeeded(string s) =>
297 s.Contains(' ') ? $"\"{s}\"" : s;
298}
299
300
View only · write via MCP/CIDE