Skip to content

Commit 4f0392c

Browse files
JamesNKCopilot
andcommitted
Support configurable named integration sources
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bf7167e commit 4f0392c

40 files changed

Lines changed: 1042 additions & 38 deletions

‎docs/specs/cli-config-sources-1p-scenarios.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ The relevant excerpt from the project's file is:
116116

117117
This is an excerpt, not a replacement for the generated file. Maya keeps its AppHost, SDK, package, and other project-owned settings.
118118

119-
Only [allowlisted top-level settings](cli-config-sources.md#materialize-an-allowlist-into-the-app-configuration) are copied, not the entire source JSON. Currently only `skillSources` is allowed, and it is copied in full when supplied. Other source values, including `docs`, AppHost paths, SDK/channel selection, packages, and arbitrary feature settings, are not imported.
119+
Only [allowlisted top-level settings](cli-config-sources.md#materialize-an-allowlist-into-the-app-configuration) are copied, not the entire source JSON. `skillSources` and `integrationSources` are allowed, and each is copied in full when supplied. Other source values, including `docs`, AppHost paths, SDK/channel selection, packages, and arbitrary feature settings, are not imported.
120120

121121
Maya commits the resulting app file with the application. It contains neither Git credentials nor an absolute tracking path, so another machine can use the saved values immediately.
122122

‎docs/specs/cli-config-sources.md‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Aspire CLI config sources
22

3-
**Status:** Configuration acquisition, allowlisted app-file materialization, and named agent skill sources are implemented in the CLI. Other content-acquisition customizations remain proposed.
3+
**Status:** Configuration acquisition, allowlisted app-file materialization, named agent skill sources, and named integration sources are implemented in the CLI. Other content-acquisition customizations remain proposed.
44

55
For example developer journeys, see [Microsoft first-party config-source scenarios](cli-config-sources-1p-scenarios.md).
66

@@ -133,6 +133,7 @@ Only explicitly allowlisted top-level settings may be copied. Each supplied sect
133133
| Setting | Purpose |
134134
|---|---|
135135
| `skillSources` | Named agent skill sources and all their settings |
136+
| `integrationSources` | Named NuGet integration feeds and allowed package patterns |
136137

137138
Use the same root-only normalization for local settings and source files before matching the allowlist, and match top-level names case-insensitively like other CLI settings. Root-level flat colon-separated keys merge into existing nested sections regardless of casing. Normalize source files before configuration-provider validation; genuine duplicate nested leaves remain validation errors. Nested colon keys remain literal, including profile names, environment-variable names, and custom skill-source keys such as `{"skillSources":{"team":{"custom:key":"..."}}}`. These keys are copied unchanged when their top-level section is allowed. There is no source-specific normalization.
138139

@@ -353,6 +354,32 @@ The allowlist copies the entire supplied `skillSources` section, including arbit
353354
|---|---|---|
354355
| Skills | `aspire agent init` registers all configured native sources by default; `--aspire-source embedded` installs bundled skill files | Additional validated bundles, selected/default skills, and workspace installation destinations |
355356
| Templates | .NET templates are installed from NuGet; CLI-native starters use embedded files or scaffolding | Template catalogs, package identities, versions, and feeds; external starter content could follow later |
356-
| Integrations | Templates declare dependencies; `aspire add` and integration list/search discover NuGet packages | Additional catalog entries, package versions, feeds, and package-source mappings |
357+
| Integrations | Templates declare dependencies; `aspire add` and integration list/search discover packages from all configured NuGet integration sources | Additional catalog metadata and package-version constraints |
358+
359+
### Named integration sources
360+
361+
Configure integration discovery independently of the AppHost's SDK channel:
362+
363+
```json
364+
{
365+
"integrationSources": {
366+
"default": {},
367+
"team": {
368+
"source": "https://packages.example.com/v3/index.json",
369+
"patterns": ["Contoso.Hosting.*", "Contoso.SpecialIntegration"]
370+
}
371+
}
372+
}
373+
```
374+
375+
The implicit `default` entry retains existing channel and ambient NuGet discovery, including official and Community Toolkit packages. Set `default.enabled` to `false` to search only explicitly configured sources. An entry with a `source` can omit `patterns` to discover all packages from that feed, equivalent to `["*"]`; an explicitly supplied array must be non-empty. Override `default.source` to replace built-in discovery with a different feed, optionally specifying `default.patterns` to restrict its packages.
376+
377+
Each enabled source is searched for matching package IDs, regardless of their namespace. Patterns use case-insensitive NuGet matching: an exact ID, a prefix ending in `*`, or `*` for every package. Other wildcard positions are invalid. Sources may be HTTP(S) NuGet service indexes or local package directories; relative directories resolve against the selected AppHost directory. Embedded credentials are rejected. Configure private-feed credentials using NuGet's existing credential mechanisms.
378+
379+
Named entries referring to the same feed identity are combined before discovery. Their allowed patterns are unioned case-insensitively, disabled entries do not contribute patterns, and the first entry supplies the display name and source spelling. Integration discovery performs one search per feed using the patterns' common prefix (an empty query for unrelated prefixes), following pagination as needed, then filters results against the merged patterns. Local feeds are likewise enumerated once. The implicit built-in discovery entry remains separate.
380+
381+
`aspire add`, `aspire integration list`, `aspire integration search`, and MCP integration discovery use the same configured sources. Stable and prerelease packages are included for custom sources, and the existing deprecated-package and optional polyglot-compatibility filters still apply. The selected source is used for version lookup. Adding a package persists an exact package-to-feed mapping in the AppHost's NuGet configuration, preserving ambient SDK and dependency feeds so subsequent restores can resolve the package. This does not change the AppHost's SDK channel. An explicit `aspire add --source` continues to override discovery.
382+
383+
Sources and their fields merge case-insensitively across global and selected-app settings. As with `skillSources`, `configSource` can supply the entire `integrationSources` section. Materialization replaces a supplied section in full and preserves an omitted section. Refreshing configuration does not add packages or remove previously persisted NuGet mappings.
357384

358385
Keep standard content available initially and preserve explicit user selections and suppression options. Future source-driven content settings must be explicitly added to the allowlist and materialized before they affect acquisition choices. An availability notice never changes those choices. An explicit configuration update does not automatically regenerate applications or reinstall skills. Git access does not grant access to private package feeds, and all acquired content must retain its existing validation and authentication requirements.

‎extension/schemas/aspire-config.schema.json‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,40 @@
306306
},
307307
"additionalProperties": false
308308
},
309+
"integrationSources": {
310+
"description": "Named NuGet integration sources and allowed package patterns, merged across configuration layers.",
311+
"type": "object",
312+
"additionalProperties": {
313+
"type": "object",
314+
"properties": {
315+
"enabled": {
316+
"description": "Whether to search this source. Defaults to true. Disable default to omit built-in channel and NuGet discovery.",
317+
"anyOf": [
318+
{
319+
"type": "boolean"
320+
},
321+
{
322+
"type": "string",
323+
"enum": [
324+
"true",
325+
"false"
326+
]
327+
}
328+
]
329+
},
330+
"source": {
331+
"description": "NuGet service index URL or local package directory. The default entry uses built-in discovery when omitted.",
332+
"type": "string"
333+
},
334+
"patterns": {
335+
"description": "Optional allowed package IDs: exact IDs, prefixes ending in *, or * for all packages. Defaults to * when omitted.",
336+
"type": "array",
337+
"items": {}
338+
}
339+
},
340+
"additionalProperties": false
341+
}
342+
},
309343
"packages": {
310344
"description": "Package references for the AppHost. Value is either a string (NuGet, short form — empty = SDK version, otherwise explicit version) or an object with a required \"source\" discriminator (\"nuget\", \"project\", or \"npm\") and per-source fields (version for nuget, path for project/npm).",
311345
"type": "object",

‎extension/schemas/aspire-global-settings.schema.json‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,40 @@
208208
},
209209
"additionalProperties": false
210210
},
211+
"integrationSources": {
212+
"description": "Named NuGet integration sources and allowed package patterns, merged across configuration layers.",
213+
"type": "object",
214+
"additionalProperties": {
215+
"type": "object",
216+
"properties": {
217+
"enabled": {
218+
"description": "Whether to search this source. Defaults to true. Disable default to omit built-in channel and NuGet discovery.",
219+
"anyOf": [
220+
{
221+
"type": "boolean"
222+
},
223+
{
224+
"type": "string",
225+
"enum": [
226+
"true",
227+
"false"
228+
]
229+
}
230+
]
231+
},
232+
"source": {
233+
"description": "NuGet service index URL or local package directory. The default entry uses built-in discovery when omitted.",
234+
"type": "string"
235+
},
236+
"patterns": {
237+
"description": "Optional allowed package IDs: exact IDs, prefixes ending in *, or * for all packages. Defaults to * when omitted.",
238+
"type": "array",
239+
"items": {}
240+
}
241+
},
242+
"additionalProperties": false
243+
}
244+
},
211245
"language": {
212246
"description": "The language identifier for this AppHost (e.g., \"typescript\", \"python\"). Used to determine which runtime to use for execution.",
213247
"type": "string"

‎extension/schemas/aspire-settings.schema.json‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,40 @@
224224
},
225225
"additionalProperties": false
226226
},
227+
"integrationSources": {
228+
"description": "Named NuGet integration sources and allowed package patterns, merged across configuration layers.",
229+
"type": "object",
230+
"additionalProperties": {
231+
"type": "object",
232+
"properties": {
233+
"enabled": {
234+
"description": "Whether to search this source. Defaults to true. Disable default to omit built-in channel and NuGet discovery.",
235+
"anyOf": [
236+
{
237+
"type": "boolean"
238+
},
239+
{
240+
"type": "string",
241+
"enum": [
242+
"true",
243+
"false"
244+
]
245+
}
246+
]
247+
},
248+
"source": {
249+
"description": "NuGet service index URL or local package directory. The default entry uses built-in discovery when omitted.",
250+
"type": "string"
251+
},
252+
"patterns": {
253+
"description": "Optional allowed package IDs: exact IDs, prefixes ending in *, or * for all packages. Defaults to * when omitted.",
254+
"type": "array",
255+
"items": {}
256+
}
257+
},
258+
"additionalProperties": false
259+
}
260+
},
227261
"language": {
228262
"description": "The language identifier for this AppHost (e.g., \"typescript\", \"python\"). Used to determine which runtime to use for execution.",
229263
"type": "string"

‎src/Aspire.Cli/Commands/AddCommand.cs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,25 @@ bool MatchesIntegrationName((string FriendlyName, NuGetPackage Package, PackageC
376376
}
377377
}
378378

379+
if (source is null && selectedNuGetPackage.Channel.IntegrationSource?.Source is { } integrationSource)
380+
{
381+
// Persist an exact package mapping so later builds can still restore the integration.
382+
// Unlike --source, this keeps ambient feeds available for the SDK and dependencies.
383+
var configuration = _nugetService.BuildConfiguration(
384+
effectiveAppHostProjectFile.Directory!,
385+
"integration-add",
386+
[new PackageMapping(selectedNuGetPackage.Package.Id, integrationSource)],
387+
restrictToSelectedSources: false,
388+
cancellationToken: cancellationToken);
389+
await new DotNetAppHostNuGetConfigMerger(_nugetService).CreateOrUpdateAsync(
390+
effectiveAppHostProjectFile.Directory!,
391+
configuration,
392+
createIfMissing: true,
393+
globalPackagesFolder: null,
394+
confirmationCallback: null,
395+
cancellationToken);
396+
}
397+
379398
context = new AddPackageContext
380399
{
381400
AppHostFile = effectiveAppHostProjectFile,
@@ -479,6 +498,11 @@ bool MatchesIntegrationName((string FriendlyName, NuGetPackage Package, PackageC
479498
string packageId,
480499
string? source)
481500
{
501+
if (channel.IntegrationSource is not null)
502+
{
503+
// Named integration sources select the package feed, not the AppHost's SDK channel.
504+
return null;
505+
}
482506
if (channel.Type is PackageChannelType.Explicit)
483507
{
484508
return channel.Name;

‎src/Aspire.Cli/Configuration/AspireConfigFile.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Aspire.Cli.Agents.AspireSkills;
99
using Aspire.Cli.Documentation.ApiDocs;
1010
using Aspire.Cli.Documentation.Docs;
11+
using Aspire.Cli.Packaging;
1112
using Aspire.Cli.Resources;
1213
using Aspire.Cli.Utils;
1314
using Aspire.Hosting.Utils;
@@ -120,6 +121,10 @@ public string? SdkVersion
120121
[Description("Named agent skill sources, merged across configuration layers. The built-in default source remains enabled unless explicitly disabled.")]
121122
public Dictionary<string, SkillSource>? SkillSources { get; set; }
122123

124+
[JsonPropertyName(IntegrationSourceConfiguration.SectionName)]
125+
[Description("Named NuGet integration sources and allowed package patterns, merged across configuration layers.")]
126+
public Dictionary<string, IntegrationSourceConfiguration>? IntegrationSources { get; set; }
127+
123128
/// <summary>
124129
/// Developer certificate configuration.
125130
/// </summary>
@@ -462,6 +467,7 @@ public static AspireConfigFile FromLegacy(AspireJsonConfiguration? settings, Dic
462467
config.Channel = settings.Channel;
463468
config.Features = settings.Features;
464469
config.SkillSources = settings.SkillSources;
470+
config.IntegrationSources = settings.IntegrationSources;
465471

466472
// Legacy settings.json stored packages as Dictionary<string,string> with an
467473
// ad-hoc ".csproj" suffix for project references. Translate to strong entries.

‎src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Text.Json;
66
using System.Text.Json.Serialization;
77
using Aspire.Cli.Agents.AspireSkills;
8+
using Aspire.Cli.Packaging;
89

910
namespace Aspire.Cli.Configuration;
1011

@@ -80,6 +81,10 @@ internal sealed class AspireJsonConfiguration
8081
[Description("Named agent skill sources, merged across configuration layers. The built-in default source remains enabled unless explicitly disabled.")]
8182
public Dictionary<string, SkillSource>? SkillSources { get; set; }
8283

84+
[JsonPropertyName(IntegrationSourceConfiguration.SectionName)]
85+
[Description("Named NuGet integration sources and allowed package patterns, merged across configuration layers.")]
86+
public Dictionary<string, IntegrationSourceConfiguration>? IntegrationSources { get; set; }
87+
8388
/// <summary>
8489
/// Captures any additional properties not explicitly defined in this class.
8590
/// This ensures settings like "features" are preserved when saving.

‎src/Aspire.Cli/Configuration/ConfigSourceService.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using Aspire.Cli.Commands;
1212
using Aspire.Cli.Git;
1313
using Aspire.Cli.Interaction;
14+
using Aspire.Cli.Packaging;
1415
using Aspire.Cli.Resources;
1516
using Aspire.Cli.Utils;
1617
using Aspire.Shared;
@@ -32,7 +33,7 @@ internal sealed class ConfigSourceService(
3233
ILogger<ConfigSourceService> logger) : IDisposable
3334
{
3435
internal const string MicrosoftRepository = "https://msft.ghe.com/coreai/aspire-1p-init";
35-
private static readonly string[] s_allowedSettings = [SkillSource.SectionName];
36+
private static readonly string[] s_allowedSettings = [SkillSource.SectionName, IntegrationSourceConfiguration.SectionName];
3637
private static readonly TimeSpan s_gitTimeout = TimeSpan.FromSeconds(30);
3738
private readonly Dictionary<ConfigSourceIdentity, ConfigSourceSnapshot> _snapshots = [];
3839
private string? _projectDirectory;

‎src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ public async Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(
4848
configuration,
4949
"Aspire.ProjectTemplates",
5050
prerelease,
51+
SearchTake,
5152
cancellationToken).ConfigureAwait(false);
5253

5354
return packages.Where(package => package.Id.Equals("Aspire.ProjectTemplates", StringComparison.OrdinalIgnoreCase));
@@ -62,6 +63,7 @@ public async Task<IEnumerable<NuGetPackage>> GetIntegrationPackagesAsync(
6263
configuration,
6364
"Aspire.Hosting",
6465
prerelease,
66+
SearchTake,
6567
cancellationToken).ConfigureAwait(false);
6668

6769
return FilterPackages(packages, filter: null);
@@ -76,6 +78,7 @@ public async Task<IEnumerable<NuGetPackage>> GetCliPackagesAsync(
7678
configuration,
7779
"Aspire.Cli",
7880
prerelease,
81+
SearchTake,
7982
cancellationToken).ConfigureAwait(false);
8083

8184
return packages.Where(package => package.Id.Equals("Aspire.Cli", StringComparison.OrdinalIgnoreCase));
@@ -89,10 +92,13 @@ public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(
8992
bool useCache,
9093
CancellationToken cancellationToken)
9194
{
95+
// Allowed integration patterns are applied after searching, so a broad merged query
96+
// must not lose matching packages behind unrelated results on the first page.
9297
var packages = await SearchAsync(
9398
configuration,
9499
packageId,
95100
prerelease,
101+
filter is null ? SearchTake : int.MaxValue,
96102
cancellationToken).ConfigureAwait(false);
97103

98104
return FilterPackages(packages, filter);
@@ -109,6 +115,7 @@ public async Task<IEnumerable<NuGetPackage>> GetPackageVersionsAsync(
109115
configuration,
110116
exactPackageId,
111117
prerelease,
118+
SearchTake,
112119
cancellationToken).ConfigureAwait(false);
113120

114121
// The helper had no exact-match mode. The CLI ran an ordinary search for the package ID and expanded the
@@ -131,12 +138,14 @@ private async Task<List<NuGetPackage>> SearchAsync(
131138
NuGetPackageOperationConfiguration configuration,
132139
string query,
133140
bool prerelease,
141+
int take,
134142
CancellationToken cancellationToken)
135143
{
136144
var results = await SearchClientAsync(
137145
configuration,
138146
query,
139147
prerelease,
148+
take,
140149
cancellationToken).ConfigureAwait(false);
141150

142151
return results.Select(package => new NuGetPackage
@@ -151,14 +160,15 @@ private async Task<IReadOnlyList<NuGetSearchResult>> SearchClientAsync(
151160
NuGetPackageOperationConfiguration configuration,
152161
string query,
153162
bool prerelease,
163+
int take,
154164
CancellationToken cancellationToken)
155165
{
156166
try
157167
{
158168
return await nuGetClient.SearchAsync(
159169
query,
160170
prerelease,
161-
SearchTake,
171+
take,
162172
explicitSources: [],
163173
configuration.ExplicitConfigFile?.FullName,
164174
configuration.EffectiveWorkingDirectory.FullName,

0 commit comments

Comments
 (0)