diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj
index a3633f025f6..0140c148a9c 100644
--- a/src/Aspire.Cli/Aspire.Cli.csproj
+++ b/src/Aspire.Cli/Aspire.Cli.csproj
@@ -24,6 +24,9 @@
$(DefineConstants);CLI
true
false
+
+ false
@@ -39,6 +42,8 @@
To do this we make sure it doesn't PublishAOT, and ensure that it doesn't publish for any RIDs at all. -->
false
+
+ true
diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs
index c06ff55b5b1..fee20a63601 100644
--- a/src/Aspire.Cli/Bundles/BundleService.cs
+++ b/src/Aspire.Cli/Bundles/BundleService.cs
@@ -29,6 +29,9 @@ internal sealed class BundleService(ILayoutDiscovery layoutDiscovery, ILogger
public bool IsBundle => s_isBundle;
+ ///
+ public bool IsSelfExtracting => false;
+
///
/// Opens a read-only stream over the embedded bundle payload.
/// Returns if no payload is embedded.
@@ -54,6 +57,9 @@ public async Task EnsureExtractedAsync(CancellationToken cancellationToken = def
return;
}
+ // TODO: When IsSelfExtracting is wired to the MSBuild property (future PR),
+ // non-self-extracting distributions can skip extraction when the layout is on disk.
+
var processPath = Environment.ProcessPath;
if (string.IsNullOrEmpty(processPath))
{
diff --git a/src/Aspire.Cli/Bundles/IBundleService.cs b/src/Aspire.Cli/Bundles/IBundleService.cs
index a486ef788a1..06427b1804e 100644
--- a/src/Aspire.Cli/Bundles/IBundleService.cs
+++ b/src/Aspire.Cli/Bundles/IBundleService.cs
@@ -15,6 +15,12 @@ internal interface IBundleService
///
bool IsBundle { get; }
+ ///
+ /// Gets whether the CLI binary should perform self-extraction of the embedded bundle at runtime.
+ /// When , the layout is expected to already be on disk (e.g. archive/installer distributions).
+ ///
+ bool IsSelfExtracting { get; }
+
///
/// Ensures the bundle is extracted for the current CLI binary if it contains an embedded payload.
/// No-ops if no payload is embedded, or if the layout is already extracted and up to date.
diff --git a/tests/Aspire.Cli.Tests/BundleServiceTests.cs b/tests/Aspire.Cli.Tests/BundleServiceTests.cs
index 9d8f5f015f5..d01f9fbc304 100644
--- a/tests/Aspire.Cli.Tests/BundleServiceTests.cs
+++ b/tests/Aspire.Cli.Tests/BundleServiceTests.cs
@@ -2,6 +2,8 @@
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Cli.Bundles;
+using Aspire.Cli.Layout;
+using Microsoft.Extensions.Logging.Abstractions;
namespace Aspire.Cli.Tests;
@@ -74,4 +76,39 @@ public void GetCurrentVersion_ReturnsNonNull()
Assert.NotNull(version);
Assert.NotEqual("unknown", version);
}
+
+ [Fact]
+ public void IsSelfExtracting_ReturnsFalse_WhenMetadataNotSetToTrue()
+ {
+ // Test assembly builds with default SelfExtractingBundle=false,
+ // so IsSelfExtracting should be false.
+ var layoutDiscovery = new LayoutDiscovery(NullLogger.Instance);
+ var service = new BundleService(layoutDiscovery, NullLogger.Instance);
+ Assert.False(service.IsSelfExtracting);
+ }
+
+ [Fact]
+ public async Task EnsureExtractedAndGetLayoutAsync_ReturnsNull_WhenNoBundleAndNoLayout()
+ {
+ // Test assembly has no embedded bundle and IsSelfExtracting=false.
+ // EnsureExtractedAsync should no-op and DiscoverLayout should return null.
+ var layoutDiscovery = new LayoutDiscovery(NullLogger.Instance);
+ var service = new BundleService(layoutDiscovery, NullLogger.Instance);
+
+ var layout = await service.EnsureExtractedAndGetLayoutAsync();
+ Assert.Null(layout);
+ }
+
+ [Fact]
+ public async Task EnsureExtractedAsync_DoesNotThrow_WhenNotSelfExtractingAndNoLayout()
+ {
+ // When IsSelfExtracting=false and no layout exists, EnsureExtractedAsync
+ // should not throw — it should gracefully no-op (since IsBundle is also false
+ // in the test assembly). This verifies the method doesn't unconditionally
+ // short-circuit in a way that prevents DiscoverLayout from being called.
+ var layoutDiscovery = new LayoutDiscovery(NullLogger.Instance);
+ var service = new BundleService(layoutDiscovery, NullLogger.Instance);
+
+ await service.EnsureExtractedAsync();
+ }
}
diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
index 6d4d2f1ea84..c3d80740eb4 100644
--- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
+++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
@@ -606,6 +606,8 @@ internal sealed class NullBundleService : IBundleService
{
public bool IsBundle => false;
+ public bool IsSelfExtracting => false;
+
public Task EnsureExtractedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task ExtractAsync(string destinationPath, bool force = false, CancellationToken cancellationToken = default)
@@ -618,10 +620,12 @@ public Task ExtractAsync(string destinationPath, bool force
///
/// A configurable bundle service for testing bundle-dependent behavior.
///
-internal sealed class TestBundleService(bool isBundle) : IBundleService
+internal sealed class TestBundleService(bool isBundle, bool isSelfExtracting = false) : IBundleService
{
public bool IsBundle => isBundle;
+ public bool IsSelfExtracting => isSelfExtracting;
+
public Layout.LayoutConfiguration? Layout { get; set; }
public Task EnsureExtractedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;