File: Program.cs
Web Access
Project: src\tools\GenerateTemplateManifest\GenerateTemplateManifest.csproj (GenerateTemplateManifest)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Text.Json;
using System.Xml.Linq;
using GenerateTemplateManifest;
using NuGet.Configuration;
 
var command = new TemplateManifestCommand();
command.SetAction(async result =>
{
    var sourceDirectory = result.GetValue(command.SourceDirectory)!.FullName;
    var processedDirectory = result.GetValue(command.ProcessedDirectory)!.FullName;
    var manifestPath = result.GetValue(command.ManifestPath)!.FullName;
    var nugetConfigPath = result.GetValue(command.NuGetConfigPath)!.FullName;
    var localPackageFeed = result.GetValue(command.LocalPackageFeed)!.FullName;
    var restoreDirectory = result.GetValue(command.RestoreDirectory)!.FullName;
    var planOnly = result.GetValue(command.PlanOnly);
    var builtPackages = planOnly ? new HashSet<string>() : ComponentManifest.ReadBuiltPackages(localPackageFeed);
    var manifest = new ComponentManifest(builtPackages);
    var lockCount = 0;
 
    foreach (var lockPath in Directory.EnumerateFiles(sourceDirectory, "package-lock.json", SearchOption.AllDirectories))
    {
        lockCount++;
        var packagePath = Path.Combine(Path.GetDirectoryName(lockPath)!, "package.json");
        using var packageJson = JsonDocument.Parse(File.ReadAllText(packagePath));
        using var lockfile = JsonDocument.Parse(File.ReadAllText(lockPath));
        if (lockfile.RootElement.GetProperty("lockfileVersion").GetInt32() != 3)
        {
            throw new InvalidDataException($"Expected npm lockfile version 3 in {lockPath}");
        }
 
        var packages = lockfile.RootElement.GetProperty("packages");
        var root = packages.GetProperty("");
        foreach (var group in new[] { "dependencies", "devDependencies", "optionalDependencies" })
        {
            var declared = packageJson.RootElement.TryGetProperty(group, out var direct)
                ? direct.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString())
                : new Dictionary<string, string?>();
            var locked = root.TryGetProperty(group, out var resolved)
                ? resolved.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString())
                : new Dictionary<string, string?>();
            if (declared.Count != locked.Count || declared.Any(p => !locked.TryGetValue(p.Key, out var version) || p.Value != version))
            {
                throw new InvalidDataException($"{group} in {lockPath} differs from {packagePath}; update the npm lockfile");
            }
        }
 
        foreach (var package in packages.EnumerateObject())
        {
            if (package.Name.Length == 0)
            {
                continue; // The root entry is the template app, not an installed package.
            }
 
            var marker = "node_modules/";
            var index = package.Name.LastIndexOf(marker, StringComparison.Ordinal);
            if (index < 0)
            {
                throw new InvalidDataException($"Unexpected npm package path '{package.Name}' in {lockPath}");
            }
 
            // Aliases are installed under their alias path, e.g. node_modules/aliased-number,
            // but the lock entry's "name": "is-number" identifies the actual component.
            var name = package.Value.TryGetProperty("name", out var packageName)
                ? packageName.GetString() ?? ""
                : package.Name[(index + marker.Length)..];
            manifest.Register("npm", name,
                package.Value.GetProperty("version").GetString() ?? "", lockPath);
        }
    }
 
    foreach (var packagePath in Directory.EnumerateFiles(sourceDirectory, "package.json", SearchOption.AllDirectories))
    {
        if (!File.Exists(Path.Combine(Path.GetDirectoryName(packagePath)!, "package-lock.json")))
        {
            throw new InvalidDataException($"Missing npm lockfile for {packagePath}");
        }
    }
 
    Directory.CreateDirectory(restoreDirectory);
    File.WriteAllText(Path.Combine(restoreDirectory, "Directory.Build.props"), "<Project />");
    File.WriteAllText(Path.Combine(restoreDirectory, "Directory.Build.targets"), "<Project />");
    File.WriteAllText(Path.Combine(restoreDirectory, "Directory.Packages.props"), "<Project />");
 
    // Use a fresh discovery directory so removed templates/configurations cannot survive an
    // incremental run. Only the stable, deduplicated restore projects retain their assets/cache.
    var discoveryDirectory = Directory.CreateTempSubdirectory("aspire-template-cg-");
    var plan = new TemplateRestorePlan();
    var discoveryTime = Stopwatch.StartNew();
    try
    {
        await plan.DiscoverAsync(sourceDirectory, processedDirectory, discoveryDirectory.FullName).ConfigureAwait(false);
    }
    finally
    {
        discoveryDirectory.Delete(recursive: true);
    }
    if (plan.Projects.Count == 0)
    {
        throw new InvalidDataException($"No template projects found under {sourceDirectory}");
    }
    var solutionPath = plan.Write(restoreDirectory);
    discoveryTime.Stop();
    Console.WriteLine($"{plan.ConfigurationCount} template configurations, {plan.ProjectCount} project instances -> {plan.Projects.Count} unique projects ({plan.Projects.Sum(p => p.Frameworks.Count)} framework graphs), planned in {discoveryTime.Elapsed.TotalSeconds:F2}s.");
    if (planOnly)
    {
        return 0;
    }
    foreach (var (name, version) in plan.Sdks)
    {
        manifest.Register("nuget", name, version, solutionPath);
    }
 
    var startInfo = new ProcessStartInfo("dotnet")
    {
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        UseShellExecute = false,
        WorkingDirectory = restoreDirectory
    };
    // SDK resolution also discovers config files by walking project directories, independently
    // of restore's --configfile. Always refresh the adjacent config, including when a local feed disappears.
    var config = XDocument.Load(nugetConfigPath);
    var configuration = config.Root ?? throw new InvalidDataException($"Invalid NuGet config: {nugetConfigPath}");
    var sources = configuration.Element("packageSources")
        ?? throw new InvalidDataException($"NuGet config has no packageSources: {nugetConfigPath}");
    var mappings = configuration.Element("packageSourceMapping")
        ?? throw new InvalidDataException($"NuGet config has no packageSourceMapping: {nugetConfigPath}");
    var builtIds = builtPackages.Select(package => package[..package.LastIndexOf('/')]).ToHashSet(StringComparer.OrdinalIgnoreCase);
 
    // A -dev nupkg can change without its version changing. Use a private extraction cache and
    // evict only this build's exact identities. The normal cache remains a read-only local feed
    // for external packages, avoiding downloads already performed by the managed build.
    var sharedCache = SettingsUtility.GetGlobalPackagesFolder(
        Settings.LoadSpecificSettings(Path.GetDirectoryName(nugetConfigPath)!, Path.GetFileName(nugetConfigPath)));
    var privateCache = Path.Combine(restoreDirectory, "packages");
    Directory.CreateDirectory(privateCache);
    foreach (var package in builtPackages)
    {
        var packagePath = Path.GetFullPath(Path.Combine(privateCache, package.ToLowerInvariant().Replace('/', Path.DirectorySeparatorChar)));
        if (!packagePath.StartsWith(privateCache + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
        {
            throw new InvalidDataException($"Invalid built package cache path: {package}");
        }
        if (Directory.Exists(packagePath))
        {
            Directory.Delete(packagePath, recursive: true);
        }
    }
    startInfo.Environment["NUGET_PACKAGES"] = privateCache;
    if (Directory.Exists(sharedCache) && !string.Equals(sharedCache, privateCache, StringComparison.OrdinalIgnoreCase))
    {
        const string cacheSource = "template-external-cache";
        sources.Add(new XElement("add", new XAttribute("key", cacheSource), new XAttribute("value", sharedCache)));
        var cachePatterns = mappings.Elements("packageSource").Elements("package")
            .Select(p => p.Attribute("pattern")!.Value)
            .Where(pattern => !builtIds.Contains(pattern))
            .Distinct(StringComparer.OrdinalIgnoreCase);
        mappings.Add(new XElement("packageSource", new XAttribute("key", cacheSource),
            cachePatterns.Select(pattern => new XElement("package", new XAttribute("pattern", pattern)))));
    }
    // Exact mappings take precedence over the external feed/cache patterns, so packages
    // produced by this build cannot be satisfied by another build's package cache.
    const string localSourceName = "template-built-local";
    sources.Add(new XElement("add",
        new XAttribute("key", localSourceName),
        new XAttribute("value", localPackageFeed)));
    mappings.Add(new XElement("packageSource",
        new XAttribute("key", localSourceName),
        builtIds.Order(StringComparer.OrdinalIgnoreCase).Select(id => new XElement("package", new XAttribute("pattern", id)))));
    var restoreConfigPath = Path.Combine(restoreDirectory, "nuget.config");
    config.Save(restoreConfigPath);
 
    // A sibling graph can repopulate an evicted -dev package before NuGet checks another graph
    // for no-op restore. Reevaluate every graph, even when all package IDs/versions are unchanged.
    foreach (var argument in new[] { "restore", solutionPath, "--configfile", restoreConfigPath, "--force", "--disable-build-servers", "--verbosity", "quiet" })
    {
        startInfo.ArgumentList.Add(argument);
    }
 
    var restoreTime = Stopwatch.StartNew();
    using (var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start dotnet restore"))
    {
        // Drain both streams concurrently so the shared restore cannot block on a full pipe.
        var stdout = process.StandardOutput.ReadToEndAsync();
        var stderr = process.StandardError.ReadToEndAsync();
        process.WaitForExit();
        Console.Write(stdout.GetAwaiter().GetResult());
        Console.Error.Write(stderr.GetAwaiter().GetResult());
        if (process.ExitCode != 0)
        {
            throw new InvalidOperationException($"Template dependency restore failed (exit code {process.ExitCode})");
        }
        restoreTime.Stop();
        Console.WriteLine($"Independent graph batch restored in {restoreTime.Elapsed.TotalSeconds:F2}s.");
    }
 
    foreach (var project in plan.Projects)
    {
        // JavaScript SDK projects have no NuGet restore graph. Their npm graph is read from
        // the lockfile; the SDK itself is registered above. Do not silently skip new NuGet inputs.
        if (project.Frameworks.Count == 0 &&
            project.Project.Attribute("Sdk")!.Value.StartsWith("Microsoft.VisualStudio.JavaScript.Sdk/", StringComparison.Ordinal))
        {
            if (project.Project.Descendants().Any(e => e.Name.LocalName is "PackageReference" or "PackageDownload" or "ProjectReference"))
            {
                throw new InvalidDataException($"JavaScript project {project.Name} contains unsupported NuGet dependencies.");
            }
            continue;
        }
        var assetsPath = Path.Combine(restoreDirectory, project.Name, "obj", "project.assets.json");
        using var assets = JsonDocument.Parse(File.ReadAllText(assetsPath));
        manifest.RegisterNuGetAssets(assets.RootElement, assetsPath);
    }
 
    Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!);
    File.WriteAllText(manifestPath, manifest.Serialize());
    Console.WriteLine($"Wrote {manifest.Count} registrations from {plan.Projects.Count} restore projects and {lockCount} npm lockfiles to {manifestPath}");
    return 0;
});
 
return await command.Parse(args).InvokeAsync().ConfigureAwait(false);