Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ internal enum TypeScriptAppHostToolchain
Npm,
Bun,
Yarn,
Pnpm
Pnpm,
Deno
}

internal static class TypeScriptAppHostToolchainResolver
Expand All @@ -28,6 +29,9 @@ internal static class TypeScriptAppHostToolchainResolver
private const string YarnConfigFileName = ".yarnrc.yml";
private const string PackageLockFileName = "package-lock.json";
private const string PnpmLockFileName = "pnpm-lock.yaml";
private const string DenoLockFileName = "deno.lock";
private const string DenoJsonFileName = "deno.json";
private const string DenoJsoncFileName = "deno.jsonc";

public static bool IsTypeScriptLanguage(LanguageInfo? language)
{
Expand Down Expand Up @@ -87,6 +91,21 @@ internal static TypeScriptAppHostToolchainResolution ResolveWithReason(Directory
return CreateLockFileResolution(TypeScriptAppHostToolchain.Yarn, YarnConfigFileName, candidateDirectory);
}

if (File.Exists(Path.Combine(candidateDirectory.FullName, DenoLockFileName)))
{
return CreateLockFileResolution(TypeScriptAppHostToolchain.Deno, DenoLockFileName, candidateDirectory);
}

if (File.Exists(Path.Combine(candidateDirectory.FullName, DenoJsonFileName)))
{
return CreateLockFileResolution(TypeScriptAppHostToolchain.Deno, DenoJsonFileName, candidateDirectory);
}

if (File.Exists(Path.Combine(candidateDirectory.FullName, DenoJsoncFileName)))
{
return CreateLockFileResolution(TypeScriptAppHostToolchain.Deno, DenoJsoncFileName, candidateDirectory);
}

if (File.Exists(Path.Combine(candidateDirectory.FullName, PackageLockFileName)))
{
return CreateLockFileResolution(TypeScriptAppHostToolchain.Npm, PackageLockFileName, candidateDirectory);
Expand All @@ -113,6 +132,7 @@ public static string GetCommandName(TypeScriptAppHostToolchain toolchain)
TypeScriptAppHostToolchain.Bun => "bun",
TypeScriptAppHostToolchain.Yarn => "yarn",
TypeScriptAppHostToolchain.Pnpm => "pnpm",
TypeScriptAppHostToolchain.Deno => "deno",
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
};
}
Expand All @@ -130,6 +150,7 @@ public static string GetDisplayName(TypeScriptAppHostToolchain toolchain)
TypeScriptAppHostToolchain.Bun => "Bun",
TypeScriptAppHostToolchain.Yarn => "Yarn",
TypeScriptAppHostToolchain.Pnpm => "pnpm",
TypeScriptAppHostToolchain.Deno => "Deno",
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
};
}
Expand Down Expand Up @@ -198,6 +219,13 @@ private static CommandSpec[] CreatePreExecuteCommands(TypeScriptAppHostToolchain
Command = "pnpm",
Args = ["exec", "tsc", "--noEmit", "-p", tsConfigFileName]
},
// Deno type-checks with its own compiler and config (deno.json), so there is no
// tsc/tsconfig step. `deno check` is the native no-emit type-check over the AppHost graph.
TypeScriptAppHostToolchain.Deno => new CommandSpec
{
Command = "deno",
Args = ["check", "{appHostFile}"]
},
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
}
];
Expand All @@ -222,6 +250,15 @@ private static CommandSpec CreateExecuteCommand(TypeScriptAppHostToolchain toolc
Command = "pnpm",
Args = ["exec", "tsx", "--tsconfig", tsConfigFileName, "{appHostFile}"]
},
// Deno runs the AppHost as its own runtime (no tsx transpiler). Unlike Node/Bun, Deno is
// not permissive for package.json projects: APIs like Deno.env/Deno.serve throw NotCapable
// without flags (and error non-interactively rather than prompting). The AppHost needs
// full host access, so `-A` grants all permissions, mirroring how Node/Bun run unrestricted.
TypeScriptAppHostToolchain.Deno => new CommandSpec
{
Command = "deno",
Args = ["run", "-A", "{appHostFile}"]
},
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
};
}
Expand Down Expand Up @@ -275,6 +312,14 @@ private static CommandSpec CreateWatchCommand(TypeScriptAppHostToolchain toolcha
"--exec", $"pnpm exec tsc --noEmit -p {tsConfigFileName} && pnpm exec tsx --tsconfig {tsConfigFileName} \"{{appHostFile}}\""
]
},
// Deno has a native file watcher, so nodemon is unnecessary. `--check` makes each restart
// type-check before running, matching the nodemon "tsc --noEmit && run" behavior other
// toolchains emulate. `-A` grants full permissions as on the non-watch execute command.
TypeScriptAppHostToolchain.Deno => new CommandSpec
{
Command = "deno",
Args = ["run", "-A", "--check", "--watch", "{appHostFile}"]
},
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
};
}
Expand Down Expand Up @@ -344,6 +389,7 @@ private static bool TryParseToolchain(string packageManagerName, out TypeScriptA
"bun" => TypeScriptAppHostToolchain.Bun,
"yarn" => TypeScriptAppHostToolchain.Yarn,
"pnpm" => TypeScriptAppHostToolchain.Pnpm,
"deno" => TypeScriptAppHostToolchain.Deno,
_ => null
};

Expand Down
3 changes: 3 additions & 0 deletions src/Aspire.Cli/Scaffolding/ScaffoldingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,9 @@ private static string CreateRootDelegateScript(TypeScriptAppHostToolchain toolch
TypeScriptAppHostToolchain.Pnpm => $"pnpm --dir {relativeAppHostDirectory} run {scriptName}",
TypeScriptAppHostToolchain.Yarn => $"yarn --cwd {relativeAppHostDirectory} run {scriptName}",
TypeScriptAppHostToolchain.Bun => $"bun --cwd {relativeAppHostDirectory} run {scriptName}",
// Deno has no `run <script>` for package.json scripts; `deno task` runs them and `--cwd`
// scopes the task to the nested AppHost package, mirroring npm's `--prefix`.
TypeScriptAppHostToolchain.Deno => $"deno task --cwd {relativeAppHostDirectory} {scriptName}",
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
};
}
Expand Down
3 changes: 2 additions & 1 deletion src/Aspire.Cli/Utils/CommandPathResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ internal static class CommandPathResolver
["npx"] = new("Node.js", "https://nodejs.org/en/download"),
["bun"] = new("Bun", "https://bun.sh/docs/installation"),
["yarn"] = new("Yarn", "https://yarnpkg.com/getting-started/install"),
["pnpm"] = new("pnpm", "https://pnpm.io/installation")
["pnpm"] = new("pnpm", "https://pnpm.io/installation"),
["deno"] = new("Deno", "https://docs.deno.com/runtime/getting_started/installation/")
};

/// <summary>
Expand Down
3 changes: 2 additions & 1 deletion src/Aspire.Cli/Utils/MissingJavaScriptToolWarning.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ internal static class MissingJavaScriptToolWarning
"npx is not installed or not found in PATH.",
"bun is not installed or not found in PATH.",
"yarn is not installed or not found in PATH.",
"pnpm is not installed or not found in PATH."
"pnpm is not installed or not found in PATH.",
"deno is not installed or not found in PATH."
];

public static bool IsMatch(IEnumerable<(OutputLineStream Stream, string Line)> lines)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ internal static void SetPackageManager(string projectRoot, string toolchain, boo
return;
}

foreach (var lockFileName in new[] { "package-lock.json", "bun.lock", "bun.lockb", "pnpm-lock.yaml", "yarn.lock" })
foreach (var lockFileName in new[] { "package-lock.json", "bun.lock", "bun.lockb", "pnpm-lock.yaml", "yarn.lock", "deno.lock" })
{
var lockFilePath = Path.Combine(projectRoot, lockFileName);
if (File.Exists(lockFilePath))
Expand Down Expand Up @@ -80,14 +80,33 @@ internal static string GetTypeCheckCommand(string toolchain, string tsConfigFile
"yarn" => $"yarn run tsc --noEmit -p {tsConfigFileName}",
"pnpm" => $"pnpm exec tsc --noEmit -p {tsConfigFileName}",
"npm" => $"npx --no-install tsc --noEmit -p {tsConfigFileName}",
// Deno type-checks with its own compiler over the AppHost graph; there is no tsc/tsconfig step.
"deno" => "deno check apphost.mts",
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, "Unsupported TypeScript AppHost toolchain.")
};

/// <summary>
/// Gets the script runner command for a toolchain.
/// </summary>
internal static string GetRunScriptCommand(string toolchain, string scriptName) =>
$"{GetCommandName(toolchain)} run {scriptName}";
NormalizeToolchain(toolchain) switch
{
// Deno runs package.json scripts via `deno task`, not `deno run` (which runs a file).
"deno" => $"deno task {scriptName}",
_ => $"{GetCommandName(toolchain)} run {scriptName}"
};

/// <summary>
/// Gets the console text that indicates watch mode is active for a toolchain.
/// The E2E path runs the scaffolded <c>aspire:dev</c> package script for every toolchain,
/// so readiness comes from <c>tsc --watch</c>.
/// </summary>
internal static string GetWatchModeReadyText(string toolchain)
{
_ = NormalizeToolchain(toolchain);

return "Watching for file changes.";
}

/// <summary>
/// Gets the primary lock file name a toolchain should produce after restore/install.
Expand All @@ -99,6 +118,7 @@ internal static string GetLockFileName(string toolchain) =>
"yarn" => "yarn.lock",
"pnpm" => "pnpm-lock.yaml",
"npm" => "package-lock.json",
"deno" => "deno.lock",
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, "Unsupported TypeScript AppHost toolchain.")
};

Expand All @@ -114,6 +134,7 @@ internal static string GetPackageManager(string toolchain) =>
"yarn" => "yarn@4.14.1",
"pnpm" => "pnpm@10.0.0",
"npm" => "npm@10.0.0",
"deno" => "deno@2.9.0",
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, "Unsupported TypeScript AppHost toolchain.")
};

Expand All @@ -132,6 +153,7 @@ internal static string GetDisplayName(string toolchain) =>
"yarn" => "Yarn",
"pnpm" => "pnpm",
"npm" => "Node.js",
"deno" => "Deno",
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, "Unsupported TypeScript AppHost toolchain.")
};

Expand All @@ -147,6 +169,7 @@ internal static string GetInstallationLink(string toolchain) =>
"yarn" => "https://yarnpkg.com/getting-started/install",
"pnpm" => "https://pnpm.io/installation",
"npm" => "https://nodejs.org/en/download",
"deno" => "https://docs.deno.com/runtime/getting_started/installation/",
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, "Unsupported TypeScript AppHost toolchain.")
};

Expand Down Expand Up @@ -180,6 +203,7 @@ private static string GetCommandName(string toolchain) =>
"yarn" => "yarn",
"pnpm" => "pnpm",
"npm" => "npm",
"deno" => "deno",
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, "Unsupported TypeScript AppHost toolchain.")
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Cli.EndToEnd.Tests.Helpers;
using Xunit;

namespace Aspire.Cli.EndToEnd.Tests;

public sealed class TypeScriptAppHostToolchainTestHelpersTests
{
[Fact]
public void GetWatchModeReadyText_WhenToolchainIsDeno_ReturnsTypeScriptCompilerWatchText()
{
var watchModeReadyText = TypeScriptAppHostToolchainTestHelpers.GetWatchModeReadyText("deno");

Assert.Equal("Watching for file changes.", watchModeReadyText);
}
}
5 changes: 3 additions & 2 deletions tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ public sealed class TypeScriptPolyglotTests(ITestOutputHelper output)
"npm",
"bun",
"yarn",
"pnpm"
"pnpm",
"deno"
};

[Theory]
Expand Down Expand Up @@ -289,7 +290,7 @@ public async Task GeneratedAspireDevScript_StartsWatchMode_WithConfiguredToolcha
await auto.TypeAsync(TypeScriptAppHostToolchainTestHelpers.GetRunScriptCommand(toolchain, "aspire:dev"));
await auto.EnterAsync();
await auto.WaitUntilAsync(
s => s.ContainsText("Watching for file changes."),
s => s.ContainsText(TypeScriptAppHostToolchainTestHelpers.GetWatchModeReadyText(toolchain)),
timeout: TimeSpan.FromMinutes(2),
description: $"{toolchain} watch mode to start");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public sealed class TypeScriptAppHostToolingCheckTests(ITestOutputHelper outputH
[InlineData("bun@1.2.0", nameof(TypeScriptAppHostToolchain.Bun))]
[InlineData("yarn@4.14.1", nameof(TypeScriptAppHostToolchain.Yarn))]
[InlineData("pnpm@10.12.1", nameof(TypeScriptAppHostToolchain.Pnpm))]
[InlineData("deno@2.9.0", nameof(TypeScriptAppHostToolchain.Deno))]
public async Task CheckAsync_ReturnsPass_WhenConfiguredToolchainIsAvailable(string packageManagerSpec, string toolchainName)
{
var toolchain = Enum.Parse<TypeScriptAppHostToolchain>(toolchainName);
Expand Down Expand Up @@ -54,6 +55,7 @@ public async Task CheckAsync_ReturnsPass_WhenConfiguredToolchainIsAvailable(stri
[InlineData("bun@1.2.0", nameof(TypeScriptAppHostToolchain.Bun), "Bun", "https://bun.sh/docs/installation")]
[InlineData("yarn@4.14.1", nameof(TypeScriptAppHostToolchain.Yarn), "Yarn", "https://yarnpkg.com/getting-started/install")]
[InlineData("pnpm@10.12.1", nameof(TypeScriptAppHostToolchain.Pnpm), "pnpm", "https://pnpm.io/installation")]
[InlineData("deno@2.9.0", nameof(TypeScriptAppHostToolchain.Deno), "Deno", "https://docs.deno.com/runtime/getting_started/installation/")]
public async Task CheckAsync_ReturnsFail_WhenConfiguredToolchainIsMissing(
string packageManagerSpec,
string toolchainName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,43 @@ public void Resolve_WhenPackageManagerIsBun_ReturnsBun()
Assert.Equal(TypeScriptAppHostToolchain.Bun, toolchain);
}

[Fact]
public void Resolve_WhenPackageManagerIsDeno_ReturnsDeno()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"packageManager\": \"deno@2.9.0\" }");

var toolchain = TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, new TestEnvironment(), logger: null);

Assert.Equal(TypeScriptAppHostToolchain.Deno, toolchain);
}

[Fact]
public void Resolve_WhenDenoLockExists_ReturnsDeno()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"name\": \"apphost\" }");
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "deno.lock"), "{ \"version\": \"5\" }");

var resolution = TypeScriptAppHostToolchainResolver.ResolveWithReason(workspace.WorkspaceRoot, new TestEnvironment());

Assert.Equal(TypeScriptAppHostToolchain.Deno, resolution.Toolchain);
Assert.Equal($"deno.lock found in {workspace.WorkspaceRoot.FullName}", resolution.Reason);
}

[Fact]
public void Resolve_WhenDenoJsonExists_ReturnsDeno()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"name\": \"apphost\" }");
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "deno.json"), "{}");

var resolution = TypeScriptAppHostToolchainResolver.ResolveWithReason(workspace.WorkspaceRoot, new TestEnvironment());

Assert.Equal(TypeScriptAppHostToolchain.Deno, resolution.Toolchain);
Assert.Equal($"deno.json found in {workspace.WorkspaceRoot.FullName}", resolution.Reason);
}

[Fact]
public void Resolve_WhenPnpmLockExists_ReturnsPnpm()
{
Expand Down Expand Up @@ -346,6 +383,26 @@ public void ApplyToRuntimeSpec_WhenPnpmSelected_UsesPnpmTypeCheckCommands()
Assert.Contains("pnpm exec tsc --noEmit -p tsconfig.apphost.json && pnpm exec tsx --tsconfig tsconfig.apphost.json \"{appHostFile}\"", runtimeSpec.WatchExecute?.Args ?? []);
}

[Fact]
public void ApplyToRuntimeSpec_WhenDenoSelected_UsesDenoRunCommands()
{
var baseRuntimeSpec = CreateBaseRuntimeSpec();

var runtimeSpec = TypeScriptAppHostToolchainResolver.ApplyToRuntimeSpec(baseRuntimeSpec, TypeScriptAppHostToolchain.Deno);

Assert.Equal("TypeScript (Deno)", runtimeSpec.DisplayName);
Assert.Equal("deno", runtimeSpec.InstallDependencies?.Command);
Assert.Equal(["install"], runtimeSpec.InstallDependencies!.Args);
var preExecute = Assert.Single(runtimeSpec.PreExecute!);
Assert.Equal("deno", preExecute.Command);
Assert.Equal(["check", "{appHostFile}"], preExecute.Args);
Assert.Equal("deno", runtimeSpec.Execute.Command);
Assert.Equal(["run", "-A", "{appHostFile}"], runtimeSpec.Execute.Args);
Assert.Equal("deno", runtimeSpec.WatchExecute?.Command);
Assert.Equal(["run", "-A", "--check", "--watch", "{appHostFile}"], runtimeSpec.WatchExecute!.Args);
Assert.Equal("node", runtimeSpec.ExtensionLaunchCapability);
}

private static RuntimeSpec CreateBaseRuntimeSpec()
{
return new RuntimeSpec
Expand Down
2 changes: 2 additions & 0 deletions tests/Aspire.Cli.Tests/Utils/CommandPathResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public class CommandPathResolverTests
[InlineData("bun.cmd", "bun is not installed or not found in PATH. Please install Bun and try again.")]
[InlineData("yarn", "yarn is not installed or not found in PATH. Please install Yarn and try again.")]
[InlineData("pnpm", "pnpm is not installed or not found in PATH. Please install pnpm and try again.")]
[InlineData("deno", "deno is not installed or not found in PATH. Please install Deno and try again.")]
public void TryResolveCommand_WhenJavaScriptCommandIsMissing_ReturnsToolSpecificInstallMessage(string command, string expectedMessage)
{
static string? MissingCommandResolver(string _) => null;
Expand Down Expand Up @@ -57,6 +58,7 @@ public void TryResolveCommand_WhenCommandExists_ReturnsResolvedPath()
[InlineData("bun", "https://bun.sh/docs/installation")]
[InlineData("yarn", "https://yarnpkg.com/getting-started/install")]
[InlineData("pnpm", "https://pnpm.io/installation")]
[InlineData("deno", "https://docs.deno.com/runtime/getting_started/installation/")]
public void GetInstallationLink_WhenJavaScriptCommandKnown_ReturnsExpectedLink(string command, string expectedLink)
{
Assert.Equal(expectedLink, CommandPathResolver.GetInstallationLink(command));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public class MissingJavaScriptToolWarningTests(ITestOutputHelper outputHelper)
[InlineData("bun is not installed or not found in PATH. Please install Bun and try again.")]
[InlineData("yarn is not installed or not found in PATH. Please install Yarn and try again.")]
[InlineData("pnpm is not installed or not found in PATH. Please install pnpm and try again.")]
[InlineData("deno is not installed or not found in PATH. Please install Deno and try again.")]
public void IsMatch_WhenJavaScriptToolIsMissing_ReturnsTrue(string message)
{
var lines = new[]
Expand Down
Loading
Loading