| File: Projects\LegacyTypeScriptAppHost.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.Text.RegularExpressions; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Projects; /// <summary> /// Helpers for detecting and describing the legacy TypeScript AppHost layout. /// </summary> /// <remarks> /// TypeScript AppHosts scaffolded before the move to <c>apphost.mts</c> ship an /// <c>apphost.ts</c> that imports the generated SDK from <c>./.modules/aspire.js</c>. /// The newer recommended layout uses <c>apphost.mts</c> importing from /// <c>./.aspire/modules/aspire.mjs</c>. The legacy layout continues to work (see /// <see cref="GuestAppHostProject.ConvertGeneratedFilesForLegacyTypeScriptAppHost"/>), /// so detection here is only used to nudge users toward migrating via /// <c>aspire update --migrate</c>. /// See: https://github.com/microsoft/aspire/issues/17842 /// </remarks> internal static partial class LegacyTypeScriptAppHost { /// <summary> /// The legacy TypeScript AppHost entry point file name. /// </summary> internal const string LegacyAppHostFileName = "apphost.ts"; /// <summary> /// The modern TypeScript AppHost entry point file name. /// </summary> internal const string ModernAppHostFileName = "apphost.mts"; /// <summary> /// Returns <see langword="true"/> when <paramref name="appPath"/> contains a legacy /// <c>apphost.ts</c> AND no modern <c>apphost.mts</c> sibling. The absence of /// <c>apphost.mts</c> is what keeps the CLI on the legacy generated-file layout. /// </summary> internal static bool IsLegacyLayout(string appPath) { return File.Exists(Path.Combine(appPath, LegacyAppHostFileName)) && !File.Exists(Path.Combine(appPath, ModernAppHostFileName)); } /// <summary> /// Returns <see langword="true"/> when <paramref name="appHostFile"/> is a legacy /// <c>apphost.ts</c> entry point. /// </summary> internal static bool IsLegacyAppHostFile(FileInfo appHostFile) { return appHostFile.Name.Equals(LegacyAppHostFileName, StringComparison.OrdinalIgnoreCase); } /// <summary> /// Rewrites the contents of a legacy <c>apphost.ts</c> so its SDK imports target the modern /// layout. Legacy AppHosts import from <c>./.modules/aspire.js</c>; the modern layout uses /// <c>./.aspire/modules/aspire.mjs</c>. This is the inverse of /// <see cref="GuestAppHostProject.ConvertGeneratedFilesForLegacyTypeScriptAppHost"/>. /// </summary> /// <remarks> /// Example transform: /// <code> /// import { createBuilder } from './.modules/aspire.js'; /// // becomes /// import { createBuilder } from './.aspire/modules/aspire.mjs'; /// </code> /// All rewrites are anchored to the <c>.modules/</c> path segment so that only the generated /// SDK imports are touched. Unanchored extension replacements would corrupt unrelated user /// imports — e.g. <c>./database.js</c> contains the substring <c>base.js</c> and would /// otherwise become <c>./database.mjs</c>. The <c>.modules/</c> → <c>.aspire/modules/</c> /// substitution itself is safe because the modern path segment is <c>/modules/</c> /// (slash-prefixed), never <c>.modules/</c> (dot-prefixed). /// </remarks> internal static string RewriteAppHostContent(string content) { return content // Rewrite the known generated SDK imports (folder + extension) in one anchored step. .Replace(".modules/aspire.js", ".aspire/modules/aspire.mjs", StringComparison.Ordinal) .Replace(".modules/base.js", ".aspire/modules/base.mjs", StringComparison.Ordinal) .Replace(".modules/transport.js", ".aspire/modules/transport.mjs", StringComparison.Ordinal) // Move any remaining .modules/ references to .aspire/modules/ without altering file // extensions, so user imports outside the generated SDK are never rewritten. .Replace(".modules/", ".aspire/modules/", StringComparison.Ordinal); } /// <summary> /// Rewrites a single <c>tsconfig.apphost.json</c> <c>include</c> entry from the legacy layout /// to the modern one (e.g. <c>apphost.ts</c> → <c>apphost.mts</c> and /// <c>.modules/aspire.ts</c> → <c>.aspire/modules/aspire.mts</c>). Entries that don't match /// the legacy shape are returned unchanged. /// </summary> internal static string RewriteTsConfigIncludeEntry(string entry) { var isLegacyGeneratedModule = entry.Contains(".modules/", StringComparison.Ordinal); var isLegacyAppHost = EndsWithPathSegment(entry, LegacyAppHostFileName); var rewritten = entry.Replace(".modules/", ".aspire/modules/", StringComparison.Ordinal); // Only files the migration moves on disk should change extensions. Other user includes // (for example src/**/*.ts) must stay covered by TypeScript after migration. if ((isLegacyAppHost || isLegacyGeneratedModule) && rewritten.EndsWith(".ts", StringComparison.Ordinal) && !rewritten.EndsWith(".mts", StringComparison.Ordinal) && !rewritten.EndsWith(".d.ts", StringComparison.Ordinal)) { rewritten = string.Concat(rewritten.AsSpan(0, rewritten.Length - ".ts".Length), ".mts"); } return rewritten; } /// <summary> /// Rewrites standalone references to the legacy AppHost file name in text-based metadata files. /// </summary> internal static string RewriteAppHostFileNameReferences(string content) { return LegacyAppHostFileNameRegex().Replace(content, ModernAppHostFileName); } private static bool EndsWithPathSegment(string path, string segment) { if (!path.EndsWith(segment, StringComparison.Ordinal)) { return false; } var segmentStart = path.Length - segment.Length; return segmentStart is 0 || path[segmentStart - 1] is '/' or '\\'; } /// <summary> /// Resolves the TypeScript AppHost entry point for the current working directory, if any. /// Prefers the AppHost recorded in settings (<c>aspire.config.json</c>) and falls back to a /// recursive file-system scan. Returns <see langword="null"/> when no TypeScript AppHost can /// be located. Both <c>aspire update --migrate</c> and the <c>aspire doctor</c> legacy-layout check /// share this so detection stays in lockstep. /// </summary> /// <param name="projectLocator">Used to read the configured AppHost from settings.</param> /// <param name="languageDiscovery">Used to detect the language and locate the AppHost file.</param> /// <param name="workingDirectory">The directory to resolve the AppHost relative to.</param> /// <param name="logger">Logs diagnostics when resolution fails unexpectedly.</param> /// <param name="cancellationToken">Cancellation token.</param> internal static async Task<FileInfo?> ResolveTypeScriptAppHostAsync( IProjectLocator projectLocator, ILanguageDiscovery languageDiscovery, DirectoryInfo workingDirectory, ILogger logger, CancellationToken cancellationToken) { try { var configuredAppHost = await projectLocator.GetAppHostFromSettingsAsync(cancellationToken); if (configuredAppHost is not null && TypeScriptAppHostToolchainResolver.IsTypeScriptLanguage(languageDiscovery.GetLanguageByFile(configuredAppHost))) { return configuredAppHost; } var detectedLanguageId = await languageDiscovery.DetectLanguageRecursiveAsync(workingDirectory, cancellationToken); if (detectedLanguageId is null) { return null; } var detectedLanguage = languageDiscovery.GetLanguageById(detectedLanguageId.Value); if (!TypeScriptAppHostToolchainResolver.IsTypeScriptLanguage(detectedLanguage)) { return null; } var discoveredPath = detectedLanguage?.FindInDirectory(workingDirectory.FullName); return discoveredPath is not null ? new FileInfo(discoveredPath) : null; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { logger.LogDebug(ex, "Failed to resolve TypeScript AppHost"); return null; } } [GeneratedRegex(@"\bapphost\.ts\b", RegexOptions.CultureInvariant)] private static partial Regex LegacyAppHostFileNameRegex(); }