11// Licensed to the .NET Foundation under one or more agreements.
22// The .NET Foundation licenses this file to you under the MIT license.
33
4+ using System . Formats . Tar ;
5+ using System . IO . Compression ;
46using System . Security . Cryptography ;
57using System . Text ;
68using Aspire . Cli . Configuration ;
79using Aspire . Cli . DotNet ;
810using Aspire . Cli . Layout ;
911using Aspire . Cli . NuGet ;
1012using Aspire . Cli . Packaging ;
13+ using Aspire . Shared ;
1114using Microsoft . Extensions . Logging ;
1215
1316namespace Aspire . Cli . Projects ;
@@ -17,7 +20,7 @@ namespace Aspire.Cli.Projects;
1720/// </summary>
1821internal interface IAppHostServerProjectFactory
1922{
20- IAppHostServerProject Create ( string appPath ) ;
23+ Task < IAppHostServerProject > CreateAsync ( string appPath , CancellationToken cancellationToken = default ) ;
2124}
2225
2326/// <summary>
@@ -32,7 +35,7 @@ internal sealed class AppHostServerProjectFactory(
3235 BundleNuGetService bundleNuGetService ,
3336 ILoggerFactory loggerFactory ) : IAppHostServerProjectFactory
3437{
35- public IAppHostServerProject Create ( string appPath )
38+ public async Task < IAppHostServerProject > CreateAsync ( string appPath , CancellationToken cancellationToken = default )
3639 {
3740 // Normalize the path
3841 var normalizedPath = Path . GetFullPath ( appPath ) ;
@@ -71,7 +74,10 @@ public IAppHostServerProject Create(string appPath)
7174 loggerFactory . CreateLogger < DotNetBasedAppHostServerProject > ( ) ) ;
7275 }
7376
74- // Priority 2: Check if we have a bundle layout with a pre-built AppHost server
77+ // Priority 2: Ensure bundle is extracted if we have an embedded payload
78+ await EnsureBundleAsync ( cancellationToken ) ;
79+
80+ // Priority 3: Check if we have a bundle layout with a pre-built AppHost server
7581 var layout = layoutDiscovery . DiscoverLayout ( ) ;
7682 if ( layout is not null && layout . GetAppHostServerPath ( ) is string serverPath && File . Exists ( serverPath ) )
7783 {
@@ -91,6 +97,145 @@ public IAppHostServerProject Create(string appPath)
9197 "with a valid bundle layout." ) ;
9298 }
9399
100+ /// <summary>
101+ /// Extracts the embedded bundle payload if the CLI binary is a self-extracting bundle
102+ /// and no valid layout has been discovered yet.
103+ /// </summary>
104+ private async Task EnsureBundleAsync ( CancellationToken cancellationToken )
105+ {
106+ // If a layout already exists, nothing to do
107+ if ( layoutDiscovery . DiscoverLayout ( ) is not null )
108+ {
109+ return ;
110+ }
111+
112+ // Check if the current process has an embedded bundle payload
113+ var processPath = Environment . ProcessPath ;
114+ if ( string . IsNullOrEmpty ( processPath ) )
115+ {
116+ return ;
117+ }
118+
119+ var trailer = BundleTrailer . TryRead ( processPath ) ;
120+ if ( trailer is null )
121+ {
122+ return ; // No embedded payload (dev build or already-extracted CLI)
123+ }
124+
125+ // Extract to the parent directory of the CLI binary's directory.
126+ // If CLI is at ~/.aspire/bin/aspire, extract to ~/.aspire/ so layout discovery
127+ // finds components via the bin/ layout pattern ({layout}/bin/aspire + {layout}/runtime/).
128+ var cliDir = Path . GetDirectoryName ( processPath ) ;
129+ if ( string . IsNullOrEmpty ( cliDir ) )
130+ {
131+ return ;
132+ }
133+
134+ var extractDir = Path . GetDirectoryName ( cliDir ) ?? cliDir ;
135+ var logger = loggerFactory . CreateLogger < AppHostServerProjectFactory > ( ) ;
136+ logger . LogInformation ( "Extracting embedded bundle to {Path}..." , extractDir ) ;
137+
138+ await ExtractPayloadAsync ( processPath , trailer , extractDir , cancellationToken ) ;
139+
140+ // Verify extraction succeeded
141+ if ( layoutDiscovery . DiscoverLayout ( ) is null )
142+ {
143+ logger . LogWarning ( "Bundle extraction completed but layout discovery still failed" ) ;
144+ }
145+ }
146+
147+ /// <summary>
148+ /// Extracts the embedded tar.gz payload from the CLI binary to the specified directory.
149+ /// The tarball contains a top-level directory which is stripped during extraction.
150+ /// </summary>
151+ internal static async Task ExtractPayloadAsync ( string processPath , BundleTrailerInfo trailer , string destinationPath , CancellationToken cancellationToken )
152+ {
153+ Directory . CreateDirectory ( destinationPath ) ;
154+
155+ using var payloadStream = BundleTrailer . OpenPayload ( processPath , trailer ) ;
156+ await using var gzipStream = new GZipStream ( payloadStream , CompressionMode . Decompress ) ;
157+ await using var tarReader = new TarReader ( gzipStream ) ;
158+
159+ while ( await tarReader . GetNextEntryAsync ( cancellationToken : cancellationToken ) is { } entry )
160+ {
161+ // Strip the top-level directory (equivalent to tar --strip-components=1)
162+ var name = entry . Name ;
163+ var slashIndex = name . IndexOf ( '/' ) ;
164+ if ( slashIndex < 0 )
165+ {
166+ continue ; // Top-level directory entry itself, skip
167+ }
168+
169+ var relativePath = name [ ( slashIndex + 1 ) ..] ;
170+ if ( string . IsNullOrEmpty ( relativePath ) )
171+ {
172+ continue ;
173+ }
174+
175+ var fullPath = Path . Combine ( destinationPath , relativePath ) ;
176+
177+ switch ( entry . EntryType )
178+ {
179+ case TarEntryType . Directory :
180+ Directory . CreateDirectory ( fullPath ) ;
181+ break ;
182+
183+ case TarEntryType . RegularFile :
184+ var dir = Path . GetDirectoryName ( fullPath ) ;
185+ if ( dir is not null )
186+ {
187+ Directory . CreateDirectory ( dir ) ;
188+ }
189+ await entry . ExtractToFileAsync ( fullPath , overwrite : true , cancellationToken ) ;
190+ break ;
191+
192+ case TarEntryType . SymbolicLink :
193+ var linkDir = Path . GetDirectoryName ( fullPath ) ;
194+ if ( linkDir is not null )
195+ {
196+ Directory . CreateDirectory ( linkDir ) ;
197+ }
198+ if ( File . Exists ( fullPath ) )
199+ {
200+ File . Delete ( fullPath ) ;
201+ }
202+ File . CreateSymbolicLink ( fullPath , entry . LinkName ) ;
203+ break ;
204+ }
205+ }
206+
207+ // Set executable permissions on Unix for key binaries
208+ if ( ! OperatingSystem . IsWindows ( ) )
209+ {
210+ SetExecutablePermissions ( destinationPath ) ;
211+ }
212+ }
213+
214+ /// <summary>
215+ /// Sets executable permissions on key binaries after extraction on Unix systems.
216+ /// </summary>
217+ [ System . Runtime . Versioning . UnsupportedOSPlatform ( "windows" ) ]
218+ private static void SetExecutablePermissions ( string layoutPath )
219+ {
220+ var muxerName = BundleDiscovery . GetDotNetExecutableName ( ) ;
221+ string [ ] executablePaths =
222+ [
223+ Path . Combine ( layoutPath , BundleDiscovery . RuntimeDirectoryName , muxerName ) ,
224+ Path . Combine ( layoutPath , BundleDiscovery . DcpDirectoryName , "dcp" ) ,
225+ ] ;
226+
227+ foreach ( var path in executablePaths )
228+ {
229+ if ( File . Exists ( path ) )
230+ {
231+ File . SetUnixFileMode ( path ,
232+ UnixFileMode . UserRead | UnixFileMode . UserWrite | UnixFileMode . UserExecute |
233+ UnixFileMode . GroupRead | UnixFileMode . GroupExecute |
234+ UnixFileMode . OtherRead | UnixFileMode . OtherExecute ) ;
235+ }
236+ }
237+ }
238+
94239 /// <summary>
95240 /// Detects the Aspire repository root for dev mode.
96241 /// Checks ASPIRE_REPO_ROOT env var first, then walks up from the CLI executable
0 commit comments