Skip to content
Merged
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
64 changes: 59 additions & 5 deletions src/Aspire.Cli/Projects/GuestRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,21 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo
IGuestProcessLauncher launcher,
CancellationToken cancellationToken)
{
var commandSpec = watchMode && _spec.WatchExecute is not null
? _spec.WatchExecute
var useWatchCommand = watchMode && _spec.WatchExecute is not null;
var commandSpec = useWatchCommand
? _spec.WatchExecute!
: _spec.Execute;

await EnsureMigrationFilesExistAsync(directory, cancellationToken);
if (!useWatchCommand)
{
var preExecuteResult = await RunPreExecuteCommandsAsync(appHostFile, directory, environmentVariables, launcher, cancellationToken);
if (preExecuteResult.ExitCode != 0)
{
return preExecuteResult;
}
}

return await ExecuteCommandAsync(commandSpec, appHostFile, directory, environmentVariables, null, launcher, cancellationToken);
}

Expand All @@ -163,9 +174,45 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo
{
var commandSpec = _spec.PublishExecute ?? _spec.Execute;

await EnsureMigrationFilesExistAsync(directory, cancellationToken);
var preExecuteResult = await RunPreExecuteCommandsAsync(appHostFile, directory, environmentVariables, launcher, cancellationToken);
if (preExecuteResult.ExitCode != 0)
{
return preExecuteResult;
}

return await ExecuteCommandAsync(commandSpec, appHostFile, directory, environmentVariables, publishArgs, launcher, cancellationToken);
}

private async Task<(int ExitCode, OutputCollector? Output)> RunPreExecuteCommandsAsync(
FileInfo appHostFile,
DirectoryInfo directory,
IDictionary<string, string> environmentVariables,
IGuestProcessLauncher launcher,
CancellationToken cancellationToken)
{
if (_spec.PreExecute is null or { Length: 0 })
{
return (0, new OutputCollector());
}

var preExecuteLauncher = launcher is ExtensionGuestLauncher ? CreateDefaultLauncher() : launcher;
foreach (var commandSpec in _spec.PreExecute)
{
var args = ReplacePlaceholders(commandSpec.Args, appHostFile, directory, null);
var mergedEnvironment = MergeEnvironmentVariables(environmentVariables, commandSpec);

_logger.LogDebug("Launching pre-execution command: {Command} {Args}", commandSpec.Command, string.Join(" ", args));
var (exitCode, output) = await preExecuteLauncher.LaunchAsync(commandSpec.Command, args, directory, mergedEnvironment, cancellationToken);
if (exitCode != 0)
{
return (exitCode, output ?? new OutputCollector());
}
}

return (0, new OutputCollector());
}

private async Task<(int ExitCode, OutputCollector? Output)> ExecuteCommandAsync(
CommandSpec commandSpec,
FileInfo appHostFile,
Expand All @@ -177,8 +224,16 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo
{
var args = ReplacePlaceholders(commandSpec.Args, appHostFile, directory, additionalArgs);

await EnsureMigrationFilesExistAsync(directory, cancellationToken);
var mergedEnvironment = MergeEnvironmentVariables(environmentVariables, commandSpec);

_logger.LogDebug("Launching: {Command} {Args}", commandSpec.Command, string.Join(" ", args));
return await launcher.LaunchAsync(commandSpec.Command, args, directory, mergedEnvironment, cancellationToken);
}

private static Dictionary<string, string> MergeEnvironmentVariables(
IDictionary<string, string> environmentVariables,
CommandSpec commandSpec)
{
var mergedEnvironment = new Dictionary<string, string>(environmentVariables);
if (commandSpec.EnvironmentVariables is not null)
{
Expand All @@ -188,8 +243,7 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo
}
}

_logger.LogDebug("Launching: {Command} {Args}", commandSpec.Command, string.Join(" ", args));
return await launcher.LaunchAsync(commandSpec.Command, args, directory, mergedEnvironment, cancellationToken);
return mergedEnvironment;
}

/// <summary>
Expand Down
53 changes: 45 additions & 8 deletions src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ public static RuntimeSpec ApplyToRuntimeSpec(RuntimeSpec baseRuntimeSpec, TypeSc
DetectionPatterns = baseRuntimeSpec.DetectionPatterns,
Initialize = baseRuntimeSpec.Initialize,
InstallDependencies = CreateInstallCommand(toolchain),
PreExecute = CreatePreExecuteCommands(toolchain, tsConfigFileName),
Execute = CreateExecuteCommand(toolchain, tsConfigFileName),
WatchExecute = CreateWatchCommand(toolchain, tsConfigFileName),
PublishExecute = baseRuntimeSpec.PublishExecute,
Expand All @@ -161,6 +162,32 @@ private static CommandSpec CreateInstallCommand(TypeScriptAppHostToolchain toolc
};
}

private static CommandSpec[] CreatePreExecuteCommands(TypeScriptAppHostToolchain toolchain, string tsConfigFileName)
{
return
[
toolchain switch
{
TypeScriptAppHostToolchain.Bun => new CommandSpec
{
Command = "bun",
Args = ["run", "tsc", "--noEmit", "-p", tsConfigFileName]
},
TypeScriptAppHostToolchain.Yarn => new CommandSpec
{
Command = "yarn",
Args = ["run", "tsc", "--noEmit", "-p", tsConfigFileName]
},
TypeScriptAppHostToolchain.Pnpm => new CommandSpec
{
Command = "pnpm",
Args = ["exec", "tsc", "--noEmit", "-p", tsConfigFileName]
},
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
}
];
}

private static CommandSpec CreateExecuteCommand(TypeScriptAppHostToolchain toolchain, string tsConfigFileName)
{
return toolchain switch
Expand All @@ -170,11 +197,11 @@ private static CommandSpec CreateExecuteCommand(TypeScriptAppHostToolchain toolc
Command = "bun",
Args = ["run", "{appHostFile}"]
},
TypeScriptAppHostToolchain.Yarn => new CommandSpec
{
Command = "yarn",
Args = ["exec", "tsx", "--tsconfig", tsConfigFileName, "{appHostFile}"]
},
TypeScriptAppHostToolchain.Yarn => new CommandSpec
{
Command = "yarn",
Args = ["run", "tsx", "--tsconfig", tsConfigFileName, "{appHostFile}"]
},
TypeScriptAppHostToolchain.Pnpm => new CommandSpec
{
Command = "pnpm",
Expand All @@ -191,7 +218,17 @@ private static CommandSpec CreateWatchCommand(TypeScriptAppHostToolchain toolcha
TypeScriptAppHostToolchain.Bun => new CommandSpec
{
Command = "bun",
Args = ["--watch", "run", "{appHostFile}"]
Args =
[
"run",
"nodemon",
"--signal", "SIGTERM",
"--watch", ".",
"--ext", "ts",
"--ignore", "node_modules/",
"--ignore", ".modules/",
"--exec", $"bun run tsc --noEmit -p {tsConfigFileName} && bun run \"{{appHostFile}}\""
]
},
TypeScriptAppHostToolchain.Yarn => new CommandSpec
{
Expand All @@ -205,7 +242,7 @@ private static CommandSpec CreateWatchCommand(TypeScriptAppHostToolchain toolcha
"--ext", "ts",
"--ignore", "node_modules/",
"--ignore", ".modules/",
"--exec", $"yarn exec tsx --tsconfig {tsConfigFileName} {{appHostFile}}"
"--exec", $"yarn run tsc --noEmit -p {tsConfigFileName} && yarn run tsx --tsconfig {tsConfigFileName} \"{{appHostFile}}\""
]
},
TypeScriptAppHostToolchain.Pnpm => new CommandSpec
Expand All @@ -220,7 +257,7 @@ private static CommandSpec CreateWatchCommand(TypeScriptAppHostToolchain toolcha
"--ext", "ts",
"--ignore", "node_modules/",
"--ignore", ".modules/",
"--exec", $"pnpm exec tsx --tsconfig {tsConfigFileName} {{appHostFile}}"
"--exec", $"pnpm exec tsc --noEmit -p {tsConfigFileName} && pnpm exec tsx --tsconfig {tsConfigFileName} \"{{appHostFile}}\""
]
},
_ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,14 @@ public RuntimeSpec GetRuntimeSpec()
Command = "npm",
Args = ["install"]
},
PreExecute =
[
new CommandSpec
{
Command = "npx",
Args = ["--no-install", "tsc", "--noEmit", "-p", AppHostTsConfigFileName]
}
],
Execute = new CommandSpec
{
Command = "npx",
Expand All @@ -282,7 +290,7 @@ public RuntimeSpec GetRuntimeSpec()
"--ext", "ts",
"--ignore", "node_modules/",
"--ignore", ".modules/",
"--exec", $"npx --no-install tsx --tsconfig {AppHostTsConfigFileName} {{appHostFile}}"
"--exec", $"npx --no-install tsc --noEmit -p {AppHostTsConfigFileName} && npx --no-install tsx --tsconfig {AppHostTsConfigFileName} \"{{appHostFile}}\""
]
},
MigrationFiles = new Dictionary<string, string>
Expand Down
6 changes: 6 additions & 0 deletions src/Aspire.TypeSystem/RuntimeSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ public sealed class RuntimeSpec
/// </summary>
public CommandSpec? InstallDependencies { get; init; }

/// <summary>
/// Gets the commands to run before executing or publishing the AppHost. Null if no pre-execution validation is needed.
/// Watch-mode validation should be part of <see cref="WatchExecute" /> when needed.
/// </summary>
public CommandSpec[]? PreExecute { get; init; }

/// <summary>
/// Gets the command to execute the AppHost for run.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,11 @@ public async Task DeployTypeScriptAppToKubernetes()

// Add Kubernetes environment with Helm deployment
const k8sEnv = await builder.addKubernetesEnvironment("env");
await k8sEnv.withHelm(async (helm) => {
await helm.withNamespace(k8sNamespace);
await helm.withChartVersion(chartVersion);
await k8sEnv.withHelm({
configure: async (helm) => {
await helm.withNamespace(k8sNamespace);
await helm.withChartVersion(chartVersion);
},
});

await builder.build().run();
Expand Down
2 changes: 1 addition & 1 deletion tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ public async Task PublishWithConfigureEnvFileUpdatesEnvOutput()
const builder = await createBuilder();

const compose = await builder.addDockerComposeEnvironment("compose");
await compose.withDashboard(false);
await compose.withDashboard({ enabled: false });

const container = await builder.addContainer("my-container", "nginx:alpine");
await container.withBindMount("/host/path/data", "/container/data");
Expand Down
100 changes: 97 additions & 3 deletions tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ private static RuntimeSpec CreateTestSpec(
CommandSpec? execute = null,
CommandSpec? watchExecute = null,
CommandSpec? publishExecute = null,
CommandSpec? installDependencies = null)
CommandSpec? installDependencies = null,
CommandSpec[]? preExecute = null)
{
return new RuntimeSpec
{
Expand All @@ -43,7 +44,8 @@ private static RuntimeSpec CreateTestSpec(
},
WatchExecute = watchExecute,
PublishExecute = publishExecute,
InstallDependencies = installDependencies
InstallDependencies = installDependencies,
PreExecute = preExecute
};
}

Expand Down Expand Up @@ -111,6 +113,72 @@ public async Task RunAsync_WatchMode_UsesWatchExecuteSpec()
Assert.Contains("--watch", launcher.LastArgs);
}

[Fact]
public async Task RunAsync_WatchModeWithWatchExecute_SkipsPreExecute()
{
var spec = CreateTestSpec(
execute: new CommandSpec { Command = "run-cmd", Args = ["{appHostFile}"] },
watchExecute: new CommandSpec { Command = "watch-cmd", Args = ["--watch", "{appHostFile}"] },
preExecute:
[
new CommandSpec { Command = "typecheck-cmd", Args = ["--noEmit"] }
]);
var runtime = CreateRuntime(spec);
var launcher = new RecordingLauncher();
var appHostFile = new FileInfo("/tmp/apphost.ts");
var directory = new DirectoryInfo("/tmp");

var (exitCode, _) = await runtime.RunAsync(appHostFile, directory, new Dictionary<string, string>(), watchMode: true, launcher, CancellationToken.None);

Assert.Equal(0, exitCode);
var call = Assert.Single(launcher.Calls);
Assert.Equal("watch-cmd", call.Command);
}

[Fact]
public async Task RunAsync_RunsPreExecuteBeforeExecute()
{
var spec = CreateTestSpec(
execute: new CommandSpec { Command = "run-cmd", Args = ["{appHostFile}"] },
preExecute:
[
new CommandSpec { Command = "typecheck-cmd", Args = ["--project", "{appHostDir}"] }
]);
var runtime = CreateRuntime(spec);
var launcher = new RecordingLauncher();
var appHostFile = new FileInfo("/tmp/apphost.ts");
var directory = new DirectoryInfo("/tmp");

await runtime.RunAsync(appHostFile, directory, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);

Assert.Equal(2, launcher.Calls.Count);
Assert.Equal("typecheck-cmd", launcher.Calls[0].Command);
Assert.Equal(["--project", directory.FullName], launcher.Calls[0].Args);
Assert.Equal("run-cmd", launcher.Calls[1].Command);
}

[Fact]
public async Task RunAsync_WhenPreExecuteFails_DoesNotExecute()
{
var spec = CreateTestSpec(
execute: new CommandSpec { Command = "run-cmd", Args = ["{appHostFile}"] },
preExecute:
[
new CommandSpec { Command = "typecheck-cmd", Args = ["--noEmit"] }
]);
var runtime = CreateRuntime(spec);
var launcher = new RecordingLauncher();
launcher.ExitCodes.Enqueue(2);
var appHostFile = new FileInfo("/tmp/apphost.ts");
var directory = new DirectoryInfo("/tmp");

var (exitCode, _) = await runtime.RunAsync(appHostFile, directory, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);

Assert.Equal(2, exitCode);
var call = Assert.Single(launcher.Calls);
Assert.Equal("typecheck-cmd", call.Command);
}

[Fact]
public async Task RunAsync_WatchModeWithoutWatchSpec_FallsBackToExecute()
{
Expand Down Expand Up @@ -143,6 +211,28 @@ public async Task PublishAsync_UsesPublishExecuteSpec()
Assert.Contains(launcher.LastArgs, a => a.Contains("--output") && a.Contains("/out"));
}

[Fact]
public async Task PublishAsync_RunsPreExecuteBeforePublishExecute()
{
var spec = CreateTestSpec(
execute: new CommandSpec { Command = "run-cmd", Args = ["{appHostFile}"] },
publishExecute: new CommandSpec { Command = "publish-cmd", Args = ["{appHostFile}", "{args}"] },
preExecute:
[
new CommandSpec { Command = "typecheck-cmd", Args = ["--project", "{appHostDir}"] }
]);
var runtime = CreateRuntime(spec);
var launcher = new RecordingLauncher();
var appHostFile = new FileInfo("/tmp/apphost.ts");
var directory = new DirectoryInfo("/tmp");

await runtime.PublishAsync(appHostFile, directory, new Dictionary<string, string>(), ["--output", "/out"], launcher, CancellationToken.None);

Assert.Equal(2, launcher.Calls.Count);
Assert.Equal("typecheck-cmd", launcher.Calls[0].Command);
Assert.Equal("publish-cmd", launcher.Calls[1].Command);
}

[Fact]
public async Task PublishAsync_WithoutPublishSpec_FallsBackToExecute()
{
Expand Down Expand Up @@ -527,6 +617,8 @@ public async Task RunAsync_NoMigrationFiles_ExecutesNormally()

private sealed class RecordingLauncher : IGuestProcessLauncher
{
public List<(string Command, string[] Args)> Calls { get; } = [];
public Queue<int> ExitCodes { get; } = [];
public string LastCommand { get; private set; } = string.Empty;
public string[] LastArgs { get; private set; } = [];
public DirectoryInfo? LastWorkingDirectory { get; private set; }
Expand All @@ -539,11 +631,13 @@ private sealed class RecordingLauncher : IGuestProcessLauncher
IDictionary<string, string> environmentVariables,
CancellationToken cancellationToken)
{
Calls.Add((command, args));
LastCommand = command;
LastArgs = args;
LastWorkingDirectory = workingDirectory;
LastEnvironmentVariables = new Dictionary<string, string>(environmentVariables);
return Task.FromResult<(int, OutputCollector?)>((0, new OutputCollector()));
var exitCode = ExitCodes.Count > 0 ? ExitCodes.Dequeue() : 0;
return Task.FromResult<(int, OutputCollector?)>((exitCode, new OutputCollector()));
}
}
}
Loading
Loading