Skip to content

Support dotnet pack file.cs #50168

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
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
9 changes: 6 additions & 3 deletions documentation/general/dotnet-run-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ Additionally, the implicit project file has the following customizations:

- `ArtifactsPath` is set to a [temp directory](#build-outputs).

- `RuntimeHostConfigurationOption`s are set for `EntryPointFilePath` and `EntryPointFileDirectoryPath` which can be accessed in the app via `AppContext`:
- `PublishDir` and `PackageOutputPath` are set to `./artifacts/` so the outputs of `dotnet publish` and `dotnet pack` are next to the file-based app.

- `RuntimeHostConfigurationOption`s are set for `EntryPointFilePath` and `EntryPointFileDirectoryPath` (except for `Publish` and `Pack` targets)
which can be accessed in the app via `AppContext`:

```cs
string? filePath = AppContext.GetData("EntryPointFilePath") as string;
Expand Down Expand Up @@ -101,7 +104,7 @@ the compilation consists solely of the single file read from the standard input.

Commands `dotnet restore file.cs` and `dotnet build file.cs` are needed for IDE support and hence work for file-based programs.

Command `dotnet publish file.cs` is also supported for file-based programs.
Commands `dotnet publish file.cs` and `dotnet pack file.cs` are also supported for file-based programs.
Note that file-based apps have implicitly set `PublishAot=true`, so publishing uses Native AOT (and building reports AOT warnings).
To opt out, use `#:property PublishAot=false` directive in your `.cs` file.

Expand Down Expand Up @@ -373,7 +376,7 @@ so `dotnet file.cs` instead of `dotnet run file.cs` should be used in shebangs:

### Other possible commands

We can consider supporting other commands like `dotnet pack`, `dotnet watch`,
We can consider supporting other commands like `dotnet watch`,
however the primary scenario is `dotnet run` and we might never support additional commands.

All commands supporting file-based programs should have a way to receive the target path similarly to `dotnet run`,
Expand Down
70 changes: 45 additions & 25 deletions src/Cli/dotnet/Commands/Pack/PackCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@
using System.Collections.ObjectModel;
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Configuration;
using Microsoft.DotNet.Cli.Commands.Build;
using Microsoft.DotNet.Cli.Commands.Restore;
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Extensions;
using Microsoft.DotNet.Cli.NuGetPackageDownloader;
using Microsoft.DotNet.Cli.Utils;
using NuGet.Commands;
using NuGet.Common;
using NuGet.Packaging;
using NuGet.Packaging.Core;

namespace Microsoft.DotNet.Cli.Commands.Pack;

Expand All @@ -23,33 +22,54 @@ public class PackCommand(
string? msbuildPath = null
) : RestoringCommand(msbuildArgs, noRestore, msbuildPath: msbuildPath)
{
public static PackCommand FromArgs(string[] args, string? msbuildPath = null)
public static CommandBase FromArgs(string[] args, string? msbuildPath = null)
{
var parseResult = Parser.Parse(["dotnet", "pack", ..args]);
return FromParseResult(parseResult, msbuildPath);
}

public static PackCommand FromParseResult(ParseResult parseResult, string? msbuildPath = null)
public static CommandBase FromParseResult(ParseResult parseResult, string? msbuildPath = null)
{
var msbuildArgs = parseResult.OptionValuesToBeForwarded(PackCommandParser.GetCommand()).Concat(parseResult.GetValue(PackCommandParser.SlnOrProjectArgument) ?? []);

ReleasePropertyProjectLocator projectLocator = new(parseResult, MSBuildPropertyNames.PACK_RELEASE,
new ReleasePropertyProjectLocator.DependentCommandOptions(
parseResult.GetValue(PackCommandParser.SlnOrProjectArgument),
parseResult.HasOption(PackCommandParser.ConfigurationOption) ? parseResult.GetValue(PackCommandParser.ConfigurationOption) : null
)
);

bool noRestore = parseResult.HasOption(PackCommandParser.NoRestoreOption) || parseResult.HasOption(PackCommandParser.NoBuildOption);
var parsedMSBuildArgs = MSBuildArgs.AnalyzeMSBuildArguments(
msbuildArgs,
CommonOptions.PropertiesOption,
CommonOptions.RestorePropertiesOption,
PackCommandParser.TargetOption,
PackCommandParser.VerbosityOption);
return new PackCommand(
parsedMSBuildArgs.CloneWithAdditionalProperties(projectLocator.GetCustomDefaultConfigurationValueIfSpecified()),
noRestore,
var args = parseResult.GetValue(PackCommandParser.SlnOrProjectOrFileArgument) ?? [];

LoggerUtility.SeparateBinLogArguments(args, out var binLogArgs, out var nonBinLogArgs);

bool noBuild = parseResult.HasOption(PackCommandParser.NoBuildOption);

bool noRestore = noBuild || parseResult.HasOption(PackCommandParser.NoRestoreOption);

return CommandFactory.CreateVirtualOrPhysicalCommand(
PackCommandParser.GetCommand(),
PackCommandParser.SlnOrProjectOrFileArgument,
(msbuildArgs, appFilePath) => new VirtualProjectBuildingCommand(
entryPointFileFullPath: Path.GetFullPath(appFilePath),
msbuildArgs: msbuildArgs)
{
NoBuild = noBuild,
NoRestore = noRestore,
NoCache = true,
},
(msbuildArgs, msbuildPath) =>
{
ReleasePropertyProjectLocator projectLocator = new(parseResult, MSBuildPropertyNames.PACK_RELEASE,
new ReleasePropertyProjectLocator.DependentCommandOptions(
nonBinLogArgs,
parseResult.HasOption(PackCommandParser.ConfigurationOption) ? parseResult.GetValue(PackCommandParser.ConfigurationOption) : null
)
);
return new PackCommand(
msbuildArgs.CloneWithAdditionalProperties(projectLocator.GetCustomDefaultConfigurationValueIfSpecified()),
noRestore,
msbuildPath);
},
optionsToUseWhenParsingMSBuildFlags:
[
CommonOptions.PropertiesOption,
CommonOptions.RestorePropertiesOption,
PackCommandParser.TargetOption,
PackCommandParser.VerbosityOption,
],
parseResult,
msbuildPath);
}

Expand All @@ -67,7 +87,7 @@ private static LogLevel MappingVerbosityToNugetLogLevel(VerbosityOptions? verbos

public static int RunPackCommand(ParseResult parseResult)
{
var args = parseResult.GetValue(PackCommandParser.SlnOrProjectArgument)?.ToList() ?? new List<string>();
var args = parseResult.GetValue(PackCommandParser.SlnOrProjectOrFileArgument)?.ToList() ?? new List<string>();

if (args.Count != 1)
{
Expand Down Expand Up @@ -112,7 +132,7 @@ public static int Run(ParseResult parseResult)
parseResult.HandleDebugSwitch();
parseResult.ShowHelpOrErrorIfAppropriate();

var args = parseResult.GetValue(PackCommandParser.SlnOrProjectArgument)?.ToList() ?? new List<string>();
var args = parseResult.GetValue(PackCommandParser.SlnOrProjectOrFileArgument)?.ToList() ?? new List<string>();

if (args.Count > 0 && Path.GetExtension(args[0]).Equals(".nuspec", StringComparison.OrdinalIgnoreCase))
{
Expand Down
11 changes: 3 additions & 8 deletions src/Cli/dotnet/Commands/Pack/PackCommandParser.cs
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.ObjectModel;
using System.CommandLine;
using Microsoft.DotNet.Cli.Commands.Build;
using Microsoft.DotNet.Cli.Commands.Restore;
using Microsoft.DotNet.Cli.Extensions;
using Microsoft.DotNet.Cli.NuGetPackageDownloader;
using NuGet.Commands;
using NuGet.Common;
using NuGet.Versioning;
using static Microsoft.DotNet.Cli.Commands.Run.CSharpDirective;

namespace Microsoft.DotNet.Cli.Commands.Pack;

internal static class PackCommandParser
{
public static readonly string DocsLink = "https://aka.ms/dotnet-pack";

public static readonly Argument<IEnumerable<string>> SlnOrProjectArgument = new(CliStrings.SolutionOrProjectArgumentName)
public static readonly Argument<string[]> SlnOrProjectOrFileArgument = new(CliStrings.SolutionOrProjectOrFileArgumentName)
{
Description = CliStrings.SolutionOrProjectArgumentDescription,
Description = CliStrings.SolutionOrProjectOrFileArgumentDescription,
Arity = ArgumentArity.ZeroOrMore
};

Expand Down Expand Up @@ -97,7 +92,7 @@ private static Command ConstructCommand()
{
var command = new DocumentedCommand("pack", DocsLink, CliCommandStrings.PackAppFullName);

command.Arguments.Add(SlnOrProjectArgument);
command.Arguments.Add(SlnOrProjectOrFileArgument);
command.Options.Add(OutputOption);
command.Options.Add(CommonOptions.ArtifactsPathOption);
command.Options.Add(NoBuildOption);
Expand Down
2 changes: 0 additions & 2 deletions src/Cli/dotnet/Commands/Publish/PublishCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@ public static CommandBase FromParseResult(ParseResult parseResult, string? msbui
CommonOptions.ValidateSelfContainedOptions(parseResult.HasOption(PublishCommandParser.SelfContainedOption),
parseResult.HasOption(PublishCommandParser.NoSelfContainedOption));

var forwardedOptions = parseResult.OptionValuesToBeForwarded(PublishCommandParser.GetCommand());

bool noBuild = parseResult.HasOption(PublishCommandParser.NoBuildOption);

bool noRestore = noBuild || parseResult.HasOption(PublishCommandParser.NoRestoreOption);
Expand Down
3 changes: 2 additions & 1 deletion src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -753,7 +753,7 @@ ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
isVirtualProject: true,
targetFilePath: EntryPointFileFullPath,
artifactsPath: ArtifactsPath,
includeRuntimeConfigInformation: !MSBuildArgs.RequestedTargets?.Contains("Publish") ?? true);
includeRuntimeConfigInformation: MSBuildArgs.RequestedTargets?.ContainsAny("Publish", "Pack") != true);
var projectFileText = projectFileWriter.ToString();

using var reader = new StringReader(projectFileText);
Expand Down Expand Up @@ -858,6 +858,7 @@ public static void WriteProjectFile(
<IncludeProjectNameInArtifactsPaths>false</IncludeProjectNameInArtifactsPaths>
<ArtifactsPath>{EscapeValue(artifactsPath)}</ArtifactsPath>
<PublishDir>artifacts/$(MSBuildProjectName)</PublishDir>
<PackageOutputPath>artifacts/$(MSBuildProjectName)</PackageOutputPath>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have any tests where the user .cs overrides this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added.

<FileBasedProgram>true</FileBasedProgram>
</PropertyGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public void MsbuildInvocationIsCorrect(string[] args, string[] expectedAdditiona
.ToArray();

var msbuildPath = "<msbuildpath>";
var command = PackCommand.FromArgs(args, msbuildPath);
var command = (PackCommand)PackCommand.FromArgs(args, msbuildPath);
var expectedPrefix = args.FirstOrDefault() == "--no-build" ? ExpectedNoBuildPrefix : [.. ExpectedPrefix, .. GivenDotnetBuildInvocation.RestoreExpectedPrefixForImplicitRestore];

command.SeparateRestoreCommand.Should().BeNull();
Expand Down
85 changes: 85 additions & 0 deletions test/dotnet.Tests/CommandTests/Run/RunFileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1566,6 +1566,88 @@ public void Publish_In_SubDir()
.And.NotHaveFilesMatching("*.deps.json", SearchOption.TopDirectoryOnly); // no deps.json file for AOT-published app
}

[Fact]
public void Pack()
{
var testInstance = _testAssetsManager.CreateTestDirectory();
var programFile = Path.Join(testInstance.Path, "MyFileBasedTool.cs");
File.WriteAllText(programFile, """
#:property PackAsTool=true
Console.WriteLine($"Hello; EntryPointFilePath set? {AppContext.GetData("EntryPointFilePath") is string}");
""");

// Run unpacked.
new DotnetCommand(Log, "run", "MyFileBasedTool.cs")
.WithWorkingDirectory(testInstance.Path)
.Execute()
.Should().Pass()
.And.HaveStdOut("Hello; EntryPointFilePath set? True");

var artifactsDir = VirtualProjectBuildingCommand.GetArtifactsPath(programFile);
if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true);

var outputDir = Path.Join(testInstance.Path, "artifacts");
if (Directory.Exists(outputDir)) Directory.Delete(outputDir, recursive: true);

// Pack.
new DotnetCommand(Log, "pack", "MyFileBasedTool.cs")
.WithWorkingDirectory(testInstance.Path)
.Execute()
.Should().Pass();

var packageDir = new DirectoryInfo(outputDir).Sub("MyFileBasedTool");
packageDir.File("MyFileBasedTool.1.0.0.nupkg").Should().Exist();
new DirectoryInfo(artifactsDir).Sub("package").Should().NotExist();

// Run the packed tool.
new DotnetCommand(Log, "tool", "exec", "MyFileBasedTool", "--yes", "--add-source", packageDir.FullName)
.WithWorkingDirectory(testInstance.Path)
.Execute()
.Should().Pass()
.And.HaveStdOutContaining("Hello; EntryPointFilePath set? False");
}

[Fact]
public void Pack_CustomPath()
{
var testInstance = _testAssetsManager.CreateTestDirectory();
var programFile = Path.Join(testInstance.Path, "MyFileBasedTool.cs");
File.WriteAllText(programFile, """
#:property PackAsTool=true
#:property PackageOutputPath=custom
Console.WriteLine($"Hello; EntryPointFilePath set? {AppContext.GetData("EntryPointFilePath") is string}");
""");

// Run unpacked.
new DotnetCommand(Log, "run", "MyFileBasedTool.cs")
.WithWorkingDirectory(testInstance.Path)
.Execute()
.Should().Pass()
.And.HaveStdOut("Hello; EntryPointFilePath set? True");

var artifactsDir = VirtualProjectBuildingCommand.GetArtifactsPath(programFile);
if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true);

var outputDir = Path.Join(testInstance.Path, "custom");
if (Directory.Exists(outputDir)) Directory.Delete(outputDir, recursive: true);

// Pack.
new DotnetCommand(Log, "pack", "MyFileBasedTool.cs")
.WithWorkingDirectory(testInstance.Path)
.Execute()
.Should().Pass();

new DirectoryInfo(outputDir).File("MyFileBasedTool.1.0.0.nupkg").Should().Exist();
new DirectoryInfo(artifactsDir).Sub("package").Should().NotExist();

// Run the packed tool.
new DotnetCommand(Log, "tool", "exec", "MyFileBasedTool", "--yes", "--add-source", outputDir)
.WithWorkingDirectory(testInstance.Path)
.Execute()
.Should().Pass()
.And.HaveStdOutContaining("Hello; EntryPointFilePath set? False");
}

[Fact]
public void Clean()
{
Expand Down Expand Up @@ -2756,6 +2838,7 @@ public void Api()
<IncludeProjectNameInArtifactsPaths>false</IncludeProjectNameInArtifactsPaths>
<ArtifactsPath>/artifacts</ArtifactsPath>
<PublishDir>artifacts/$(MSBuildProjectName)</PublishDir>
<PackageOutputPath>artifacts/$(MSBuildProjectName)</PackageOutputPath>
<FileBasedProgram>true</FileBasedProgram>
</PropertyGroup>

Expand Down Expand Up @@ -2824,6 +2907,7 @@ public void Api_Diagnostic_01()
<IncludeProjectNameInArtifactsPaths>false</IncludeProjectNameInArtifactsPaths>
<ArtifactsPath>/artifacts</ArtifactsPath>
<PublishDir>artifacts/$(MSBuildProjectName)</PublishDir>
<PackageOutputPath>artifacts/$(MSBuildProjectName)</PackageOutputPath>
<FileBasedProgram>true</FileBasedProgram>
</PropertyGroup>

Expand Down Expand Up @@ -2889,6 +2973,7 @@ public void Api_Diagnostic_02()
<IncludeProjectNameInArtifactsPaths>false</IncludeProjectNameInArtifactsPaths>
<ArtifactsPath>/artifacts</ArtifactsPath>
<PublishDir>artifacts/$(MSBuildProjectName)</PublishDir>
<PackageOutputPath>artifacts/$(MSBuildProjectName)</PackageOutputPath>
<FileBasedProgram>true</FileBasedProgram>
</PropertyGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ _testhost() {
'-r=[The target runtime to build for.]:RUNTIME_IDENTIFIER:->dotnet_dynamic_complete' \
'--help[Show command line help.]' \
'-h[Show command line help.]' \
'*::PROJECT | SOLUTION -- The project or solution file to operate on. If a file is not specified, the command will search the current directory for one.: ' \
'*::PROJECT | SOLUTION | FILE -- The project or solution or C# (file-based program) file to operate on. If a file is not specified, the command will search the current directory for a project or solution.: ' \
&& ret=0
case $state in
(dotnet_dynamic_complete)
Expand Down