File: Program.cs
Web Access
Project: src\tools\CreateLayout\CreateLayout.csproj (CreateLayout)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.CommandLine;
using System.Diagnostics;
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
 
namespace Aspire.Tools.CreateLayout;
 
/// <summary>
/// Creates the Aspire bundle layout for distribution.
/// This tool assembles all components into a self-contained package.
/// </summary>
/// <remarks>
/// See docs/specs/bundle.md for the complete bundle specification and layout structure.
/// </remarks>
public static class Program
{
    public static async Task<int> Main(string[] args)
    {
        var outputOption = new Option<string>("--output", "-o")
        {
            Description = "Output directory for the layout",
            Required = true
        };
 
        var artifactsOption = new Option<string>("--artifacts", "-a")
        {
            Description = "Path to build artifacts directory",
            Required = true
        };
 
        var ridOption = new Option<string>("--rid")
        {
            Description = "Runtime identifier",
            Required = true
        };
        ridOption.AcceptOnlyFromAmong("win-x64", "win-arm64", "linux-x64", "linux-arm64", "linux-musl-x64", "osx-x64", "osx-arm64");
 
        var configurationOption = new Option<string>("--configuration", "-c")
        {
            Description = "Build configuration of the published components",
            Required = true
        };
 
        var bundleVersionOption = new Option<string>("--bundle-version")
        {
            Description = "Version string for the layout",
            DefaultValueFactory = _ => "0.0.0-dev"
        };
 
        var archiveOption = new Option<bool>("--archive")
        {
            Description = "Create a .tar.gz archive after building on all platforms"
        };
 
        var verboseOption = new Option<bool>("--verbose")
        {
            Description = "Enable verbose output"
        };
 
        var rootCommand = new RootCommand("CreateLayout - Build Aspire bundle layout for distribution");
        rootCommand.Options.Add(outputOption);
        rootCommand.Options.Add(artifactsOption);
        rootCommand.Options.Add(ridOption);
        rootCommand.Options.Add(configurationOption);
        rootCommand.Options.Add(bundleVersionOption);
        rootCommand.Options.Add(archiveOption);
        rootCommand.Options.Add(verboseOption);
 
        rootCommand.SetAction(async (parseResult, cancellationToken) =>
        {
            var outputPath = parseResult.GetValue(outputOption)!;
            var artifactsPath = parseResult.GetValue(artifactsOption)!;
            var rid = parseResult.GetValue(ridOption)!;
            var configuration = parseResult.GetValue(configurationOption)!;
            var version = parseResult.GetValue(bundleVersionOption)!;
            var createArchive = parseResult.GetValue(archiveOption);
            var verbose = parseResult.GetValue(verboseOption);
 
            try
            {
                using var builder = new LayoutBuilder(outputPath, artifactsPath, rid, configuration, version, verbose);
                await builder.BuildAsync().ConfigureAwait(false);
 
                if (createArchive)
                {
                    await builder.CreateArchiveAsync().ConfigureAwait(false);
                }
 
                return 0;
            }
            catch (Exception ex)
            {
                Console.Error.WriteLine($"Error: {ex.Message}");
                if (verbose)
                {
                    Console.Error.WriteLine(ex.StackTrace);
                }
                return 1;
            }
        });
 
        return await rootCommand.Parse(args).InvokeAsync().ConfigureAwait(false);
    }
 
}
 
/// <summary>
/// Builds the layout directory structure.
/// </summary>
internal sealed class LayoutBuilder : IDisposable
{
    private readonly string _outputPath;
    private readonly string _artifactsPath;
    private readonly string _rid;
    private readonly string _configuration;
    private readonly string _version;
    private readonly bool _verbose;
 
    public LayoutBuilder(string outputPath, string artifactsPath, string rid, string configuration, string version, bool verbose)
    {
        _outputPath = Path.GetFullPath(outputPath);
        _artifactsPath = Path.GetFullPath(artifactsPath);
        _rid = rid;
        _configuration = configuration;
        _version = version;
        _verbose = verbose;
    }
 
    public void Dispose()
    {
        // Nothing to dispose
    }
 
    public async Task BuildAsync()
    {
        Log($"Building layout for {_rid} version {_version}");
        Log($"Configuration: {_configuration}");
        Log($"Output: {_outputPath}");
        Log($"Artifacts: {_artifactsPath}");
 
        // Clean and create output directory
        if (Directory.Exists(_outputPath))
        {
            Directory.Delete(_outputPath, recursive: true);
        }
        Directory.CreateDirectory(_outputPath);
 
        // Copy components
        CopyManaged();
        CopyDashboard();
        await CopyDcpAsync().ConfigureAwait(false);
 
        Log("Layout build complete!");
    }
 
    private void CopyManaged()
    {
        Log("Copying aspire-managed...");
 
        var managedPublishPath = FindPublishPath("Aspire.Managed", "net10.0");
        if (managedPublishPath is null)
        {
            throw new InvalidOperationException("Aspire.Managed publish output not found.");
        }
 
        var managedDir = Path.Combine(_outputPath, "managed");
        Directory.CreateDirectory(managedDir);
 
        // Copy the managed executable and known sidecars, not the apphost stubs from referenced
        // Exe projects that also appear in publish output.
        var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase);
        var managedExeName = isWindows ? "aspire-managed.exe" : "aspire-managed";
 
        var managedExePath = Path.Combine(managedPublishPath, managedExeName);
        if (!File.Exists(managedExePath))
        {
            throw new InvalidOperationException($"aspire-managed executable not found at {managedExePath}");
        }
 
        File.Copy(managedExePath, Path.Combine(managedDir, managedExeName), overwrite: true);
 
        if (isWindows)
        {
            // Hex1b launches hex1bpty.exe beside the app; conpty.dll must stay beside that helper.
            // ConPTY selects OpenConsole by OS architecture, so win-x64 also needs the ARM64 host
            // when running under emulation. These files cannot live inside the managed single-file.
            // https://github.com/microsoft/terminal/blob/main/src/winconpty/winconpty.cpp
            string[] ptyFiles = _rid == "win-x64"
                ? ["hex1bpty.exe", "conpty.dll", "x64/OpenConsole.exe", "arm64/OpenConsole.exe"]
                : ["hex1bpty.exe", "conpty.dll", "arm64/OpenConsole.exe"];
 
            foreach (var relativePath in ptyFiles)
            {
                var destination = Path.Combine(managedDir, relativePath);
                Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
                File.Copy(Path.Combine(managedPublishPath, relativePath), destination, overwrite: true);
            }
        }
 
        Log($"  Copied aspire-managed to managed/");
    }
 
    internal void CopyDashboard()
    {
        Log("Copying Native AOT Dashboard...");
 
        // A non-RID publish contains a framework-dependent apphost instead of the native Dashboard.
        // Only RID-specific output can be used here.
        var dashboardPublishPath = FindPublishPath("Aspire.Dashboard", "net11.0", requireRidSpecific: true);
        if (dashboardPublishPath is null)
        {
            throw new InvalidOperationException("Aspire.Dashboard publish output not found.");
        }
 
        var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase);
        var dashboardExeName = isWindows ? "Aspire.Dashboard.exe" : "Aspire.Dashboard";
        var dashboardExePath = Path.Combine(dashboardPublishPath, dashboardExeName);
        if (!File.Exists(dashboardExePath))
        {
            throw new InvalidOperationException($"Native AOT Dashboard executable not found at {dashboardExePath}");
        }
 
        var wwwrootPath = Path.Combine(dashboardPublishPath, "wwwroot");
        if (!Directory.Exists(wwwrootPath))
        {
            throw new InvalidOperationException($"Native AOT Dashboard static assets not found at {wwwrootPath}");
        }
 
        var blazorScriptPath = Path.Combine(wwwrootPath, "_framework", "blazor.web.js");
        if (!File.Exists(blazorScriptPath) || new FileInfo(blazorScriptPath).Length == 0)
        {
            throw new InvalidOperationException($"Native AOT Dashboard Blazor script missing or empty at {blazorScriptPath}");
        }
 
        var sqliteLibraryName = isWindows
            ? "e_sqlite3.dll"
            : _rid.StartsWith("osx-", StringComparison.OrdinalIgnoreCase) ? "libe_sqlite3.dylib" : "libe_sqlite3.so";
        var sqliteLibraryPath = Path.Combine(dashboardPublishPath, sqliteLibraryName);
        if (!File.Exists(sqliteLibraryPath) || new FileInfo(sqliteLibraryPath).Length == 0)
        {
            throw new InvalidOperationException($"Native AOT Dashboard SQLite library missing or empty at {sqliteLibraryPath}");
        }
 
        // Match eng/dashboardpack/Common.projitems: native dependencies and other publish assets
        // are required at runtime, but debug symbols are not part of the bundle payload.
        var dashboardDir = Path.Combine(_outputPath, "dashboard");
        CopyDirectory(dashboardPublishPath, dashboardDir, excludeSymbols: true);
 
        Log("  Copied Native AOT Dashboard to dashboard/");
    }
 
    private Task CopyDcpAsync()
    {
        Log("Copying DCP...");
 
        // DCP comes from NuGet packages, look for it in the artifacts
        var dcpPath = FindDcpPath();
        if (dcpPath is null)
        {
            throw new InvalidOperationException(
                $"DCP package not found for RID '{_rid}'. " +
                $"Ensure the DCP NuGet package for the target platform is restored before running CreateLayout. " +
                $"A bundle without DCP would fail layout validation at runtime.");
        }
 
        var dcpDir = Path.Combine(_outputPath, "dcp");
        Directory.CreateDirectory(dcpDir);
 
        CopyDirectory(dcpPath, dcpDir, excludeSymbols: false);
        Log($"  Copied DCP to dcp");
 
        return Task.CompletedTask;
    }
 
    public async Task<string> CreateArchiveAsync()
    {
        var archiveName = $"aspire-{_version}-{_rid}";
        var archivePath = Path.Combine(Path.GetDirectoryName(_outputPath)!, archiveName + ".tar.gz");
 
        Log($"Creating archive: {archivePath}");
 
        if (OperatingSystem.IsWindows())
        {
            // Use .NET TarWriter + GZip on Windows (no system tar available)
            var fileStream = File.Create(archivePath);
            await using (fileStream.ConfigureAwait(false))
            {
                var gzipStream = new GZipStream(fileStream, CompressionLevel.Optimal);
                await using (gzipStream.ConfigureAwait(false))
                {
                    var tarWriter = new System.Formats.Tar.TarWriter(gzipStream, leaveOpen: true);
                    await using (tarWriter.ConfigureAwait(false))
                    {
                        var topLevelDir = Path.GetFileName(_outputPath);
                        foreach (var filePath in Directory.EnumerateFiles(_outputPath, "*", SearchOption.AllDirectories))
                        {
                            var relativePath = Path.GetRelativePath(Path.GetDirectoryName(_outputPath)!, filePath).Replace('\\', '/');
                            var dataStream = File.OpenRead(filePath);
                            await using (dataStream.ConfigureAwait(false))
                            {
                                var entry = new System.Formats.Tar.PaxTarEntry(System.Formats.Tar.TarEntryType.RegularFile, relativePath)
                                {
                                    DataStream = dataStream
                                };
                                await tarWriter.WriteEntryAsync(entry).ConfigureAwait(false);
                            }
                        }
                    }
                }
            }
        }
        else
        {
            // Use tar for tar.gz
            var psi = new ProcessStartInfo
            {
                FileName = "tar",
                Arguments = $"-czf \"{archivePath}\" -C \"{Path.GetDirectoryName(_outputPath)}\" \"{Path.GetFileName(_outputPath)}\"",
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false
            };
            // Prevent macOS from including resource forks/extended attributes in the archive
            psi.Environment["COPYFILE_DISABLE"] = "1";
 
            using var process = Process.Start(psi);
            if (process is not null)
            {
                await process.WaitForExitAsync().ConfigureAwait(false);
                if (process.ExitCode != 0)
                {
                    var stderr = await process.StandardError.ReadToEndAsync().ConfigureAwait(false);
                    throw new InvalidOperationException($"Failed to create archive (exit code {process.ExitCode}): {stderr}");
                }
            }
        }
 
        Log($"Archive created: {archivePath}");
        return archivePath;
    }
 
    internal string? FindPublishPath(string projectName, string targetFramework, bool requireRidSpecific = false)
    {
        // Only use the requested configuration so stale output from another build cannot be bundled.
        // Prefer RID-specific publish paths over non-RID output.
        var ridSpecificSearchPaths = new[]
        {
            // PlatformName-scoped output used by RID packaging and signing.
            Path.Combine(_artifactsPath, "bin", projectName, _rid, _configuration, targetFramework, _rid, "publish"),
            // RID-specific self-contained publish output (preferred for Aspire.Managed)
            Path.Combine(_artifactsPath, "bin", projectName, _configuration, targetFramework, _rid, "publish"),
            // Native AOT output
            Path.Combine(_artifactsPath, "bin", projectName, _configuration, targetFramework, _rid, "native"),
        };
 
        var searchPaths = requireRidSpecific
            ? ridSpecificSearchPaths
            :
            [
                .. ridSpecificSearchPaths,
                // Non-RID publish output
                Path.Combine(_artifactsPath, "bin", projectName, _configuration, targetFramework, "publish"),
            ];
 
        foreach (var path in searchPaths)
        {
            if (Directory.Exists(path))
            {
                return path;
            }
        }
 
        return null;
    }
 
    private string? FindDcpPath()
    {
        // DCP is in NuGet packages as Microsoft.DeveloperControlPlane.{os}-{arch}
        // Respect the NUGET_PACKAGES environment variable, then fall back to the default location.
        var nugetPackages = Environment.GetEnvironmentVariable("NUGET_PACKAGES")
            ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages");
 
        // Map RID to DCP package name format
        // win-x64 -> windows-amd64, linux-x64 -> linux-amd64, osx-arm64 -> darwin-arm64
        var dcpRid = _rid.ToLowerInvariant() switch
        {
            "win-x64" => "windows-amd64",
            "win-arm64" => "windows-arm64",
            "linux-x64" => "linux-amd64",
            "linux-arm64" => "linux-arm64",
            "linux-musl-x64" => "linux-musl-amd64",
            "osx-x64" => "darwin-amd64",
            "osx-arm64" => "darwin-arm64",
            _ => _rid.ToLowerInvariant()
        };
 
        var dcpPackageName = $"microsoft.developercontrolplane.{dcpRid}";
        var dcpPackagePath = Path.Combine(nugetPackages, dcpPackageName);
 
        if (Directory.Exists(dcpPackagePath))
        {
            // Find latest version (semantic versioning aware)
            var latestVersion = Directory.GetDirectories(dcpPackagePath)
                .Select(d => (Path: d, Version: Version.TryParse(Path.GetFileName(d), out var v) ? v : null))
                .Where(x => x.Version is not null)
                .OrderByDescending(x => x.Version)
                .FirstOrDefault();
 
            if (latestVersion.Path is not null)
            {
                var toolsPath = Path.Combine(latestVersion.Path, "tools");
                if (Directory.Exists(toolsPath))
                {
                    Log($"  Found DCP at {toolsPath}");
                    return toolsPath;
                }
            }
        }
 
        // Fallback: try the old Aspire.Hosting.Orchestration.{rid} package name
        var oldPackageName = $"aspire.hosting.orchestration.{_rid.ToLowerInvariant()}";
        var oldPackagePath = Path.Combine(nugetPackages, oldPackageName);
 
        if (Directory.Exists(oldPackagePath))
        {
            var latestVersion = Directory.GetDirectories(oldPackagePath)
                .OrderByDescending(d => d)
                .FirstOrDefault();
 
            if (latestVersion is not null)
            {
                var toolsPath = Path.Combine(latestVersion, "tools");
                if (Directory.Exists(toolsPath))
                {
                    Log($"  Found DCP at {toolsPath} (legacy package)");
                    return toolsPath;
                }
            }
        }
 
        return null;
    }
 
    private static void CopyDirectory(string source, string destination, bool excludeSymbols)
    {
        Directory.CreateDirectory(destination);
 
        foreach (var file in Directory.GetFiles(source))
        {
            if (excludeSymbols && (file.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase) || file.EndsWith(".dbg", StringComparison.OrdinalIgnoreCase)))
            {
                continue;
            }
 
            var destFile = Path.Combine(destination, Path.GetFileName(file));
            File.Copy(file, destFile, overwrite: true);
        }
 
        foreach (var dir in Directory.GetDirectories(source))
        {
            // macOS stores debug symbols in .dSYM directory bundles, not single files.
            // Exclude the whole bundle because it is only needed for debugging, not running the Dashboard.
            if (excludeSymbols && dir.EndsWith(".dSYM", StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }
 
            var destDir = Path.Combine(destination, Path.GetFileName(dir));
            CopyDirectory(dir, destDir, excludeSymbols);
        }
    }
 
    private void Log(string message)
    {
        if (_verbose || !message.StartsWith("  ", StringComparison.Ordinal))
        {
            Console.WriteLine(message);
        }
    }
}
 
#region JSON Models
 
[JsonSerializable(typeof(JsonElement))]
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    WriteIndented = true)]
internal sealed partial class LayoutJsonContext : JsonSerializerContext
{
}
 
#endregion