File: Migrations\TypeScriptAppHostMigration.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using Spectre.Console;
 
namespace Aspire.Cli.Migrations;
 
/// <summary>
/// Migrates a legacy TypeScript AppHost (<c>apphost.ts</c> importing the generated SDK from
/// <c>./.modules/aspire.js</c>) to the modern <c>apphost.mts</c> layout (importing from
/// <c>./.aspire/modules/aspire.mjs</c>).
/// </summary>
/// <remarks>
/// The legacy layout keeps working via the compatibility path in
/// <see cref="GuestAppHostProject"/>, so this migration is the user-facing, automated way to move
/// onto the recommended format. See https://github.com/microsoft/aspire/issues/17842.
/// </remarks>
internal sealed class TypeScriptAppHostMigration : IMigration
{
    private const string TsConfigFileName = "tsconfig.apphost.json";
    private const string PackageJsonFileName = "package.json";
    private static readonly string[] s_eslintConfigFileNames = ["eslint.config.mjs", "eslint.config.js"];
 
    private readonly IProjectLocator _projectLocator;
    private readonly ILanguageDiscovery _languageDiscovery;
    private readonly IAppHostProjectFactory _projectFactory;
    private readonly IInteractionService _interactionService;
    private readonly CliExecutionContext _executionContext;
    private readonly ILogger<TypeScriptAppHostMigration> _logger;
 
    public TypeScriptAppHostMigration(
        IProjectLocator projectLocator,
        ILanguageDiscovery languageDiscovery,
        IAppHostProjectFactory projectFactory,
        IInteractionService interactionService,
        CliExecutionContext executionContext,
        ILogger<TypeScriptAppHostMigration> logger)
    {
        _projectLocator = projectLocator;
        _languageDiscovery = languageDiscovery;
        _projectFactory = projectFactory;
        _interactionService = interactionService;
        _executionContext = executionContext;
        _logger = logger;
    }
 
    /// <inheritdoc />
    public string Id => "typescript-apphost-mts";
 
    /// <inheritdoc />
    public int Order => 100;
 
    /// <inheritdoc />
    public async Task<MigrationDescriptor?> DetectAsync(MigrationContext context, CancellationToken cancellationToken)
    {
        var appHostFile = await ResolveLegacyAppHostAsync(context, cancellationToken);
        if (appHostFile is null)
        {
            return null;
        }
 
        return new MigrationDescriptor
        {
            Title = string.Format(
                CultureInfo.CurrentCulture,
                MigrationStrings.TypeScriptMigrationTitleFormat,
                LegacyTypeScriptAppHost.LegacyAppHostFileName,
                LegacyTypeScriptAppHost.ModernAppHostFileName),
            Detail = string.Format(
                CultureInfo.CurrentCulture,
                DoctorCommandStrings.LegacyTypeScriptAppHostMessageFormat,
                appHostFile.FullName),
            Metadata = new JsonObject
            {
                ["language"] = KnownLanguageId.TypeScript,
                ["appHostPath"] = appHostFile.FullName
            }
        };
    }
 
    /// <inheritdoc />
    public async Task ApplyAsync(MigrationContext context, CancellationToken cancellationToken)
    {
        // Re-resolve rather than trusting an earlier DetectAsync result: applying must be safe to
        // run on its own and a no-op when there is nothing (left) to migrate.
        var appHostFile = await ResolveLegacyAppHostAsync(context, cancellationToken);
        if (appHostFile?.Directory is not { Exists: true } appHostDirectory)
        {
            return;
        }
 
        var modernAppHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, LegacyTypeScriptAppHost.ModernAppHostFileName));
 
        await _interactionService.ShowStatusAsync(
            MigrationStrings.MigratingStatus,
            () =>
            {
                MigrateFilesOnDisk(appHostFile, modernAppHostFile, appHostDirectory);
                return Task.FromResult(true);
            },
            emoji: KnownEmojis.Gear);
 
        _interactionService.DisplaySuccess(string.Format(
            CultureInfo.CurrentCulture,
            MigrationStrings.MigrationSucceededFormat,
            appHostFile.Name,
            modernAppHostFile.Name));
 
        // Regenerate the SDK into the modern `.aspire/modules/` folder so the project is
        // immediately runnable. This is best-effort: if the toolchain isn't available the
        // file migration above still stands and the user can run `aspire restore` later.
        await RegenerateSdkAsync(modernAppHostFile, appHostDirectory, cancellationToken);
    }
 
    /// <summary>
    /// Resolves the current TypeScript AppHost and returns it only when it is a legacy
    /// <c>apphost.ts</c> in a legacy layout (no modern <c>apphost.mts</c> sibling). Returns
    /// <see langword="null"/> otherwise.
    /// </summary>
    private async Task<FileInfo?> ResolveLegacyAppHostAsync(MigrationContext context, CancellationToken cancellationToken)
    {
        var appHostFile = context.AppHostFile;
        if (appHostFile is null)
        {
            appHostFile = await LegacyTypeScriptAppHost.ResolveTypeScriptAppHostAsync(
                _projectLocator, _languageDiscovery, _executionContext.WorkingDirectory, _logger, cancellationToken);
        }
        else if (!TypeScriptAppHostToolchainResolver.IsTypeScriptLanguage(_languageDiscovery.GetLanguageByFile(appHostFile)))
        {
            return null;
        }
 
        if (appHostFile?.Directory is not { Exists: true } appHostDirectory ||
            !LegacyTypeScriptAppHost.IsLegacyAppHostFile(appHostFile) ||
            !LegacyTypeScriptAppHost.IsLegacyLayout(appHostDirectory.FullName))
        {
            return null;
        }
 
        return appHostFile;
    }
 
    /// <summary>
    /// Performs the on-disk migration: rewrites metadata to point at the modern files, renames the
    /// AppHost, rewrites its SDK imports, and removes the legacy <c>.modules/</c> folder (regenerated
    /// under <c>.aspire/modules/</c> afterwards).
    /// </summary>
    private void MigrateFilesOnDisk(FileInfo legacyAppHostFile, FileInfo modernAppHostFile, DirectoryInfo appHostDirectory)
    {
        // Keep the destructive AppHost swap last: if an unexpected failure occurs while rewriting
        // metadata, the user-authored apphost.ts is still available and the migration can be retried.
        var modernAppHostContent = LegacyTypeScriptAppHost.RewriteAppHostContent(File.ReadAllText(legacyAppHostFile.FullName));
 
        // 1. Update aspire.config.json's appHost.path. We edit the JSON node directly rather than
        //    round-tripping through AspireConfigFile.Save so that unrelated config keys/values
        //    (profiles, packages, and any properties the typed model doesn't know about) are
        //    preserved. The file is re-serialized with indentation, so exact original whitespace
        //    is not retained.
        UpdateConfigAppHostPath(appHostDirectory);
 
        // 2. Update tsconfig.apphost.json include entries to point at the modern files.
        UpdateTsConfigIncludes(appHostDirectory);
 
        // 3. Update metadata that can reference the AppHost file name directly.
        UpdatePackageJsonScripts(appHostDirectory);
        UpdateEslintConfigFiles(appHostDirectory);
 
        // 4. Write the new apphost.mts and only then remove apphost.ts.
        File.WriteAllText(modernAppHostFile.FullName, modernAppHostContent);
        legacyAppHostFile.Delete();
 
        // 5. Remove the legacy .modules folder; the modern .aspire/modules is regenerated next.
        var legacyModulesDir = Path.Combine(appHostDirectory.FullName, LanguageInfo.LegacyGeneratedFolderName);
        if (Directory.Exists(legacyModulesDir))
        {
            Directory.Delete(legacyModulesDir, recursive: true);
        }
    }
 
    private void UpdateConfigAppHostPath(DirectoryInfo appHostDirectory)
    {
        var configDirectory = ConfigurationHelper.GetConfigRootDirectory(appHostDirectory);
        var configPath = Path.Combine(configDirectory.FullName, AspireConfigFile.FileName);
        if (!File.Exists(configPath))
        {
            return;
        }
 
        try
        {
            if (JsonNode.Parse(File.ReadAllText(configPath)) is not JsonObject root ||
                root["appHost"] is not JsonObject appHost ||
                appHost["path"] is not JsonValue pathValue ||
                pathValue.GetValueKind() is not JsonValueKind.String)
            {
                return;
            }
 
            var currentPath = pathValue.GetValue<string>();
 
            // Only touch a path that still references the legacy file name so we don't disturb a
            // path that has already been migrated or points elsewhere. Preserve any directory prefix.
            if (!currentPath.EndsWith(LegacyTypeScriptAppHost.LegacyAppHostFileName, StringComparison.OrdinalIgnoreCase))
            {
                return;
            }
 
            appHost["path"] = string.Concat(
                currentPath.AsSpan(0, currentPath.Length - LegacyTypeScriptAppHost.LegacyAppHostFileName.Length),
                LegacyTypeScriptAppHost.ModernAppHostFileName);
 
            File.WriteAllText(configPath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
        }
        catch (JsonException ex)
        {
            _logger.LogDebug(ex, "Failed to update appHost.path in {ConfigPath} during migration", configPath);
        }
    }
 
    private void UpdateTsConfigIncludes(DirectoryInfo appHostDirectory)
    {
        var tsConfigPath = Path.Combine(appHostDirectory.FullName, TsConfigFileName);
        if (!File.Exists(tsConfigPath))
        {
            return;
        }
 
        try
        {
            // tsconfig files are JSONC. Skipping comments makes common files parse successfully,
            // but comments are not preserved when this migration re-serializes the include array.
            if (JsonNode.Parse(
                    File.ReadAllText(tsConfigPath),
                    documentOptions: new JsonDocumentOptions
                    {
                        AllowTrailingCommas = true,
                        CommentHandling = JsonCommentHandling.Skip
                    }) is not JsonObject root ||
                root["include"] is not JsonArray include)
            {
                return;
            }
 
            var rewritten = new JsonArray();
            foreach (var entry in include)
            {
                if (entry is JsonValue value && value.GetValueKind() is JsonValueKind.String)
                {
                    // Use JsonValue.Create + the non-generic Add(JsonNode?) overload so we stay
                    // trimming/AOT-safe (JsonArray.Add<T> on a string is flagged IL2026/IL3050).
                    rewritten.Add((JsonNode?)JsonValue.Create(LegacyTypeScriptAppHost.RewriteTsConfigIncludeEntry(value.GetValue<string>())));
                }
                else
                {
                    rewritten.Add(entry?.DeepClone());
                }
            }
 
            root["include"] = rewritten;
            File.WriteAllText(tsConfigPath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
        }
        catch (JsonException ex)
        {
            _logger.LogDebug(ex, "Failed to update include entries in {TsConfigPath} during migration", tsConfigPath);
        }
    }
 
    private void UpdatePackageJsonScripts(DirectoryInfo appHostDirectory)
    {
        var packageJsonPath = Path.Combine(appHostDirectory.FullName, PackageJsonFileName);
        if (!File.Exists(packageJsonPath))
        {
            return;
        }
 
        try
        {
            if (JsonNode.Parse(File.ReadAllText(packageJsonPath)) is not JsonObject root ||
                root["scripts"] is not JsonObject scripts)
            {
                return;
            }
 
            var changed = false;
            foreach (var script in scripts.ToArray())
            {
                if (script.Value is JsonValue value &&
                    value.GetValueKind() is JsonValueKind.String)
                {
                    var current = value.GetValue<string>();
                    var rewritten = LegacyTypeScriptAppHost.RewriteAppHostFileNameReferences(current);
                    if (!string.Equals(current, rewritten, StringComparison.Ordinal))
                    {
                        scripts[script.Key] = (JsonNode?)JsonValue.Create(rewritten);
                        changed = true;
                    }
                }
            }
 
            if (changed)
            {
                File.WriteAllText(packageJsonPath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
            }
        }
        catch (JsonException ex)
        {
            _logger.LogDebug(ex, "Failed to update script entries in {PackageJsonPath} during migration", packageJsonPath);
        }
    }
 
    private static void UpdateEslintConfigFiles(DirectoryInfo appHostDirectory)
    {
        foreach (var configFileName in s_eslintConfigFileNames)
        {
            var configPath = Path.Combine(appHostDirectory.FullName, configFileName);
            if (!File.Exists(configPath))
            {
                continue;
            }
 
            var current = File.ReadAllText(configPath);
            var rewritten = LegacyTypeScriptAppHost.RewriteAppHostFileNameReferences(current);
            if (!string.Equals(current, rewritten, StringComparison.Ordinal))
            {
                File.WriteAllText(configPath, rewritten);
            }
        }
    }
 
    private async Task RegenerateSdkAsync(FileInfo modernAppHostFile, DirectoryInfo appHostDirectory, CancellationToken cancellationToken)
    {
        try
        {
            if (_projectFactory.TryGetProject(modernAppHostFile) is not GuestAppHostProject guestProject)
            {
                return;
            }
 
            var success = await _interactionService.ShowStatusAsync(
                MigrationStrings.RegeneratingStatus,
                async () => await guestProject.BuildAndGenerateSdkAsync(appHostDirectory, cancellationToken: cancellationToken),
                emoji: KnownEmojis.Gear);
 
            if (!success)
            {
                _interactionService.DisplayMessage(
                    KnownEmojis.Warning,
                    $"[yellow]{Markup.Escape(MigrationStrings.RegenerateFailedWarning)}[/]",
                    allowMarkup: true);
            }
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            throw;
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "SDK regeneration after migration failed");
            _interactionService.DisplayMessage(
                KnownEmojis.Warning,
                $"[yellow]{Markup.Escape(MigrationStrings.RegenerateFailedWarning)}[/]",
                allowMarkup: true);
        }
    }
}