| 1 | using System.ComponentModel; |
| 2 | using System.Diagnostics; |
| 3 | using System.Runtime.InteropServices; |
| 4 | using Ookii.CommandLine; |
| 5 | |
| 6 | namespace 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.")] |
| 11 | partial 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 | |
| 58 | internal 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 | |