Skip to content

Commit 7286d4d

Browse files
committed
Add version check, update --self extraction, tests, and README docs
- Version marker file (.aspire-bundle-version) written after extraction; EnsureBundleAsync and SetupCommand skip extraction when version matches - update --self proactively extracts embedded payload after replacing binary - 10 unit tests for BundleTrailer: roundtrip, payload slicing, version marker, and tar.gz extraction with strip-components - README documents self-extracting binary install path
1 parent b57f266 commit 7286d4d

File tree

6 files changed

+358
-17
lines changed

6 files changed

+358
-17
lines changed

eng/scripts/README.md

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -303,10 +303,38 @@ Remove-Item -Recurse -Force "$env:LOCALAPPDATA\Aspire"
303303

304304
### Bundle vs CLI-Only
305305

306-
| Feature | CLI-Only Scripts | Bundle Scripts |
307-
|---------|-----------------|----------------|
308-
| Requires .NET SDK | Yes | No |
309-
| Package size | ~25 MB | ~200 MB compressed |
310-
| Polyglot support | Partial | Full |
311-
| Components included | CLI only | CLI, Runtime, Dashboard, DCP |
312-
| Use case | .NET developers | TypeScript, Python, Go developers |
306+
| Feature | CLI-Only Scripts | Bundle Scripts | Self-Extracting Binary |
307+
|---------|-----------------|----------------|----------------------|
308+
| Requires .NET SDK | Yes | No | No |
309+
| Package size | ~25 MB | ~200 MB compressed | ~210 MB (single file) |
310+
| Polyglot support | Partial | Full | Full |
311+
| Components included | CLI only | CLI, Runtime, Dashboard, DCP | CLI, Runtime, Dashboard, DCP |
312+
| Installation steps | Download + PATH | Download + extract + PATH | Download + `aspire setup` |
313+
| Use case | .NET developers | TypeScript, Python, Go developers | Simplest install path |
314+
315+
### Self-Extracting Binary
316+
317+
The Aspire CLI can also be distributed as a self-extracting binary that embeds the full bundle
318+
payload inside the native AOT executable. This is the simplest installation method:
319+
320+
```bash
321+
# Linux/macOS - download and extract
322+
mkdir -p ~/.aspire/bin
323+
curl -fsSL <url>/aspire -o ~/.aspire/bin/aspire
324+
chmod +x ~/.aspire/bin/aspire
325+
~/.aspire/bin/aspire setup
326+
327+
# Add to PATH
328+
export PATH="$HOME/.aspire/bin:$PATH"
329+
```
330+
331+
```powershell
332+
# Windows - download and extract
333+
New-Item -ItemType Directory -Force -Path "$env:LOCALAPPDATA\Aspire\bin"
334+
Invoke-WebRequest -Uri <url>/aspire.exe -OutFile "$env:LOCALAPPDATA\Aspire\bin\aspire.exe"
335+
& "$env:LOCALAPPDATA\Aspire\bin\aspire.exe" setup
336+
```
337+
338+
The `aspire setup` command extracts the embedded payload to the parent directory of the CLI binary.
339+
Alternatively, extraction happens lazily on the first command that needs the bundle layout
340+
(e.g., `aspire run` with a polyglot project).

src/Aspire.Cli/Commands/SetupCommand.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,15 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
8080
return ExitCodeConstants.FailedToBuildArtifacts;
8181
}
8282

83-
// Check if layout already exists
83+
// Check if layout already exists with matching version
8484
if (!force && _layoutDiscovery.DiscoverLayout() is not null)
8585
{
86-
InteractionService.DisplayMessage(":white_check_mark:", "Bundle is already extracted. Use --force to re-extract.");
87-
return ExitCodeConstants.Success;
86+
var existingHash = BundleTrailer.ReadVersionMarker(installPath);
87+
if (existingHash == trailer.VersionHash)
88+
{
89+
InteractionService.DisplayMessage(":white_check_mark:", "Bundle is already extracted and up to date. Use --force to re-extract.");
90+
return ExitCodeConstants.Success;
91+
}
8892
}
8993

9094
// Extract with spinner
@@ -93,6 +97,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
9397
async () =>
9498
{
9599
await AppHostServerProjectFactory.ExtractPayloadAsync(processPath, trailer, installPath, cancellationToken);
100+
BundleTrailer.WriteVersionMarker(installPath, trailer.VersionHash);
96101
return ExitCodeConstants.Success;
97102
});
98103

src/Aspire.Cli/Commands/UpdateCommand.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
using Aspire.Cli.Resources;
1717
using Aspire.Cli.Telemetry;
1818
using Aspire.Cli.Utils;
19+
using Aspire.Shared;
1920
using Microsoft.Extensions.Logging;
2021
using Spectre.Console;
2122

@@ -485,6 +486,17 @@ private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken c
485486
// If we get here, the update was successful, clean up old backups
486487
CleanupOldBackupFiles(targetExePath);
487488

489+
// If the new binary is a self-extracting bundle, proactively extract it
490+
// so the user doesn't have to wait on next run
491+
var trailer = BundleTrailer.TryRead(targetExePath);
492+
if (trailer is not null)
493+
{
494+
InteractionService.DisplayMessage("package", "Extracting embedded bundle...");
495+
var extractDir = Path.GetDirectoryName(installDir) ?? installDir;
496+
await AppHostServerProjectFactory.ExtractPayloadAsync(targetExePath, trailer, extractDir, cancellationToken);
497+
BundleTrailer.WriteVersionMarker(extractDir, trailer.VersionHash);
498+
}
499+
488500
// Display helpful message about PATH
489501
if (!IsInPath(installDir))
490502
{

src/Aspire.Cli/Projects/AppHostServerProject.cs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,6 @@ public async Task<IAppHostServerProject> CreateAsync(string appPath, Cancellatio
103103
/// </summary>
104104
private async Task EnsureBundleAsync(CancellationToken cancellationToken)
105105
{
106-
// If a layout already exists, nothing to do
107-
if (layoutDiscovery.DiscoverLayout() is not null)
108-
{
109-
return;
110-
}
111-
112106
// Check if the current process has an embedded bundle payload
113107
var processPath = Environment.ProcessPath;
114108
if (string.IsNullOrEmpty(processPath))
@@ -122,7 +116,7 @@ private async Task EnsureBundleAsync(CancellationToken cancellationToken)
122116
return; // No embedded payload (dev build or already-extracted CLI)
123117
}
124118

125-
// Extract to the parent directory of the CLI binary's directory.
119+
// Determine extraction directory: parent of the CLI binary's directory.
126120
// If CLI is at ~/.aspire/bin/aspire, extract to ~/.aspire/ so layout discovery
127121
// finds components via the bin/ layout pattern ({layout}/bin/aspire + {layout}/runtime/).
128122
var cliDir = Path.GetDirectoryName(processPath);
@@ -132,11 +126,25 @@ private async Task EnsureBundleAsync(CancellationToken cancellationToken)
132126
}
133127

134128
var extractDir = Path.GetDirectoryName(cliDir) ?? cliDir;
129+
130+
// If layout exists and version matches, skip extraction
131+
if (layoutDiscovery.DiscoverLayout() is not null)
132+
{
133+
var existingHash = BundleTrailer.ReadVersionMarker(extractDir);
134+
if (existingHash == trailer.VersionHash)
135+
{
136+
return; // Already extracted with matching version
137+
}
138+
}
139+
135140
var logger = loggerFactory.CreateLogger<AppHostServerProjectFactory>();
136141
logger.LogInformation("Extracting embedded bundle to {Path}...", extractDir);
137142

138143
await ExtractPayloadAsync(processPath, trailer, extractDir, cancellationToken);
139144

145+
// Write version marker so subsequent runs skip extraction
146+
BundleTrailer.WriteVersionMarker(extractDir, trailer.VersionHash);
147+
140148
// Verify extraction succeeded
141149
if (layoutDiscovery.DiscoverLayout() is null)
142150
{

src/Shared/BundleTrailer.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,36 @@ public static Stream OpenPayload(string filePath, BundleTrailerInfo trailer)
114114
return new SubStream(stream, (long)trailer.PayloadSize, ownsStream: true);
115115
}
116116

117+
/// <summary>
118+
/// Name of the marker file written after successful extraction.
119+
/// </summary>
120+
public const string VersionMarkerFileName = ".aspire-bundle-version";
121+
122+
/// <summary>
123+
/// Writes a version marker file to the extraction directory.
124+
/// </summary>
125+
public static void WriteVersionMarker(string extractDir, ulong versionHash)
126+
{
127+
var markerPath = Path.Combine(extractDir, VersionMarkerFileName);
128+
File.WriteAllText(markerPath, versionHash.ToString("X16", System.Globalization.CultureInfo.InvariantCulture));
129+
}
130+
131+
/// <summary>
132+
/// Reads the version hash from a previously written marker file.
133+
/// Returns null if the marker doesn't exist or is invalid.
134+
/// </summary>
135+
public static ulong? ReadVersionMarker(string extractDir)
136+
{
137+
var markerPath = Path.Combine(extractDir, VersionMarkerFileName);
138+
if (!File.Exists(markerPath))
139+
{
140+
return null;
141+
}
142+
143+
var content = File.ReadAllText(markerPath).Trim();
144+
return ulong.TryParse(content, System.Globalization.NumberStyles.HexNumber, null, out var hash) ? hash : null;
145+
}
146+
117147
/// <summary>
118148
/// A stream wrapper that exposes a fixed-length window of an underlying stream.
119149
/// </summary>

0 commit comments

Comments
 (0)