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
20 changes: 20 additions & 0 deletions src/Aspire.Cli/Commands/AppHostLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,26 @@ internal static void AddLaunchOptions(Command command)
command.Options.Add(s_isolatedOption);
}

/// <summary>
/// Gets the arguments that should be passed through to the AppHost process.
/// </summary>
internal static string[] GetAppHostArguments(ParseResult parseResult)
{
return [.. parseResult.UnmatchedTokens];
}

/// <summary>
/// Adds AppHost arguments to a child CLI command after a command-line separator.
/// </summary>
internal static void AddAppHostArguments(List<string> commandArguments, IReadOnlyCollection<string> appHostArguments)
{
if (appHostArguments.Count > 0)
{
commandArguments.Add("--");
commandArguments.AddRange(appHostArguments);
}
}

/// <summary>
/// Launches an AppHost in detached mode, waits for the backchannel, and displays the result.
/// </summary>
Expand Down
20 changes: 17 additions & 3 deletions src/Aspire.Cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
var format = parseResult.GetValue(AppHostLauncher.s_formatOption);
var isolated = parseResult.GetValue(AppHostLauncher.s_isolatedOption);
var isExtensionHost = ExtensionHelper.IsExtensionHost(InteractionService, out _, out _);
var appHostArguments = AppHostLauncher.GetAppHostArguments(parseResult);
var startDebugSession = false;
if (isExtensionHost)
{
Expand Down Expand Up @@ -176,8 +177,18 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
&& ExtensionHelper.IsExtensionHost(InteractionService, out var extensionInteractionService, out _)
&& string.IsNullOrEmpty(_configuration[KnownConfigNames.ExtensionDebugSessionId]))
{
var debugSessionArgs = new List<string>();
AppHostLauncher.AddAppHostArguments(debugSessionArgs, appHostArguments);
extensionInteractionService.DisplayConsolePlainText(RunCommandStrings.StartingDebugSessionInExtension);
await extensionInteractionService.StartDebugSessionAsync(ExecutionContext.WorkingDirectory.FullName, passedAppHostProjectFile?.FullName, startDebugSession, new DebugSessionOptions { Command = "run" });
await extensionInteractionService.StartDebugSessionAsync(
ExecutionContext.WorkingDirectory.FullName,
passedAppHostProjectFile?.FullName,
startDebugSession,
new DebugSessionOptions
{
Command = "run",
Args = debugSessionArgs.Count > 0 ? [.. debugSessionArgs] : null
});
return ExitCodeConstants.Success;
}

Expand Down Expand Up @@ -249,7 +260,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
Isolated = isolated,
StartDebugSession = startDebugSession,
EnvironmentVariables = new Dictionary<string, string>(),
UnmatchedTokens = parseResult.UnmatchedTokens.ToArray(),
UnmatchedTokens = appHostArguments,
WorkingDirectory = ExecutionContext.WorkingDirectory,
BuildCompletionSource = buildCompletionSource,
BackchannelCompletionSource = backchannelCompletionSource,
Expand Down Expand Up @@ -660,13 +671,16 @@ private Task<int> ExecuteDetachedAsync(ParseResult parseResult, FileInfo? passed
var noBuild = parseResult.GetValue(s_noBuildOption);
var waitForDebugger = parseResult.GetValue(RootCommand.WaitForDebuggerOption);
var globalArgs = RootCommand.GetChildProcessArgs(parseResult);
var additionalArgs = parseResult.UnmatchedTokens.Where(t => t != "--detach").ToList();
var appHostArguments = AppHostLauncher.GetAppHostArguments(parseResult);
var additionalArgs = new List<string>();

if (noBuild)
{
additionalArgs.Add("--no-build");
}

AppHostLauncher.AddAppHostArguments(additionalArgs, appHostArguments);

return _appHostLauncher.LaunchDetachedAsync(
passedAppHostProjectFile,
format,
Expand Down
5 changes: 4 additions & 1 deletion src/Aspire.Cli/Commands/StartCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,16 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
var isExtensionHost = false;
var waitForDebugger = parseResult.GetValue(RootCommand.WaitForDebuggerOption);
var globalArgs = RootCommand.GetChildProcessArgs(parseResult);
var additionalArgs = parseResult.UnmatchedTokens.ToList();
var appHostArguments = AppHostLauncher.GetAppHostArguments(parseResult);
var additionalArgs = new List<string>();

if (noBuild)
{
additionalArgs.Add("--no-build");
}

AppHostLauncher.AddAppHostArguments(additionalArgs, appHostArguments);

return await _appHostLauncher.LaunchDetachedAsync(
passedAppHostProjectFile,
format,
Expand Down
5 changes: 3 additions & 2 deletions src/Aspire.Cli/Projects/GuestAppHostProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ await GenerateCodeViaRpcAsync(
// Start guest apphost - it will connect to AppHost server, define resources.
// If launcher is an ExtensionGuestLauncher, it delegates to the VS Code extension.
var (guestExitCode, guestOutput) = await ExecuteGuestAppHostAsync(
appHostFile, directory, environmentVariables, enableHotReload, rpcClient, launcher, cancellationToken);
appHostFile, directory, environmentVariables, enableHotReload, rpcClient, launcher, context.UnmatchedTokens, cancellationToken);

if (launcher is ExtensionGuestLauncher)
{
Expand Down Expand Up @@ -1447,6 +1447,7 @@ private async Task<int> InstallDependenciesAsync(
bool watchMode,
IAppHostRpcClient rpcClient,
IGuestProcessLauncher launcher,
string[] runArgs,
CancellationToken cancellationToken)
{
await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken);
Expand All @@ -1457,7 +1458,7 @@ private async Task<int> InstallDependenciesAsync(
return (ExitCodeConstants.FailedToDotnetRunAppHost, new OutputCollector());
}

return await _guestRuntime.RunAsync(appHostFile, directory, environmentVariables, watchMode, launcher, cancellationToken);
return await _guestRuntime.RunAsync(appHostFile, directory, environmentVariables, watchMode, launcher, runArgs, cancellationToken);
}

/// <summary>
Expand Down
35 changes: 34 additions & 1 deletion src/Aspire.Cli/Projects/GuestRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,35 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo
bool watchMode,
IGuestProcessLauncher launcher,
CancellationToken cancellationToken)
{
return await RunAsync(appHostFile, directory, environmentVariables, watchMode, launcher, runArgs: null, cancellationToken);
}

/// <summary>
/// Runs the AppHost guest process.
/// </summary>
/// <param name="appHostFile">The AppHost file to execute.</param>
/// <param name="directory">The project directory.</param>
/// <param name="environmentVariables">Environment variables to set for the process.</param>
/// <param name="watchMode">Whether to run in watch mode for hot reload.</param>
/// <param name="launcher">Strategy for launching the process.</param>
/// <param name="runArgs">Additional arguments for the AppHost process.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A tuple of the exit code and captured output (null when launched via extension).</returns>
public async Task<(int ExitCode, OutputCollector? Output)> RunAsync(
FileInfo appHostFile,
DirectoryInfo directory,
IDictionary<string, string> environmentVariables,
bool watchMode,
IGuestProcessLauncher launcher,
string[]? runArgs,
CancellationToken cancellationToken)
{
var commandSpec = watchMode && _spec.WatchExecute is not null
? _spec.WatchExecute
: _spec.Execute;

return await ExecuteCommandAsync(commandSpec, appHostFile, directory, environmentVariables, null, launcher, cancellationToken);
return await ExecuteCommandAsync(commandSpec, appHostFile, directory, environmentVariables, runArgs, launcher, cancellationToken);
}
Comment thread
sebastienros marked this conversation as resolved.

/// <summary>
Expand Down Expand Up @@ -232,6 +255,16 @@ private static string[] ReplacePlaceholders(

foreach (var arg in args)
{
if (arg == "{args}")
{
if (additionalArgs is { Length: > 0 })
{
result.AddRange(additionalArgs);
}

continue;
}

var replaced = arg
.Replace("{appHostFile}", appHostFile?.FullName ?? "")
.Replace("{appHostDir}", directory.FullName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Tests.Utils;
using Hex1b.Automation;
using Hex1b.Input;
using Xunit;

namespace Aspire.Cli.EndToEnd.Tests;
Expand Down Expand Up @@ -52,4 +53,70 @@ public async Task CreateAndRunTypeScriptEmptyAppHostProject()

await pendingRun;
}

[Fact]
[CaptureWorkspaceOnFailure]
public async Task RunTypeScriptEmptyAppHost_ForwardsArgumentsToAppHostProcess()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var workspace = TemporaryWorkspace.Create(output);

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);

await auto.AspireNewAsync("TsRunArgsApp", counter, template: AspireTemplate.TypeScriptEmptyAppHost);

var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "TsRunArgsApp");
File.WriteAllText(Path.Combine(projectRoot, "apphost.ts"), """
// Aspire TypeScript AppHost
// For more information, see: https://aspire.dev

import { writeFileSync } from 'node:fs';
import { createBuilder } from './.modules/aspire.js';

writeFileSync('run-args.txt', process.argv.slice(2).join('\n'));

const builder = await createBuilder();

await builder.build().run();
""");

await auto.TypeAsync("cd TsRunArgsApp");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);

await auto.TypeAsync("aspire run -- arg1=value1 --flag -- literal");
await auto.EnterAsync();
await auto.WaitUntilAsync(s =>
{
if (s.ContainsText("Select an AppHost to use:"))
{
throw new InvalidOperationException(
"Unexpected apphost selection prompt detected! " +
"This indicates multiple apphosts were incorrectly detected.");
}

return s.ContainsText("Press CTRL+C to stop the AppHost and exit.");
}, timeout: TimeSpan.FromMinutes(3), description: "Press CTRL+C message (aspire run started)");

await auto.Ctrl().KeyAsync(Hex1bKey.C);
await auto.WaitForSuccessPromptAsync(counter);

var runArgsPath = Path.Combine(projectRoot, "run-args.txt");
Assert.True(File.Exists(runArgsPath), $"Expected forwarded arguments file not found: {runArgsPath}");
Assert.Equal(["arg1=value1", "--flag", "--", "literal"], File.ReadAllLines(runArgsPath));

await auto.TypeAsync("exit");
await auto.EnterAsync();

await pendingRun;
}
}
109 changes: 105 additions & 4 deletions tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1501,7 +1501,7 @@ public async Task RunCommand_WithNoBuildAndWatchModeEnabled_ReturnsInvalidComman
}

[Fact]
public void RunCommand_ForwardsUnmatchedTokensToAppHost()
public void RunCommand_ForwardsArgumentsAfterDoubleDashToAppHost()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
Expand All @@ -1511,8 +1511,109 @@ public void RunCommand_ForwardsUnmatchedTokensToAppHost()
var result = command.Parse("run -- --custom-arg value");

Assert.Empty(result.Errors);
Assert.Contains("--custom-arg", result.UnmatchedTokens);
Assert.Contains("value", result.UnmatchedTokens);
Assert.Equal(["--custom-arg", "value"], AppHostLauncher.GetAppHostArguments(result));
}

[Fact]
public void RunCommand_PreservesLiteralDoubleDashAppHostArgument()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();

var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("run -- arg1 -- arg2");

Assert.Empty(result.Errors);
Assert.Equal(["arg1", "--", "arg2"], AppHostLauncher.GetAppHostArguments(result));
}

[Fact]
public async Task RunCommand_PassesArgumentsAfterDoubleDashToDotNetRunner()
{
var capturedArgs = new TaskCompletionSource<string[]>(TaskCreationOptions.RunContinuationsAsynchronously);

var backchannelFactory = (IServiceProvider sp) =>
{
var backchannel = new TestAppHostBackchannel();
backchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
return backchannel;
};

var runnerFactory = (IServiceProvider sp) =>
{
var runner = new TestDotNetCliRunner();
runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
{
capturedArgs.SetResult(args);
var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
backchannelCompletionSource!.SetResult(backchannel);
await Task.Delay(Timeout.InfiniteTimeSpan, ct);
return 0;
};
return runner;
};

using var workspace = TemporaryWorkspace.Create(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.ProjectLocatorFactory = _ => new TestProjectLocator();
options.AppHostBackchannelFactory = backchannelFactory;
options.DotNetCliRunnerFactory = runnerFactory;
});

using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("run -- arg1=value1 --custom-arg");

using var cts = new CancellationTokenSource();
var pendingRun = result.InvokeAsync(cancellationToken: cts.Token);

var args = await capturedArgs.Task.DefaultTimeout();
cts.Cancel();

var exitCode = await pendingRun.DefaultTimeout();

Assert.Equal(ExitCodeConstants.Success, exitCode);
Assert.Equal(["arg1=value1", "--custom-arg"], args);
}

[Fact]
public async Task RunCommand_WhenDelegatingToExtension_ForwardsArgumentsAfterDoubleDash()
{
DebugSessionOptions? capturedOptions = null;

var extensionInteractionServiceFactory = (IServiceProvider sp) =>
{
var service = new TestExtensionInteractionService(sp);
service.StartDebugSessionCallback = (_, _, _, options) =>
{
capturedOptions = options;
};
return service;
};

using var workspace = TemporaryWorkspace.Create(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = extensionInteractionServiceFactory;
options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel();
});

using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("run -- arg1=value1 --no-build");

var exitCode = await result.InvokeAsync().DefaultTimeout();

Assert.Equal(ExitCodeConstants.Success, exitCode);
Assert.NotNull(capturedOptions);
Assert.Equal("run", capturedOptions.Command);
var debugSessionArgs = capturedOptions.Args;
Assert.NotNull(debugSessionArgs);
Assert.Equal(["--", "arg1=value1", "--no-build"], debugSessionArgs);
}

[Fact]
Expand Down Expand Up @@ -1603,7 +1704,7 @@ public async Task RunCommand_NonInteractive_SkipsExtensionDelegation()
var extensionInteractionServiceFactory = (IServiceProvider sp) =>
{
var service = new TestExtensionInteractionService(sp);
service.StartDebugSessionCallback = (_, _, _) =>
service.StartDebugSessionCallback = (_, _, _, _) =>
{
startDebugSessionCalled = true;
};
Expand Down
Loading
Loading