// 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.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Shared;
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 trayOption = new Option<string?>("--tray-dir")
{
Description = "Prepared native tray directory (.app directory on macOS; required for Windows, macOS, and glibc Linux)"
};
var verboseOption = new Option<bool>("--verbose")
{
Description = "Enable verbose output"
};
var dashboardOption = new Option<string?>("--dashboard")
{
Description = "Prepared Dashboard publish directory (discovered from artifacts when omitted)"
};
var managedOption = new Option<string?>("--managed")
{
Description = "Prepared Aspire.Managed publish directory (discovered from artifacts when omitted)"
};
var terminalHostOption = new Option<string?>("--terminal-host")
{
Description = "Prepared TerminalHost publish directory (discovered from artifacts when omitted)"
};
var appHostServerOption = new Option<string?>("--apphost-server")
{
Description = "Prepared AppHost Server publish directory (discovered from artifacts when omitted)"
};
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(trayOption);
rootCommand.Options.Add(dashboardOption);
rootCommand.Options.Add(managedOption);
rootCommand.Options.Add(terminalHostOption);
rootCommand.Options.Add(appHostServerOption);
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,
parseResult.GetValue(trayOption), parseResult.GetValue(dashboardOption), parseResult.GetValue(managedOption),
parseResult.GetValue(terminalHostOption), parseResult.GetValue(appHostServerOption));
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;
private readonly string? _trayDirectory;
private readonly string? _dashboardPath;
private readonly string? _managedPath;
private readonly string? _terminalHostPath;
private readonly string? _appHostServerPath;
public LayoutBuilder(string outputPath, string artifactsPath, string rid, string configuration, string version, bool verbose,
string? trayDirectory, string? dashboardPath, string? managedPath, string? terminalHostPath, string? appHostServerPath)
{
_outputPath = Path.GetFullPath(outputPath);
_artifactsPath = Path.GetFullPath(artifactsPath);
_rid = rid;
_configuration = configuration;
_version = version;
_verbose = verbose;
_trayDirectory = trayDirectory;
_dashboardPath = dashboardPath;
_managedPath = managedPath;
_terminalHostPath = terminalHostPath;
_appHostServerPath = appHostServerPath;
}
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();
CopyAppHostServer();
CopyDashboard();
CopyTerminalHost();
CopyTray();
await CopyDcpAsync().ConfigureAwait(false);
Log("Layout build complete!");
}
internal void CopyTray()
{
if (_rid == "linux-musl-x64")
{
return;
}
if (string.IsNullOrWhiteSpace(_trayDirectory))
{
throw new InvalidOperationException($"The {_rid} bundle requires --tray-dir pointing to the prepared native tray directory (.app directory on macOS).");
}
if (_rid is "linux-x64" or "linux-arm64")
{
LinuxTrayPayload.Validate(_trayDirectory, _rid);
if (OperatingSystem.IsWindows())
{
throw new InvalidOperationException("Linux tray bundles must be assembled on Unix to preserve executable permissions.");
}
var destination = Path.Combine(_outputPath, "tray");
Directory.CreateDirectory(destination);
foreach (var name in new[] { LinuxTrayPayload.ExecutableName, LinuxTrayPayload.IconName })
{
var source = Path.Combine(_trayDirectory, name);
var target = Path.Combine(destination, name);
File.Copy(source, target, overwrite: true);
File.SetUnixFileMode(target, File.GetUnixFileMode(source));
}
Log("Copied Linux tray to tray/");
return;
}
if (_rid.StartsWith("win-", StringComparison.Ordinal))
{
WindowsTrayPayload.Validate(_trayDirectory, _rid);
var destination = Path.Combine(_outputPath, "tray");
Directory.CreateDirectory(destination);
// Only the explicitly produced runtime payload is shipped, never PDBs,
// managed build intermediates, or logs from a previous smoke run.
foreach (var name in new[] { WindowsTrayPayload.ExecutableName, WindowsTrayPayload.IconName })
{
File.Copy(Path.Combine(_trayDirectory, name), Path.Combine(destination, name), overwrite: true);
}
Log("Copied Windows tray to tray/");
return;
}
if (!_rid.StartsWith("osx-", StringComparison.OrdinalIgnoreCase))
{
return;
}
foreach (var relativePath in new[] { "Contents/MacOS/aspire-tray", "Contents/Info.plist", "Contents/Resources/Aspire.icns", "Contents/_CodeSignature/CodeResources" })
{
var path = Path.Combine(_trayDirectory, relativePath);
if (!File.Exists(path))
{
throw new InvalidOperationException($"Required macOS tray payload not found: {path}. Publish the tray app before creating the bundle.");
}
}
var executablePath = Path.Combine(_trayDirectory, "Contents", "MacOS", "aspire-tray");
if (OperatingSystem.IsWindows())
{
throw new InvalidOperationException("macOS tray bundles must be assembled on a Unix host to preserve executable permissions.");
}
if ((File.GetUnixFileMode(executablePath) & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) == 0)
{
throw new InvalidOperationException($"The macOS tray payload is not executable: {executablePath}");
}
// Copy the sealed app verbatim, including hidden files, resources and
// _CodeSignature. Reconstructing the app from its executable invalidates signing.
CopyDirectory(_trayDirectory, Path.Combine(_outputPath, "tray", "Aspire Tray.app"), excludeSymbols: false);
Log("Copied Aspire Tray.app to tray/");
}
internal void CopyManaged()
{
Log("Copying aspire-managed...");
var managedPublishPath = ResolvePublishPath("Aspire.Managed", "--managed", _managedPath, requireRidSpecific: false);
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);
Log($" Copied aspire-managed to managed/");
}
internal void CopyTerminalHost()
{
var publishPath = ResolvePublishPath("Aspire.TerminalHost", "--terminal-host", _terminalHostPath, requireRidSpecific: true)
?? throw new InvalidOperationException("Aspire.TerminalHost RID-specific publish output not found.");
foreach (var file in TerminalHostPayload.GetRequiredFiles(_rid))
{
var path = Path.Combine(publishPath, file);
if (!File.Exists(path) || new FileInfo(path).Length == 0)
{
throw new InvalidOperationException($"Required Native AOT terminal host payload missing or empty at {path}");
}
}
CopyDirectory(publishPath, Path.Combine(_outputPath, "terminalhost"), excludeSymbols: true);
// Existing Hosting versions discover DCP's ConPTY provider beside the configured terminal
// executable. Compatibility launches point at managed/aspire-managed.exe, so retain the
// Windows PTY sidecars there as well; otherwise DCP falls back to inbox ConPTY and loses Kitty graphics.
foreach (var file in TerminalHostPayload.GetWindowsPtyFiles(_rid))
{
var destination = Path.Combine(_outputPath, "managed", file);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
File.Copy(Path.Combine(publishPath, file), destination, overwrite: true);
}
Log(" Copied Native AOT terminal host to terminalhost/");
}
internal void CopyAppHostServer()
{
var publishPath = ResolvePublishPath("Aspire.Hosting.Native.Server", "--apphost-server", _appHostServerPath, requireRidSpecific: true)
?? throw new InvalidOperationException("AppHost Server v2 publish output not found.");
var extension = _rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : "";
var source = Path.Combine(publishPath, "Aspire.Hosting.Native.Server" + extension);
foreach (var requiredFile in new[] { source, Path.Combine(publishPath, "ats-contract.json") })
{
if (!File.Exists(requiredFile) || new FileInfo(requiredFile).Length == 0)
{
throw new InvalidOperationException($"AppHost Server v2 payload missing or empty at {requiredFile}");
}
}
var destination = Path.Combine(_outputPath, "apphost-server");
CopyDirectory(publishPath, destination, excludeSymbols: true);
// Product naming is independent of the assembly identity used in ATS type IDs.
File.Move(Path.Combine(destination, Path.GetFileName(source)),
Path.Combine(destination, "aspire-apphost-server" + extension), overwrite: true);
Log("Copied AppHost Server v2 to apphost-server/");
}
internal void CopyDashboard()
{
Log("Copying Native AOT Dashboard...");
if (_dashboardPath is not null && string.IsNullOrWhiteSpace(_dashboardPath))
{
throw new InvalidOperationException("--dashboard requires a nonempty publish directory.");
}
// A non-RID publish contains a framework-dependent apphost instead of the native Dashboard.
// Only RID-specific output can be used here.
var dashboardFramework = GetComponentTargetFramework("Aspire.Dashboard");
var dashboardPublishPath = _dashboardPath ?? FindPublishPath("Aspire.Dashboard", dashboardFramework, requireRidSpecific: true);
if (dashboardPublishPath is null)
{
throw new InvalidOperationException(
$"Aspire.Dashboard publish output not found for RID '{_rid}', configuration '{_configuration}'. " +
$"Searched: {string.Join(", ", GetRidSpecificPublishPaths("Aspire.Dashboard", dashboardFramework))}");
}
if (!Directory.Exists(dashboardPublishPath))
{
throw new InvalidOperationException($"Dashboard publish directory not found at {dashboardPublishPath}");
}
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 = $"{(OperatingSystem.IsMacOS() ? "--no-xattrs " : "")}-czf \"{archivePath}\" -C \"{Path.GetDirectoryName(_outputPath)}\" \"{Path.GetFileName(_outputPath)}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
// COPYFILE_DISABLE suppresses AppleDouble files, but BSD tar still emits
// binary PAX attributes such as "SCHILY.xattr.com.apple.provenance=\x01\x02..."
// unless --no-xattrs is set. Those records can contain NUL/newline bytes
// that .NET's TarReader rejects. App signatures live in the Mach-O and
// _CodeSignature files, so excluding filesystem xattrs preserves the seal.
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;
}
// Versions.props -> SDK-generated assembly metadata -> standalone discovery.
// Bundle.proj passes explicit paths instead, keeping copying aligned with validation and signing.
internal static string GetComponentTargetFramework(string projectName) =>
typeof(LayoutBuilder).Assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
.SingleOrDefault(attribute => attribute.Key == $"{projectName}.TargetFramework")?.Value
?? throw new InvalidOperationException($"Missing target framework metadata for {projectName}.");
private string? ResolvePublishPath(string projectName, string option, string? selectedPath, bool requireRidSpecific)
{
if (selectedPath is null)
{
return FindPublishPath(projectName, GetComponentTargetFramework(projectName), requireRidSpecific);
}
if (string.IsNullOrWhiteSpace(selectedPath))
{
throw new InvalidOperationException($"{option} requires a nonempty publish directory.");
}
if (!Directory.Exists(selectedPath))
{
throw new InvalidOperationException($"{projectName} publish directory not found at {selectedPath}");
}
return selectedPath;
}
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 = GetRidSpecificPublishPaths(projectName, targetFramework);
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[] GetRidSpecificPublishPaths(string projectName, string targetFramework) =>
[
// 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"),
];
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);
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(destFile, File.GetUnixFileMode(file));
}
}
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);
}
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(destination, File.GetUnixFileMode(source));
}
}
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