diff --git a/src/Aspire.Cli/Commands/DestroyCommand.cs b/src/Aspire.Cli/Commands/DestroyCommand.cs
index 8169a4315a1..376aa1a59c1 100644
--- a/src/Aspire.Cli/Commands/DestroyCommand.cs
+++ b/src/Aspire.Cli/Commands/DestroyCommand.cs
@@ -29,6 +29,16 @@ public DestroyCommand(IDotNetCliRunner runner, IInteractionService interactionSe
Description = DestroyCommandStrings.YesOptionDescription
};
Options.Add(_yesOption);
+
+ Validators.Add(result =>
+ {
+ var nonInteractive = result.GetValue(RootCommand.NonInteractiveOption);
+ var yes = result.GetValue(_yesOption);
+ if (nonInteractive && !yes)
+ {
+ result.AddError(DestroyCommandStrings.NonInteractiveRequiresYes);
+ }
+ });
}
protected override string OperationCompletedPrefix => DestroyCommandStrings.OperationCompletedPrefix;
diff --git a/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs
index 2d8ae6319d4..5c2af895f41 100644
--- a/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs
+++ b/src/Aspire.Cli/Resources/DestroyCommandStrings.Designer.cs
@@ -77,6 +77,15 @@ public static string Description {
return ResourceManager.GetString("Description", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to The destroy command requires --yes when the --non-interactive option is specified..
+ ///
+ public static string NonInteractiveRequiresYes {
+ get {
+ return ResourceManager.GetString("NonInteractiveRequiresYes", resourceCulture);
+ }
+ }
///
/// Looks up a localized string similar to The output path containing the deployment artifacts to destroy.
diff --git a/src/Aspire.Cli/Resources/DestroyCommandStrings.resx b/src/Aspire.Cli/Resources/DestroyCommandStrings.resx
index c98f9d4bbbe..bee9ab48370 100644
--- a/src/Aspire.Cli/Resources/DestroyCommandStrings.resx
+++ b/src/Aspire.Cli/Resources/DestroyCommandStrings.resx
@@ -120,6 +120,9 @@
Destroy a previously deployed AppHost environment (Preview)
+
+ The destroy command requires --yes when the --non-interactive option is specified.
+
The output path containing the deployment artifacts to destroy
diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf
index 9b1b2aaf2c1..df10f4b3f19 100644
--- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf
+++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.cs.xlf
@@ -12,6 +12,11 @@
The destroy operation was canceled.
+
+ The destroy command requires --yes when the --non-interactive option is specified.
+ The destroy command requires --yes when the --non-interactive option is specified.
+
+
Destroy completed
Destroy completed
diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf
index 90aec8b6558..ccb65be3651 100644
--- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf
+++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.de.xlf
@@ -12,6 +12,11 @@
The destroy operation was canceled.
+
+ The destroy command requires --yes when the --non-interactive option is specified.
+ The destroy command requires --yes when the --non-interactive option is specified.
+
+
Destroy completed
Destroy completed
diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf
index cbfac6dbc82..14c8a539caf 100644
--- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf
+++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.es.xlf
@@ -12,6 +12,11 @@
The destroy operation was canceled.
+
+ The destroy command requires --yes when the --non-interactive option is specified.
+ The destroy command requires --yes when the --non-interactive option is specified.
+
+
Destroy completed
Destroy completed
diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf
index beb5d05a030..4bb3522c84a 100644
--- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf
+++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.fr.xlf
@@ -12,6 +12,11 @@
The destroy operation was canceled.
+
+ The destroy command requires --yes when the --non-interactive option is specified.
+ The destroy command requires --yes when the --non-interactive option is specified.
+
+
Destroy completed
Destroy completed
diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf
index c3401bc54d4..698a37bae1f 100644
--- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf
+++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.it.xlf
@@ -12,6 +12,11 @@
The destroy operation was canceled.
+
+ The destroy command requires --yes when the --non-interactive option is specified.
+ The destroy command requires --yes when the --non-interactive option is specified.
+
+
Destroy completed
Destroy completed
diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf
index 69f47e07da9..1d8a89de91d 100644
--- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf
+++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ja.xlf
@@ -12,6 +12,11 @@
The destroy operation was canceled.
+
+ The destroy command requires --yes when the --non-interactive option is specified.
+ The destroy command requires --yes when the --non-interactive option is specified.
+
+
Destroy completed
Destroy completed
diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf
index 9693e4695e9..7c64596959e 100644
--- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf
+++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ko.xlf
@@ -12,6 +12,11 @@
The destroy operation was canceled.
+
+ The destroy command requires --yes when the --non-interactive option is specified.
+ The destroy command requires --yes when the --non-interactive option is specified.
+
+
Destroy completed
Destroy completed
diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf
index 8b95acaff7e..5d9ab5de0de 100644
--- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf
+++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pl.xlf
@@ -12,6 +12,11 @@
The destroy operation was canceled.
+
+ The destroy command requires --yes when the --non-interactive option is specified.
+ The destroy command requires --yes when the --non-interactive option is specified.
+
+
Destroy completed
Destroy completed
diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf
index 2fd39a6800d..8945e26a909 100644
--- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf
+++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.pt-BR.xlf
@@ -12,6 +12,11 @@
The destroy operation was canceled.
+
+ The destroy command requires --yes when the --non-interactive option is specified.
+ The destroy command requires --yes when the --non-interactive option is specified.
+
+
Destroy completed
Destroy completed
diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf
index 3fe68554b97..d19e2575d55 100644
--- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf
+++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.ru.xlf
@@ -12,6 +12,11 @@
The destroy operation was canceled.
+
+ The destroy command requires --yes when the --non-interactive option is specified.
+ The destroy command requires --yes when the --non-interactive option is specified.
+
+
Destroy completed
Destroy completed
diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf
index 68a10828607..0bd08dffe4f 100644
--- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf
+++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.tr.xlf
@@ -12,6 +12,11 @@
The destroy operation was canceled.
+
+ The destroy command requires --yes when the --non-interactive option is specified.
+ The destroy command requires --yes when the --non-interactive option is specified.
+
+
Destroy completed
Destroy completed
diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf
index dbc09d2bc57..372bdb4b285 100644
--- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf
+++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hans.xlf
@@ -12,6 +12,11 @@
The destroy operation was canceled.
+
+ The destroy command requires --yes when the --non-interactive option is specified.
+ The destroy command requires --yes when the --non-interactive option is specified.
+
+
Destroy completed
Destroy completed
diff --git a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf
index c04c4da55a9..6757c480f1d 100644
--- a/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf
+++ b/src/Aspire.Cli/Resources/xlf/DestroyCommandStrings.zh-Hant.xlf
@@ -12,6 +12,11 @@
The destroy operation was canceled.
+
+ The destroy command requires --yes when the --non-interactive option is specified.
+ The destroy command requires --yes when the --non-interactive option is specified.
+
+
Destroy completed
Destroy completed
diff --git a/tests/Aspire.Cli.Tests/Commands/DestroyCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DestroyCommandTests.cs
index a435e5b6378..327e9697678 100644
--- a/tests/Aspire.Cli.Tests/Commands/DestroyCommandTests.cs
+++ b/tests/Aspire.Cli.Tests/Commands/DestroyCommandTests.cs
@@ -1,8 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Runtime.CompilerServices;
+using Aspire.Cli.Backchannel;
using Aspire.Cli.Commands;
using Aspire.Cli.Interaction;
+using Aspire.Cli.Resources;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Tests.TestServices;
using Microsoft.Extensions.DependencyInjection;
@@ -116,8 +119,49 @@ public async Task DestroyCommandPassesCorrectStepArgument()
Assert.Equal(0, exitCode);
}
- [Fact]
- public async Task DestroyCommandForwardsYesFlag()
+ [Theory]
+ [InlineData("destroy --non-interactive")]
+ [InlineData("--non-interactive destroy")]
+ public async Task DestroyCommandFailsFastWhenNonInteractiveWithoutYes(string commandLine)
+ {
+ using var tempRepo = TemporaryWorkspace.Create(outputHelper);
+ var appHostStarted = false;
+
+ var services = CliTestHelper.CreateServiceCollection(tempRepo, outputHelper, options =>
+ {
+ options.ProjectLocatorFactory = (sp) => new TestProjectLocator();
+
+ options.DotNetCliRunnerFactory = (sp) =>
+ {
+ var runner = new TestDotNetCliRunner
+ {
+ RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) =>
+ {
+ appHostStarted = true;
+ return Task.FromResult(0);
+ }
+ };
+
+ return runner;
+ };
+ });
+
+ using var provider = services.BuildServiceProvider();
+ var command = provider.GetRequiredService();
+
+ var result = command.Parse(commandLine);
+ var exitCode = await result.InvokeAsync().DefaultTimeout();
+
+ Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode);
+ Assert.Contains(result.Errors, error => string.Equals(error.Message, DestroyCommandStrings.NonInteractiveRequiresYes, StringComparison.Ordinal));
+ Assert.False(appHostStarted);
+ }
+
+ [Theory]
+ [InlineData("destroy --yes")]
+ [InlineData("destroy --non-interactive --yes")]
+ [InlineData("--non-interactive destroy --yes")]
+ public async Task DestroyCommandForwardsYesFlag(string commandLine)
{
using var tempRepo = TemporaryWorkspace.Create(outputHelper);
@@ -167,7 +211,7 @@ public async Task DestroyCommandForwardsYesFlag()
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService();
- var result = command.Parse("destroy --yes");
+ var result = command.Parse(commandLine);
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
@@ -230,4 +274,112 @@ public async Task DestroyCommandIncludesOutputPathWhenSpecified()
Assert.Equal(0, exitCode);
}
+
+ [Fact]
+ public async Task DestroyCommandReturnsNonZeroExitCodeWhenDestroyActivitiesFail()
+ {
+ using var tempRepo = TemporaryWorkspace.Create(outputHelper);
+
+ var services = CliTestHelper.CreateServiceCollection(tempRepo, outputHelper, options =>
+ {
+ options.ProjectLocatorFactory = (sp) => new TestProjectLocator();
+
+ options.DotNetCliRunnerFactory = (sp) =>
+ {
+ var runner = new TestDotNetCliRunner
+ {
+ BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0,
+
+ GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) =>
+ {
+ return (0, true, VersionHelper.GetDefaultTemplateVersion());
+ },
+
+ RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) =>
+ {
+ var destroyCompleted = new TaskCompletionSource();
+ var backchannel = new TestAppHostBackchannel
+ {
+ RequestStopAsyncCalled = destroyCompleted,
+ GetPublishingActivitiesAsyncCallback = GetFailedDestroyActivities
+ };
+ backchannelCompletionSource?.SetResult(backchannel);
+ await destroyCompleted.Task.DefaultTimeout();
+ return 0;
+ }
+ };
+
+ return runner;
+ };
+
+ options.PublishCommandPrompterFactory = (sp) =>
+ {
+ var interactionService = sp.GetRequiredService();
+ return new TestDeployCommandPrompter(interactionService);
+ };
+ });
+
+ using var provider = services.BuildServiceProvider();
+ var command = provider.GetRequiredService();
+
+ var result = command.Parse("destroy --yes");
+ var exitCode = await result.InvokeAsync().DefaultTimeout();
+
+ Assert.Equal(ExitCodeConstants.FailedToBuildArtifacts, exitCode);
+
+ static async IAsyncEnumerable GetFailedDestroyActivities([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await Task.Yield();
+
+ yield return new PublishingActivity
+ {
+ Type = PublishingActivityTypes.Step,
+ Data = new PublishingActivityData
+ {
+ Id = "destroy-step",
+ StatusText = "Destroying resources",
+ CompletionState = CompletionStates.InProgress,
+ StepId = null
+ }
+ };
+
+ yield return new PublishingActivity
+ {
+ Type = PublishingActivityTypes.Task,
+ Data = new PublishingActivityData
+ {
+ Id = "destroy-compose",
+ StatusText = "Stopping containers",
+ CompletionState = CompletionStates.InProgress,
+ StepId = "destroy-step"
+ }
+ };
+
+ yield return new PublishingActivity
+ {
+ Type = PublishingActivityTypes.Task,
+ Data = new PublishingActivityData
+ {
+ Id = "destroy-compose",
+ StatusText = "Stopping containers failed",
+ CompletionMessage = "Failed to stop containers",
+ CompletionState = CompletionStates.CompletedWithError,
+ StepId = "destroy-step"
+ }
+ };
+
+ // Simulate publish complete with error
+ yield return new PublishingActivity
+ {
+ Type = PublishingActivityTypes.PublishComplete,
+ Data = new PublishingActivityData
+ {
+ Id = "publish-complete",
+ StatusText = "Deployment completed with errors",
+ CompletionState = CompletionStates.CompletedWithError,
+ StepId = null
+ }
+ };
+ }
+ }
}