diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Command.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/Command.cs index ac371090b91a..061a0b155d75 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/Command.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Command.cs @@ -7,10 +7,14 @@ namespace Microsoft.DotNet.Cli.Utils; -public class Command(Process? process, bool trimTrailingNewlines = false) : ICommand +public class Command(Process? process, bool trimTrailingNewlines = false, IDictionary? customEnvironmentVariables = null) : ICommand { private readonly Process _process = process ?? throw new ArgumentNullException(nameof(process)); + private readonly Dictionary? _customEnvironmentVariables = + // copy the dictionary to avoid mutating the original + customEnvironmentVariables == null ? null : new(customEnvironmentVariables); + private StreamForwarder? _stdOut; private StreamForwarder? _stdErr; @@ -98,6 +102,7 @@ public ICommand WorkingDirectory(string? projectDirectory) public ICommand EnvironmentVariable(string name, string? value) { _process.StartInfo.Environment[name] = value; + _customEnvironmentVariables?[name] = value; return this; } @@ -179,6 +184,14 @@ public ICommand OnErrorLine(Action handler) public string CommandArgs => _process.StartInfo.Arguments; + public ProcessStartInfo StartInfo => _process.StartInfo; + + /// + /// If set in the constructor, it's used to keep track of environment variables modified via + /// unlike which includes all environment variables of the current process. + /// + public IReadOnlyDictionary? CustomEnvironmentVariables => _customEnvironmentVariables; + public ICommand SetCommandArgs(string commandArgs) { _process.StartInfo.Arguments = commandArgs; diff --git a/src/Cli/dotnet/CommandFactory/CommandFactoryUsingResolver.cs b/src/Cli/dotnet/CommandFactory/CommandFactoryUsingResolver.cs index 80941561f91f..393c3bc5eb3c 100644 --- a/src/Cli/dotnet/CommandFactory/CommandFactoryUsingResolver.cs +++ b/src/Cli/dotnet/CommandFactory/CommandFactoryUsingResolver.cs @@ -113,6 +113,6 @@ public static Command Create(CommandSpec commandSpec) StartInfo = psi }; - return new Command(_process); + return new Command(_process, customEnvironmentVariables: commandSpec.EnvironmentVariables); } } diff --git a/src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs b/src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs index 2ff5376e5563..8a49150d36a7 100644 --- a/src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs +++ b/src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using System.Collections.ObjectModel; using System.CommandLine; using System.Text.Json; using System.Text.Json.Serialization; @@ -46,6 +47,7 @@ static void Respond(RunApiOutput message) } [JsonDerivedType(typeof(GetProject), nameof(GetProject))] +[JsonDerivedType(typeof(GetRunCommand), nameof(GetRunCommand))] internal abstract class RunApiInput { private RunApiInput() { } @@ -74,10 +76,57 @@ public override RunApiOutput Execute() }; } } + + public sealed class GetRunCommand : RunApiInput + { + public string? ArtifactsPath { get; init; } + public required string EntryPointFileFullPath { get; init; } + + public override RunApiOutput Execute() + { + var buildCommand = new VirtualProjectBuildingCommand( + entryPointFileFullPath: EntryPointFileFullPath, + msbuildArgs: [], + verbosity: VerbosityOptions.quiet, + interactive: false) + { + CustomArtifactsPath = ArtifactsPath, + }; + + buildCommand.PrepareProjectInstance(); + + var runCommand = new RunCommand( + noBuild: false, + projectFileOrDirectory: null, + launchProfile: null, + noLaunchProfile: false, + noLaunchProfileArguments: false, + noRestore: false, + noCache: false, + interactive: false, + verbosity: VerbosityOptions.quiet, + restoreArgs: [], + args: [EntryPointFileFullPath], + environmentVariables: ReadOnlyDictionary.Empty); + + runCommand.TryGetLaunchProfileSettingsIfNeeded(out var launchSettings); + var targetCommand = (Utils.Command)runCommand.GetTargetCommand(buildCommand.CreateProjectInstance); + runCommand.ApplyLaunchSettingsProfileToCommand(targetCommand, launchSettings); + + return new RunApiOutput.RunCommand + { + ExecutablePath = targetCommand.CommandName, + CommandLineArguments = targetCommand.CommandArgs, + WorkingDirectory = targetCommand.StartInfo.WorkingDirectory, + EnvironmentVariables = targetCommand.CustomEnvironmentVariables ?? ReadOnlyDictionary.Empty, + }; + } + } } [JsonDerivedType(typeof(Error), nameof(Error))] [JsonDerivedType(typeof(Project), nameof(Project))] +[JsonDerivedType(typeof(RunCommand), nameof(RunCommand))] internal abstract class RunApiOutput { private RunApiOutput() { } @@ -100,6 +149,14 @@ public sealed class Project : RunApiOutput public required string Content { get; init; } public required ImmutableArray Diagnostics { get; init; } } + + public sealed class RunCommand : RunApiOutput + { + public required string ExecutablePath { get; init; } + public required string CommandLineArguments { get; init; } + public required string? WorkingDirectory { get; init; } + public required IReadOnlyDictionary EnvironmentVariables { get; init; } + } } [JsonSerializable(typeof(RunApiInput))] diff --git a/src/Cli/dotnet/Commands/Run/RunCommand.cs b/src/Cli/dotnet/Commands/Run/RunCommand.cs index 1a9280bb0687..36aadf1ad262 100644 --- a/src/Cli/dotnet/Commands/Run/RunCommand.cs +++ b/src/Cli/dotnet/Commands/Run/RunCommand.cs @@ -53,7 +53,7 @@ public class RunCommand private bool ShouldBuild => !NoBuild; - public string LaunchProfile { get; } + public string? LaunchProfile { get; } public bool NoLaunchProfile { get; } /// @@ -64,7 +64,7 @@ public class RunCommand public RunCommand( bool noBuild, string? projectFileOrDirectory, - string launchProfile, + string? launchProfile, bool noLaunchProfile, bool noLaunchProfileArguments, bool noRestore, @@ -145,7 +145,7 @@ public int Execute() } } - private void ApplyLaunchSettingsProfileToCommand(ICommand targetCommand, ProjectLaunchSettingsModel? launchSettings) + internal void ApplyLaunchSettingsProfileToCommand(ICommand targetCommand, ProjectLaunchSettingsModel? launchSettings) { if (launchSettings == null) { @@ -172,7 +172,7 @@ private void ApplyLaunchSettingsProfileToCommand(ICommand targetCommand, Project } } - private bool TryGetLaunchProfileSettingsIfNeeded(out ProjectLaunchSettingsModel? launchSettingsModel) + internal bool TryGetLaunchProfileSettingsIfNeeded(out ProjectLaunchSettingsModel? launchSettingsModel) { launchSettingsModel = default; if (NoLaunchProfile) @@ -310,7 +310,7 @@ internal static VerbosityOptions GetDefaultVerbosity(bool interactive) return interactive ? VerbosityOptions.minimal : VerbosityOptions.quiet; } - private ICommand GetTargetCommand(Func? projectFactory) + internal ICommand GetTargetCommand(Func? projectFactory) { FacadeLogger? logger = LoggerUtility.DetermineBinlogger(RestoreArgs, "dotnet-run"); var project = EvaluateProject(ProjectFileFullPath, projectFactory, RestoreArgs, logger); diff --git a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs index c9788a46a5ec..282cb67b785c 100644 --- a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs @@ -79,6 +79,7 @@ public VirtualProjectBuildingCommand( public Dictionary GlobalProperties { get; } public string[] BinaryLoggerArgs { get; } public VerbosityOptions Verbosity { get; } + public string? CustomArtifactsPath { get; init; } public bool NoRestore { get; init; } public bool NoCache { get; init; } public bool NoBuild { get; init; } @@ -431,7 +432,7 @@ ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection) } } - private string GetArtifactsPath() => GetArtifactsPath(EntryPointFileFullPath); + private string GetArtifactsPath() => CustomArtifactsPath ?? GetArtifactsPath(EntryPointFileFullPath); // internal for testing internal static string GetArtifactsPath(string entryPointFileFullPath) diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs index 6c9ddb9a340e..7ec28f79a2e2 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs @@ -1393,4 +1393,27 @@ public void Api_Error() .And.HaveStdOutContaining("Unknown1") .And.HaveStdOutContaining("Unknown2"); } + + [Fact] + public void Api_RunCommand() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + var programPath = Path.Join(testInstance.Path, "Program.cs"); + File.WriteAllText(programPath, """ + Console.WriteLine(); + """); + + string artifactsPath = OperatingSystem.IsWindows() ? @"C:\artifacts" : "/artifacts"; + string executablePath = OperatingSystem.IsWindows() ? @"C:\artifacts\bin\debug\Program.exe" : "/artifacts/bin/debug/Program"; + new DotnetCommand(Log, "run-api") + .WithStandardInput($$""" + {"$type":"GetRunCommand","EntryPointFileFullPath":{{ToJson(programPath)}},"ArtifactsPath":{{ToJson(artifactsPath)}}} + """) + .Execute() + .Should().Pass() + // DOTNET_ROOT environment variable is platform dependent so we don't verify it fully for simplicity + .And.HaveStdOutContaining($$""" + {"$type":"RunCommand","Version":1,"ExecutablePath":{{ToJson(executablePath)}},"CommandLineArguments":"","WorkingDirectory":"","EnvironmentVariables":{"DOTNET_ROOT + """); + } }