Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
c878c4b
Share TypeScript API projection
adamint Aug 5, 2026
ab7100e
Expose canonical SDK API exports
adamint Aug 5, 2026
ec0099e
Add SDK API export command
adamint Aug 5, 2026
8f6ea4a
Make exported declaration fragments self-contained
adamint Aug 5, 2026
bb015af
Reference the code generation package from sdk export
adamint Aug 5, 2026
529d9b9
Stop augmentations from claiming another package's type
adamint Aug 5, 2026
337e075
Pin out-of-repo scanner packages with VersionOverride
adamint Aug 5, 2026
0f84198
Dispose the scanner session when startup fails
adamint Aug 5, 2026
e9f54d7
Normalize declaration content line endings
adamint Aug 5, 2026
e3e506e
Make augmentation IDs unique and project client-only DTO properties
adamint Aug 5, 2026
f08d9e1
Reject core version skew in sdk export
adamint Aug 5, 2026
bb03ab9
Route sdk export diagnostics to stderr
adamint Aug 5, 2026
b35c38b
Restore the generated scanner project in the CPM test
adamint Aug 6, 2026
4397483
Fix TypeScript API export parameters
Aug 7, 2026
4924741
Preserve TypeScript API export ownership
Aug 7, 2026
e606886
Make sdk export restore the version it labels
Aug 7, 2026
459d81c
Verify sdk export provenance instead of trusting a name or an override
Aug 7, 2026
d204a1b
Check the core export against the checkout, not just the identity
Aug 7, 2026
5641913
Resolve the core substitution under its canonical name
Aug 7, 2026
56ffd1a
Settle the package id's spelling before anything acts on it
Aug 7, 2026
9f9fccb
Don't lose the scanner to an unreadable src directory
Aug 7, 2026
a037884
Publish an export under the package id the assembly carries
Aug 7, 2026
a165096
Canonicalize against the names the filter matches on
Aug 7, 2026
34f64a3
Say which type broke the guard, not just that one did
Aug 7, 2026
f55f47b
Name an export's options interfaces the way the SDK names them
Aug 7, 2026
d6d6a1b
Back out a naming fix that cannot fire where it matters
Aug 7, 2026
1db91e5
Derive TypeScript options interface names from the owning assembly
Aug 7, 2026
c7eacda
Pin export/generate naming agreement to the filtered context
Aug 7, 2026
60a57ea
Correct what the naming-agreement remark claims
Aug 7, 2026
2886db7
Prune per-capability registries to the capabilities a scan keeps
Aug 7, 2026
e30db3c
Fix three identity bugs in TypeScript export naming and gating
Aug 7, 2026
e6cc066
Fix package assembly scoping for API export
Aug 8, 2026
e837c41
Flow package probe manifest through repo apphost server
Aug 8, 2026
59bedbf
Make TypeScript options qualifiers injective
Aug 8, 2026
8693974
Guard TypeScript options interface collisions
Aug 9, 2026
b329056
Fix TypeScript API export review gaps
adamint Aug 9, 2026
f27bf0d
Tighten TypeScript API export versioning
adamint Aug 9, 2026
7b1a3af
Validate the substituted code generator against the CLI identity
adamint Aug 9, 2026
e279ec3
Address review findings on the canonical TypeScript API export
Aug 9, 2026
9125b7d
Export the version NuGet resolves on both package paths
Aug 9, 2026
390faaa
Apply the projector's optionality rule to the compat guard
Aug 9, 2026
c9061a2
Send the generator name the server keys on
Aug 9, 2026
f88c123
Check the options interface the generator will actually emit
Aug 9, 2026
a3c517f
Remove the stray commit-message scratch file
Aug 9, 2026
1374c3f
Stop resolving a blank --language to whichever came first alphabetically
Aug 9, 2026
d218959
Catch a projected method rename the capability id hides
Aug 10, 2026
d80f301
Terminate the options interface qualifier
Aug 10, 2026
5d95348
Qualify the two colliding options interface names
Aug 10, 2026
3e13676
Accept four-segment NuGet package versions
Aug 10, 2026
6f23cbd
Put the opening brace on its own line
Aug 10, 2026
2e1a9db
Pin the two shipped options interface collisions
Aug 10, 2026
bc0d1c5
Validate the suffix on a four-segment version
Aug 10, 2026
5563e7c
Keep the generator path off the new AtsContext member
Aug 10, 2026
98991cc
Add regression coverage for four-segment build metadata in sdk export
Aug 10, 2026
daeb8ed
Merge branch 'main' into adamint/versioned-ts-api-docs
adamint Aug 10, 2026
fa64fe6
Reduce API export to the focused contract
Aug 10, 2026
024178a
Address focused API export review findings
Aug 10, 2026
c5f87c6
Tighten API export contracts
Aug 10, 2026
5676f20
Fix empty union projection test
Aug 10, 2026
37fe731
Fix exact package restore with CPM
Aug 10, 2026
c7fd567
Fix canonical package identity in SDK exports
Aug 11, 2026
dc14f2e
Accept four-part NuGet versions in SDK export
Aug 11, 2026
6a279f7
Propagate canonical package IDs in probe manifests
Aug 11, 2026
0c56951
Normalize zero NuGet version revisions
Aug 11, 2026
644a7d5
Merge upstream/main into adamint/versioned-ts-api-docs
Aug 14, 2026
8753075
Keep sdk export package restores explicit
Aug 14, 2026
7a628a4
Honor pre-canceled API exports
Aug 14, 2026
59cb35a
Fix package identity for copied integration assets
Aug 14, 2026
30f306d
Ignore stale ATS capability registry entries
Aug 14, 2026
84ac5a0
Reject mismatched emulated core exports
Aug 14, 2026
7befb3b
Merge origin/main into sdk export branch
adamint Aug 24, 2026
5abdc5d
Address sdk export review findings
adamint Aug 24, 2026
22b5bc0
Strengthen package export collision coverage
adamint Aug 24, 2026
7a53efb
Merge latest main into sdk export
adamint Aug 24, 2026
8d8f782
Normalize TypeScript export test line endings
adamint Aug 24, 2026
90586da
Reject incompatible generator package exports
adamint Aug 25, 2026
93cc33e
Reject generator package API exports
adamint Aug 25, 2026
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
2 changes: 2 additions & 0 deletions src/Aspire.Cli/Commands/Sdk/SdkCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ internal sealed class SdkCommand : ParentCommand
public SdkCommand(
SdkGenerateCommand generateCommand,
SdkDumpCommand dumpCommand,
SdkExportCommand exportCommand,
CommonCommandServices services)
: base("sdk", "Commands for generating SDKs for building Aspire integrations in other languages.", services)
{
Hidden = true;
Subcommands.Add(generateCommand);
Subcommands.Add(dumpCommand);
Subcommands.Add(exportCommand);
}
}
233 changes: 233 additions & 0 deletions src/Aspire.Cli/Commands/Sdk/SdkCommandPreparation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Microsoft.Extensions.Logging;
using Semver;

namespace Aspire.Cli.Commands.Sdk;

/// <summary>
/// The setup that <c>sdk dump</c> and <c>sdk export</c> both need before they can ask an AppHost
/// server anything: turning command-line integration arguments into references, standing up a
/// throwaway scanner AppHost, and working out which assemblies the caller actually asked about.
/// </summary>
/// <remarks>
/// Only the preparation is shared. The two commands ask different questions of the server and
/// serialize the answers differently, and deliberately keeping that apart is what stops
/// <c>sdk dump</c> from quietly becoming an alias for the canonical export.
/// </remarks>
internal static class SdkCommandPreparation
{
/// <summary>
/// Parses one integration argument, which is either a path to a <c>.csproj</c> or a package
/// reference in <c>PackageName@Version</c> form (for example <c>Aspire.Hosting.Redis@13.5.0</c>).
/// </summary>
/// <param name="argument">The raw command-line argument.</param>
/// <param name="requireExactVersion">
/// When <see langword="true"/>, floating and range versions are rejected. Callers that publish
/// artifacts keyed on the version need this; a document published under <c>13.5.*</c> would
/// describe a different SDK after the next restore.
/// </param>
/// <param name="reference">The parsed reference when parsing succeeds.</param>
/// <param name="errorExitCode">The exit code to return when parsing fails.</param>
/// <param name="errorMessage">The user-facing failure reason when parsing fails.</param>
/// <returns><see langword="true"/> when the argument was parsed.</returns>
public static bool TryParseIntegrationArgument(
string argument,
bool requireExactVersion,
out IntegrationReference? reference,
out int errorExitCode,
out string? errorMessage)
{
reference = null;
errorExitCode = CliExitCodes.InvalidCommand;
errorMessage = null;

if (argument.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase))
{
var projectFile = new FileInfo(argument);
if (!projectFile.Exists)
{
errorExitCode = CliExitCodes.FailedToFindProject;
errorMessage = $"Integration project not found: {projectFile.FullName}";
return false;
}

reference = IntegrationReference.FromProject(
IntegrationAssemblyNameResolver.Resolve(projectFile),
projectFile.FullName);
return true;
}

if (!argument.Contains('@'))
{
errorMessage = $"Invalid integration argument '{argument}'. Expected a .csproj path or PackageName@Version format.";
return false;
}

var atIndex = argument.LastIndexOf('@');
var packageName = argument[..atIndex];
var packageVersion = argument[(atIndex + 1)..];

if (string.IsNullOrWhiteSpace(packageName) || string.IsNullOrWhiteSpace(packageVersion) || packageName.Contains('@'))
{
errorMessage = $"Invalid package format '{argument}'. Expected PackageName@Version (e.g. Aspire.Hosting.Redis@9.2.0).";
return false;
}

if (!SemVersion.TryParse(packageVersion, SemVersionStyles.Any, out _))
{
errorMessage = requireExactVersion
? $"Invalid version '{packageVersion}' in '{argument}'. Expected an exact NuGet version (e.g. 9.2.0); floating and range versions are not supported."
: $"Invalid version '{packageVersion}' in '{argument}'. Expected a valid NuGet version (e.g. 9.2.0).";
return false;
}

reference = IntegrationReference.FromPackage(packageName, packageVersion);
return true;
}

/// <summary>
/// Finds the first assembly name that more than one integration resolves to.
/// </summary>
/// <param name="integrations">The parsed integration references.</param>
/// <returns>The duplicated assembly name, or <see langword="null"/> when there is none.</returns>
public static string? FindDuplicateAssemblyName(IReadOnlyList<IntegrationReference> integrations)
=> integrations
.GroupBy(integration => integration.Name, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(group => group.Count() > 1)?.Key;

/// <summary>
/// Gets the exporting assembly names to scope a server query to, or <see langword="null"/> when
/// the caller asked for everything.
/// </summary>
/// <param name="integrations">The parsed integration references.</param>
public static string[]? GetExportingAssemblyNames(IReadOnlyList<IntegrationReference> integrations)
=> integrations.Count > 0
? [.. integrations.Select(integration => integration.Name).Distinct(StringComparer.OrdinalIgnoreCase)]
: null;

/// <summary>
/// Builds and starts a throwaway AppHost server that has the requested integrations restored, and
/// returns a connected RPC client.
/// </summary>
/// <remarks>
/// The returned <see cref="PreparedSdkSession"/> owns the temporary directory and the server
/// session; disposing it tears both down. Build failures are reported through
/// <paramref name="interactionService"/> and surface as a null session rather than an exception,
/// because a failed restore is a user-facing outcome and not a bug.
/// </remarks>
public static async Task<PreparedSdkSession?> PrepareSessionAsync(
IAppHostServerProjectFactory appHostServerProjectFactory,
IAppHostServerSessionFactory serverSessionFactory,
IInteractionService interactionService,
ILogger logger,
string tempDirectoryPrefix,
string sdkVersion,
IReadOnlyList<IntegrationReference> integrations,
string? packageSourceOverride,
CancellationToken cancellationToken)
{
var tempDirectory = Directory.CreateTempSubdirectory(tempDirectoryPrefix);
var tempDir = tempDirectory.FullName;
var disposeTempDirectory = true;

try
{
var appHostServerProject = await appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken);

logger.LogDebug("Building AppHost server with {Count} integrations", integrations.Count);

var prepareResult = await appHostServerProject.PrepareAsync(
sdkVersion,
integrations,
packageSourceOverride: packageSourceOverride,
cancellationToken: cancellationToken);

if (!prepareResult.Success)
{
interactionService.DisplayError("Failed to build capability scanner.");
if (prepareResult.Output is not null)
{
foreach (var (_, line) in prepareResult.Output.GetLines())
{
interactionService.DisplayMessage(KnownEmojis.Wrench, line);
}
}
return null;
}

var serverSession = serverSessionFactory.Create(appHostServerProject, environmentVariables: null, debug: false, gracefulShutdownSignaler: null, shutdownService: null, isolateConsole: false, cancellationToken);

try
{
// Short-lived RPC session: StartAsync() spawns the server. We never observe the
// exit-code task (WaitForExitAsync) because disposal flows the exit code through the
// activity scope and the only failure mode we care about surfaces via the RPC call.
await serverSession.StartAsync();

var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken);

disposeTempDirectory = false;
return new PreparedSdkSession(serverSession, rpcClient, tempDir, logger);
}
catch
{
// Ownership only transfers to PreparedSdkSession once we return one. Until then a
// failed start leaves the scanner process alive and holding the temp directory.
await serverSession.DisposeAsync();
throw;
}
}
finally
{
if (disposeTempDirectory)
{
DeleteTempDirectory(tempDir, logger);
}
}
}

internal static void DeleteTempDirectory(string tempDir, ILogger logger)
{
try
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Failed to clean up temp directory {TempDir}", tempDir);
}
}
}

/// <summary>
/// A started AppHost scanner server and its connected RPC client. Disposing tears down the server
/// session and deletes the temporary project directory.
/// </summary>
internal sealed class PreparedSdkSession(
IAppHostServerSession session,
IAppHostRpcClient rpcClient,
string tempDirectory,
ILogger logger) : IAsyncDisposable
{
public IAppHostRpcClient RpcClient { get; } = rpcClient;

public async ValueTask DisposeAsync()
{
try
{
await session.DisposeAsync();
}
finally
{
SdkCommandPreparation.DeleteTempDirectory(tempDirectory, logger);
}
}
}
Loading
Loading