KoalaSoft.Aspire.Hosting.ServiceSources 0.5.1

dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources --version 0.5.1
                    
NuGet\Install-Package KoalaSoft.Aspire.Hosting.ServiceSources -Version 0.5.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="KoalaSoft.Aspire.Hosting.ServiceSources" Version="0.5.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="KoalaSoft.Aspire.Hosting.ServiceSources" Version="0.5.1" />
                    
Directory.Packages.props
<PackageReference Include="KoalaSoft.Aspire.Hosting.ServiceSources" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add KoalaSoft.Aspire.Hosting.ServiceSources --version 0.5.1
                    
#r "nuget: KoalaSoft.Aspire.Hosting.ServiceSources, 0.5.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package KoalaSoft.Aspire.Hosting.ServiceSources@0.5.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=KoalaSoft.Aspire.Hosting.ServiceSources&version=0.5.1
                    
Install as a Cake Addin
#tool nuget:?package=KoalaSoft.Aspire.Hosting.ServiceSources&version=0.5.1
                    
Install as a Cake Tool

Aspire.Hosting.ServiceSources

NuGet Downloads License: MIT

A .NET Aspire AppHost extension that lets builder.AddService("orders") resolve to a real, running resource whose source is chosen per developer, not baked into the AppHost.

Why

AddProject<T>() assumes a service lives in the AppHost's own solution. In a real microservice environment, services live in separate repositories, and different developers want different things for the same service: clone it locally to edit, run it from an already-checked-out working copy, reach an instance already running in a shared Kubernetes dev cluster, hit a fixed URL, or just run a published container image. The AppHost should only describe what it depends on; where that dependency actually comes from is a per-developer choice, made without ever touching the AppHost's .csproj/.sln.

AddService() is the seam: the AppHost calls it once per service, and a developer-local config file decides how it's actually resolved — a managed or self-managed local git checkout ("local"), a kubectl port-forward against a dev cluster ("kubernetes"), a fixed, already-known URL ("url"), or a published container image run locally ("container") — behind one stable return type, so the AppHost code never has to change when a developer switches sources.

Install

Published on nuget.org as KoalaSoft.Aspire.Hosting.ServiceSources. If every service your AppHost declares is a .NET project, this is the only package you need:

dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources

This package floors Aspire at 13.5.2, so an AppHost still on 13.4.x gets a mixed Aspire family. NuGet takes the highest floor, so Aspire.Hosting is lifted to 13.5.2 while your Aspire.AppHost.Sdk, Aspire.Hosting.AppHost and the DCP and dashboard packages the SDK pins to it stay where they are. Nothing warns about it at restore. Move your AppHost's own Aspire version to 13.5.2 or later at the same time:

<Sdk Name="Aspire.AppHost.Sdk" Version="13.5.2" />

Services that aren't .NET projects need Aspire's hosting package for their language, referenced by your AppHost alongside this one — see Non-.NET local services:

kind Package Minimum
java CommunityToolkit.Aspire.Hosting.Java 13.3.0
javascript Aspire.Hosting.JavaScript 13.5.2
dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources

# then one line per language the AppHost actually declares a service for
dotnet add package Aspire.Hosting.JavaScript              # kind: javascript
dotnet add package CommunityToolkit.Aspire.Hosting.Java   # kind: java

Add one per language you actually use, and nothing for a language you don't. This package compiles against both but declares neither as a dependency, so an AppHost with no javascript service never sees Aspire.Hosting.JavaScript, nor the Aspire floor it carries — which is the point, because a mixed Aspire family restores clean and then throws TypeLoadException on first resolve. The version is yours to choose: pick any release at or above the minimum above, and the two need not match each other.

Forget one and you are told which. Below the minimum, your build fails naming the package and the version it resolved — SERVICESOURCES001 for Aspire.Hosting.JavaScript, SERVICESOURCES002 for CommunityToolkit.Aspire.Hosting.Java. A prerelease of the minimum counts as below it. Missing entirely, the first AddService() for a service of that kind fails with a message naming the package to install — which is the only report a guest-language AppHost gets, since the Aspire CLI hands it this package through a project reference and a project reference imports no build-time checks.

The build-time check also fires for a package that arrived transitively, from a graph your AppHost does not control — where neither raising it nor removing it may be yours to do. Set ServiceSourcesSkipGuestLanguageFloorCheck=true in that project to turn the check off; the version problem is then reported at run time, by the service that needed it.

Or reference the project directly from your AppHost instead:

<ItemGroup>
  <ProjectReference Include="path/to/Aspire.Hosting.ServiceSources/Aspire.Hosting.ServiceSources.csproj" />
</ItemGroup>

Requires .NET 8 or later (net8.0, net9.0, and net10.0 are all supported) and an AppHost project using the Aspire.AppHost.Sdk (aspire new / aspire restore sets this up).

Every release is listed in the changelog, which is where breaking changes and their migrations are recorded. Check it before upgrading — while the version is below 1.0.0, a breaking change can ship in a minor release.

Preview builds

Every push to main publishes a prerelease build (0.x.y-alpha.0.N) to GitHub Packages. Stable releases go to nuget.org only — use those unless you specifically need an unreleased fix. Previews are pruned after each release — only the five most recent are kept — so treat them as disposable and never pin one in a long-lived project.

GitHub's NuGet registry requires authentication for every download, even for public packages — unlike the container registry, it has no anonymous access. This is not a grant on this repository: any authenticated GitHub user can download a public package, so all you need is a token on your own account. It must be a classic personal access token with the read:packages scope; fine-grained tokens are not supported by GitHub Packages.

dotnet nuget add source https://nuget.pkg.github.com/flojon/index.json \
  --name servicesources-preview --username <your-github-username> --password <your-pat>
dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources --prerelease

One package, so one prerelease. The language hosting packages are Aspire's own and come from nuget.org as usual — a preview of this package does not imply a preview of those.

Getting started

1. Declare the service in Program.cs:

using Aspire.Hosting.ServiceSources;

var builder = DistributedApplication.CreateBuilder(args);

var orders = builder.AddService("orders");
var api = builder.AddProject<Projects.Api>("api")
    .WithReference(orders);

builder.Build().Run();

2. Add the shared catalog, servicesources.yaml, next to the AppHost project (commit this file):

services:
  orders:
    repository: https://github.com/example/orders
    project: src/Orders.Api/Orders.Api.csproj
    defaultRef: main          # optional; branch, tag, or commit SHA

(A service that isn't a .NET project also takes a kind — see Non-.NET local services.)

3. Add your own servicesources.local.json next to it (gitignore this file — it's per-developer):

{
  "services": {
    "orders": { "source": "local" }
  }
}

That file is the base layer of the AppHost's own configuration — an environment variable or an appsettings.json entry can override any of it for a single run, without an edit. See Overriding servicesources.local.json.

That's it — running the AppHost now clones orders into <AppHostDirectory>/.servicesources/checkouts/orders/, checks out main, and runs it via Aspire's own project orchestration, wired up to api through service discovery exactly like a project reference would be.

"local" source options

Requires git (2.7 or newer) on PATH for a managed checkout — the same "a tool you already have" trade the "kubernetes" source makes with kubectl. Every git operation runs under your own git, so your credential helper, SSH agent, ~/.gitconfig and proxy settings apply unchanged. A service pointed at your own directory with path needs no git at all.

{
  "services": {
    "orders": { "source": "local" },
    "payments": {
      "source": "local",
      "local": { "path": "/home/dev/code/payments", "ref": "feature/new-checkout" }
    }
  }
}
  • Omit path for a managed checkout: cloned once into <AppHostDirectory>/.servicesources/checkouts/<serviceName>/, and reconciled to the configured ref (or the catalog's defaultRef) on every run. Uncommitted edits are never discarded — if the checkout is dirty and the ref changed, resolution fails loudly instead of overwriting your work. Anything you put at that path yourself that isn't a plain clone — a linked git worktree, or a clone made with --separate-git-dir — is refused with an explanation rather than replaced; point at it with path instead. A directory there with no .git entry at all is treated as debris from an interrupted clone and deleted, so don't hand-place a plain directory as a quick override — use path for that too. The .servicesources/ directory gitignores itself on first use — no need to add it to your own .gitignore — and shields what it holds from your AppHost repository's build settings (see below).

    ref/defaultRef accept a commit SHA, not only a branch or tag — and a SHA is how a team turns "whatever's at the tip the first time each developer clones" into a reviewed checkout, since a resolved service can build and run code the checkout's own repository controls (see SECURITY.md). It costs a catalog edit per bump; that's the actual trade.

  • Set path to point at a checkout you manage yourself (e.g. an existing local clone). It's used as-is — no clone, no checkout, no fetch, ever. A relative path is anchored to the AppHost directory, and must name a directory that already exists. ref cannot be combined with path.

  • Keep the file to the services you actually add — unless you use UseDeferredCheckout(), which removes the reason to. AddService() has to hand back the real resource, so it can't wait until the AppHost has finished composing to find out which services it wants: an entry whose first checkout an AddService() call would have to block on is cloned on the first call, in parallel with the others, before the AppHost has said which ones it wants. Entries you never add cost network and disk for that first clone. The AppHost logs which ones those were at startup — and warns if one of them failed, since nothing else would ever tell you — so you know what to drop.

    Nothing else is speculated over. A checkout that already exists — every service on every run after the first — is resolved only for the services you add, and so is a path override. And a service whose first checkout is deferred is cloned only when you add it: a deferred registration blocks on nothing, so its clone no longer has to be started ahead of demand to run alongside the others. With UseDeferredCheckout() on, a config listing ten "local" services in front of an AppHost that adds two downloads two (#76).

    Either way, only the services you actually add are reconciled to their configured ref: a checkout that already exists is never touched on behalf of an entry you don't AddService(), so work in progress on a branch there is safe.

prepare: a checkout that has to bootstrap itself

A managed checkout is assumed to be runnable the moment it is cloned. That holds for a dotnet project, and for a Maven/Gradle service whose wrapper builds it as part of running — but not for a repository whose runnable artifact or data asset is produced by a script the repository commits and then gitignores. Such a checkout resolves cleanly and then fails, because the thing the catalog names isn't there.

prepare is a command run inside the materialized checkout, before the kind is allowed to judge it:

services:
  routing:
    repository: https://github.com/example/routing
    kind: java
    prepare:
      command: ["./prepare.sh"]
      windowsCommand: ["pwsh", "-File", "prepare.ps1"]   # optional; replaces command on Windows
      mode: oncePerCommit                                # the default | once | always | never
    java:
      jarPath: graphhopper-web-11.0.jar
      args: ["server", "gh-config-local.yml"]
      port: 8989
  • command is a list, not a string. There is no shell, so there are no quoting or word-splitting rules to get wrong and an argument containing spaces needs no escaping. The first element is either a path inside the checkout (./prepare.sh, scripts/bootstrap) or a bare program name resolved through PATH (make, npm, bash). A path is confined to the checkout: an absolute one, or one that climbs out with .., is rejected by name — the same rule java.jarPath follows, and for the same reason, since the catalog is shared configuration you clone rather than write.

  • windowsCommand replaces command when the AppHost runs on Windows. It exists because one catalog is committed and shared by a team across platforms, so each value can only be spelled one way, and ./prepare.sh isn't executable on Windows. Leave it out for a program that exists as an executable everywhere (make, python, dotnet) — the command runs there unchanged.

    npm is not one of those, and it's the case to know about. There is no npm.exe, only npm.cmd; nothing here goes through a shell, and Windows resolves a bare name on PATH by appending .exe rather than by walking PATHEXT. So ["npm", "ci"] starts fine on Linux and macOS and fails to start on Windows, and wants windowsCommand: ["npm.cmd", "ci"] beside it. The same goes for yarn, pnpm and tsc. The launch failure names this as the likely cause.

  • The command runs with the checkout as its working directory, with no shell between it and the tool, and both its streams are relayed line by line as they arrive, tagged with the service. A tool that prints a progress meter floods this — a plain curl download, for one — since every carriage-return-terminated update becomes its own line; prefer curl -sS or --no-progress-meter (or the equivalent for whatever tool the command runs):

    [prepare routing] Downloading graphhopper-web-11.0.jar...
    [prepare routing] Importing sweden-latest.osm.pbf
    [prepare routing] done in 4m12s
    

    On the run that creates the checkout under UseDeferredCheckout() those lines are the service's own resource log, visible in the dashboard, and the service shows a Preparing state while the step runs — which is what a four-minute import needs, so that it reads as an initialization phase rather than as a hang. Every other run reports to the AppHost's standard output: under dotnet run that is your terminal, and under aspire run the CLI relays it, live, into its own log under ~/.aspire/logs/ rather than printing it. A capped copy — its first and last lines, with anything in between marked as elided — also lands in the same resource log once the dashboard exists, so the record ends up where the service is even on a run the console alone would have hidden it from.

  • A non-zero exit fails the service, naming it, the resolved command, the exit code and the tail of the output. During composition that fails the AppHost, exactly as a bad repository or a missing project does; on a deferred first run it costs that one service and nothing else.

  • Ctrl-C stops it. On a deferred first run the shutdown signal reaches the command's own process tree, so interrupting a long import ends the import rather than leaving it — and its children — running with no AppHost left to belong to. There is no timeout: a legitimate bootstrap can take an hour, and there is no defensible default to cut it off at.

  • aspire publish doesn't run it. Publish composes the model, writes the manifest and exits, and a bootstrap produces what a service needs in order to run — so a manifest doesn't depend on it, and paying a multi-gigabyte download on every CI publish to emit one would be nothing but cost. The block is still validated there, so a typo'd mode or a command pointing outside the checkout still fails a publish. The skip is reported, because it has one consequence worth naming: the step runs before the kind judges the checkout, so a service whose committed files aren't enough for its kind on their own — a generated .csproj, a generated project directory — is reported as missing them. Run the AppHost once to materialize the checkout, then publish.

Choosing a mode. All four answer one question — how often does this run — and the guards nest:

Mode Re-runs when For
oncePerCommit (default) the command changes, or the checkout moves to another commit a bootstrap defined by a script the repository commits
once the command changes an expensive bootstrap independent of the commit
always every start an incremental script that decides its own work
never opting out of a step the catalog declared

"the checkout moves to another commit" is more concrete than it sounds. A warm managed checkout is never fetched or moved on your behalf — the commit only moves when you move it — so under the default oncePerCommit, git pull in a service checkout is what causes prepare to run again on the next AppHost start. That's an ordinary, frequent action, and nothing about running it reads as "approve a script to run on my machine"; see SECURITY.md for why that's worth knowing rather than just worth stating.

The once vs oncePerCommit question is "does the repository define this step?" rather than "how often" — and the test is where the version is written, not where the expensive part is.

The example above is the default for that reason, even though its java.jarPath looks pinned. prepare.sh is committed in that repository and hardcodes the GraphHopper version inside itself, so a team bumping it to 12 moves the commit while the catalog's command stays ["./prepare.sh"]. Under once the marker would never invalidate, and every developer would keep serving the 11 jar until someone thought to delete a marker they have never heard of. The catalog's jarPath naming a version is what makes once look right here; the download is pinned in the script.

Reach for once when the command itself carries everything that decides what it produces, so a commit cannot change the answer — ["./fetch-model.sh", "--release", "v4.2"], or a step whose whole input is a URL the catalog spells out. Then a developer committing a one-line README fix shouldn't pay the download, and under once they don't. The default errs the other way on purpose: an unexpected re-run is annoying and immediately visible, where a stale artifact is invisible and surfaces later as a confusing runtime failure.

The command must be safe to re-run. Under always that's self-evident. Under the two guarded modes it is equally required and less obvious: the completion is recorded only on success, so a step that fails halfway runs again from the beginning on the next start, against a checkout that already holds whatever the first attempt managed to produce. A command that can't tolerate that is incorrect under every mode.

Nothing here detects that a step's outputs are stale — at most that the checkout moved. That is deliberate: nothing in the catalog says what your script reads and writes, hashing the working tree is expensive and answers wrongly in both directions, and re-running whenever the tree is dirty would pay the full bootstrap on every start for exactly the developer "local" exists to serve. Incremental rebuild is a solved problem with real dependency graphs behind it — make, Gradle, MSBuild, npm ci — so mode: always with a command that guards itself delegates to them:

[ -f graphhopper-web-11.0.jar ] && [ -d data ] && exit 0

How completion is recorded, and how to force a re-run. A managed checkout keeps a marker at <checkout>/.git/servicesources-prepare.json, holding a hash of the resolved command and the commit it ran against. Inside .git deliberately: it's invisible to the service repository's git status, so it can't be committed by accident, and it dies with the checkout — so a deleted-and-recloned checkout re-prepares even at the same commit. Delete the file to force a re-run. Each service's checkout keeps its own, so two services from one repository each pay for their own bootstrap; that is correct rather than merely tolerable, since a jar downloaded into one clone is not in the other.

Every decision to run says why — no completion recorded, the command changed, the commit moved, the commit couldn't be determined, or the mode is always. A decision to skip says nothing: that's the ordinary case, and the marker already records it.

A path checkout declares its own step. A service resolved through local.path never inherits the catalog's prepare block. Nothing establishes that your directory is even a checkout of the repository the catalog names — path is validated by "does it exist" and nothing else — so a catalog command like ["npm", "ci"] would run perfectly happily in a tree that has nothing to do with it. And it's your working tree, holding your in-flight work, where a repository's own bootstrap script is entitled to run git clean. So the command that runs there has to be one you wrote:

{
  "services": {
    "routing": {
      "source": "local",
      "local": {
        "path": "/home/dev/code/routing",
        "prepare": { "command": ["./prepare.sh"] }
      }
    }
  }
}

A catalog block on such a service is ignored, not rejected — it's the team's field and applies correctly to every developer on a managed checkout, so your local override must not turn it into a failure. Instead a notice at startup names the service and the command that was not run, verbatim, so you can paste it into the file above. It repeats on every start until you declare a block of your own, and any declared block silences it — including { "prepare": { "mode": "never" } }, which is how you say that nothing should run there. (A mode with no command is otherwise rejected on a path service: you'd have written the half of the block that can't stand alone, and there is no catalog command for it to apply to.) The marker for such a checkout lives in the tool's own tree, at <AppHostDirectory>/.servicesources/prepare/<service>.json, keyed on the resolved path as well as the command — writing into a directory this tool doesn't own is the one thing path promises never to happen.

This notice is not a general consent rule, and it's easy to read it as one. It exists because a path checkout is your working tree — the axis it protects is blast radius, not letting a catalog command mutate a directory this tool doesn't own. A managed checkout under .servicesources/checkouts/ is exactly the opposite: fully tool-owned, so nothing here asks before its prepare step (or the dotnet/javascript/java build that follows it) runs the first time — yet that script is foreign code in the sense that matters for SECURITY.md: written and reviewed by the service repository, not by you. Meeting this notice on a path service is not a sign that a managed one asks first too.

Overriding a catalog step per developer. Your block is merged over the catalog's per field, with one exception: mode overrides on its own, and command/windowsCommand are replaced together if you supply either. Splitting the pair is never what anyone means — you'd run your own command on Linux and the team's on Windows.

You write in servicesources.local.json Effect
{"mode": "never"} the catalog's step is disabled; nothing runs
{"mode": "always"} the catalog's command runs on every start
{"command": ["make", "bootstrap"]} the catalog's command and windowsCommand are both replaced, so make runs on Windows too. Mode kept
{"command": [...], "windowsCommand": [...]} both replaced, mode kept
(absent) the catalog's block stands

A block with no catalog block behind it stands on its own: you may introduce a step the catalog never declared.

Two steps can run at once. Eagerly-resolved services prepare one after another, because AddService() is serial. Deferred ones don't: UseDeferredCheckout() gives each service a task of its own precisely so that one slow checkout isn't the start of every other, and serializing the step inside it would put that coupling straight back. So a prepare command has to tolerate running alongside a different service's command. What those two share is the machine and whatever package caches they use — never a working tree, since managed checkouts are per-service clones and the one arrangement that shares a tree (two services on one path) never defers. Most caches are built for that (~/.nuget/packages, npm's); a Maven local repository is not, so a step that resolves into ~/.m2 and must not overlap with another should take its own lock. A service never prepares concurrently with itself.

What this deliberately isn't. One command, one marker, per service: no task runner, no ordering between steps, no caching of produced artifacts across developers, no timeout (Ctrl-C works on a deferred first run, and a country-sized routing graph has no defensible default) and no injected environment variables. prepare also belongs to the "local" service source; the "local" source a backing service can have means something else entirely, with no repository and so nothing to bootstrap.

Aspire builds a checkout, on every start

Nothing in this package compiles a checkout, and nothing needs to. A dotnet service is registered with Aspire's own AddProject, and Aspire launches that resource with dotnet run, whose working directory is the checkout itself. The build you would otherwise have to arrange is that command's own implicit incremental build.

So a checkout cloned for the first time compiles when the resource starts — a cold clone with no bin/ needs nothing done to it first — and a checkout whose ref you change is recompiled on the next run rather than served from the previous ref's binaries. That last one is worth stating outright, because the failure it doesn't have would be a quiet one: a service answering with code you moved away from.

Two things to know when it goes wrong:

  • The compiler's output isn't in the AppHost's console. It goes to that resource's console in the dashboard, like any other project resource, so the reason a checkout wouldn't compile is one click away rather than in the terminal you launched from. What the AppHost's console does say is that the service isn't running (#150):

    fail: Aspire.Hosting.ServiceSources[0]
          Service 'orders' is configured as 'local' and its resource is not running: it reported
          'Finished' with exit code 1. This console does not carry that resource's output, so
          nothing here says why — its own console in the Aspire dashboard does, at the dashboard
          URL logged above. A 'local' service runs from a checkout rather than from a project
          added to this AppHost, and the build of that checkout writes to those same logs — so a
          failure to compile is reported nowhere else at all.
    

    One line per failing resource instance, for every source rather than "local" alone, whenever it reports FailedToStart or ends with a non-zero exit code. A replicated service gets one line per replica that failed, naming which one and its own exit code; an unreplicated one gets a single line and no instance id.

    It errs towards saying nothing rather than crying wolf, because a channel that sometimes lies is one you learn to ignore — which is the problem it exists to fix. So none of these are reported: a terminal state whose exit code was never reported, an orderly Ctrl-C, a resource you stopped yourself from the dashboard, and RuntimeUnhealthy — the last says your container runtime is unreachable, not that this service failed, and an AppHost started before Docker has finished booting reports it for every container-backed service and then starts them all normally once the runtime answers. A service you restart and that fails again is reported again. The line says only that the service isn't running and where to look: what went wrong belongs to the process Aspire launched, whose output this package doesn't own. On a run with no dashboard — a DistributedApplicationTestingBuilder host, or an AppHost that turned it off — the line points at the resource's own logs instead of naming a dashboard that isn't there.

    "local" is where this matters most, and why it was asked for there. You never added the project — you wrote a name in servicesources.local.json — and you didn't choose where its code lives, so a resource that quietly fails to appear is one you may not know to look for. The same reasoning already covers a clone that fails for a service nothing waits on; this is the step after it.

  • Two path services in one repository can collide. If both point into the same repository and their projects share a ProjectReference, Aspire starts both at once, and two builds write that shared project's bin//obj/ simultaneously — which fails intermittently, with an MSB4018 or CS2012 naming a file "being used by another process" (microsoft/aspire#15190). Managed checkouts can't hit this: each service gets its own clone under .servicesources/checkouts/<serviceName>/, so there is no shared output directory even when two services come from one repository.

Launching the AppHost from an IDE is the one case this doesn't cover. An IDE that starts project resources itself, to attach a debugger, builds them the way it builds anything else — and a project reached by a path isn't in your solution, so it may not be built at all (microsoft/aspire#2154, open upstream).

First run: UseDeferredCheckout()

On a cold clone, AddService() blocks until the checkout it needs is on disk. Composition hasn't finished, so the AppHost hasn't started, so there is no dashboard to look at while several repositories clone — and a checkout that fails throws out of composition and takes the whole AppHost down with it, including the services that were fine.

builder.UseDeferredCheckout() moves that wait past startup for the case where it hurts: a "local" service whose managed checkout doesn't exist yet. The resource is registered against the path its checkout will have, held back with Aspire's own explicit-start behaviour, cloned while the AppHost runs, and started when its checkout lands:

var builder = DistributedApplication.CreateBuilder(args);

builder.UseDeferredCheckout();

var orders = builder.AddService("orders").WithHttpEndpoint();

The dashboard comes up immediately, checkout progress and failure become resource state you can see, and one bad clone costs one service instead of the run. The clones stay parallel: a deferred service's clone starts at its own AddService() call and blocks nobody, so several of them still run at once — the wall-clock is the slowest clone, not the sum. The one thing that still clones in turn is a third-party kind handler that declares deferral support and then declines it for a particular service; the built-in dotnet, java and javascript kinds never do. If you maintain a kind of your own, Implementing a kind covers the two members that opt into this and what declining late costs.

It also stops the AppHost downloading repositories it doesn't use. Without deferral the clones have to start before the AppHost has said which services it wants, so every "local" entry with no checkout yet is cloned; a deferred one is cloned only when it is added (#76).

The wait is one you can watch. git's own progress becomes the service's state — the phase it is in, that phase's percentage, and the bytes transferred while a pack is arriving (Receiving objects 48% · 18.54 MiB) — with every line git writes going to the service's console logs as it arrives. A failure lands in the same two places, plus one line in the AppHost's own console saying that service isn't running — the same line a checkout that won't compile gets, because both are read off the resource's state rather than off any one failure path. Nothing appears for a repository small enough that git reports nothing, which is normal rather than a sign of a stall.

What a cold checkout costs, and what it doesn't. This part is about the dotnet kind. The java and javascript kinds have no launch profile and read nothing out of the repository while composing, so deferral costs them nothing at all — skip to Scoped deliberately narrowly below. (One javascript exception, covered there: appType: node and appType: bun are deferred only when the catalog guarantees a package.json.)

Aspire reads a project's launch profile while composing the AppHost and turns it into endpoints, environment variables and command-line arguments there and then. A deferred service has no repository on disk at that point, so all three come out empty — and nothing re-runs the step.

Environment is put back for you. Once the clone lands, the profile's environmentVariables are applied to the resource before it starts, and only where the AppHost hasn't already set the same key, so WithEnvironment and WithReference still win. That matters more than it sounds: Host.CreateDefaultBuilder takes the environment name from DOTNET_ENVIRONMENT, which most repositories set in the launch profile and nowhere else, so without this a deferred service runs as Production while every warm run of it runs as Development.

Values are expanded, and the service's own DOTNET_LAUNCH_PROFILE is set to the profile it was started under — both as Aspire does them on a warm run. The profile read is whichever one Aspire itself will select, which is the same selection it makes for the service's command-line arguments once the checkout has landed: the profile your AppHost was launched under, when the service has one by that name, and otherwise the first launchable profile in the file. So the process never ends up with one profile's environment and another's arguments.

Endpoints can't be, because ports are allocated during composition and the spec is frozen. So a deferred service carries only the endpoints you declare:

var orders = builder.AddService("orders").WithHttpEndpoint();

You are not asked for that line up front, and a service that doesn't need it isn't refused — a run-to-completion worker has no applicationUrl on either path, so demanding one would mean declaring an endpoint it never listens on. Instead the real launch profile is read once the checkout lands and the shortfall is reported then, quoting the applicationUrl it actually found: the project still binds that URL itself and runs, but Aspire allocated no endpoint, so the port isn't moved off a collision, nothing proxies it, service discovery can't resolve the service and the dashboard won't link it. Add the line and the next run is whole; it is correct on a warm checkout too, where it updates the endpoint the profile already created rather than adding one.

Scoped deliberately narrowly, so the blast radius is first-run-only:

  • Only a checkout that doesn't exist yet. A warm checkout — every run after the first — takes the existing eager path unchanged, with full launch-profile fidelity.
  • Only managed checkouts. A path override is your own directory; there is nothing to clone.
  • Only the "local" source, and within it only the kinds that own a managed checkout: dotnet, java and javascript. The other sources — url, kubernetes and container — never clone a repository, so they have nothing to defer.
  • Only run mode. aspire publish and manifest generation clone first as they always have; a manifest written from a repository that isn't on disk would describe a project without its endpoints or its profile environment.

The java and javascript kinds get the same treatment for free, and without the endpoint caveat above: java requires port in its kind block, and a javascript service always gets an http endpoint with a port Aspire allocates when the block doesn't name one. Both come from the committed catalog, so a deferred java or javascript service is identical to a warm one. The checks that do need the working tree — workingDirectory and the mvnw/gradlew wrapper for java, appDirectory/package.json/scriptPath for javascript — simply move to just after the clone, which is where the docs already said they happened. For javascript, the separate resource that runs npm install is held back with the app and started ahead of it.

appType: node and appType: bun are the one exception, and they opt out rather than guess. Aspire's AddNodeApp/AddBunApp attach a package manager — and with it the npm install resource the app waits on — only if they can see a package.json in the app directory, so what a warm run builds depends on what the repository holds, and a checkout that hasn't landed can't be looked at. They are deferred only where the answer is already known: runScript is set (which requires a package.json anyway), or packageManager names one. Otherwise that one service resolves eagerly, exactly as it does without UseDeferredCheckout(). Every other appType runs a package.json script by definition and is deferred unconditionally.

Off by default: a service that used to be running by the time Build() returned is started after it instead, which is visible to anything in your AppHost that assumed otherwise. Call it before your first AddService(), which is where the decision is made.

Managed checkouts don't inherit your AppHost repository's build settings

A managed checkout is cloned inside your AppHost's repository, and MSBuild, NuGet, the .NET SDK host and the compiler's analyzer configuration all find their settings by walking up from each project or source file. Left alone, that means another team's repository gets built under rules written for yours — most visibly as NU1008 on every pinned PackageReference when your repository turns on central package management, and least visibly as your packageSourceMapping confining that repository's restores to your feeds (a leak that hides behind a warm ~/.nuget/packages and only surfaces on a clean machine or in CI).

So alongside the .gitignore, .servicesources/ gets six tool-managed files, plus an empty .mvn directory, that end those walks there:

File Content Stops
Directory.Build.props, Directory.Build.targets <Project /> your repository's build customisation
Directory.Packages.props ManagePackageVersionsCentrally=false your repository's central package management
nuget.config <packageSourceMapping><clear /></packageSourceMapping> your repository's package source mapping (see the note below)
.editorconfig root = true your repository's code style and analyzer severities
global.json {} your repository's SDK pin and msbuild-sdks versions
.mvn/ empty directory your repository's .mvn/maven.config, jvm.config and extensions.xml, and Maven's own root-directory detection, for a jar-run Java service (one without its own mvnw, which already stops the walk at the checkout)

Each of the six files is written with a comment saying what it is and why it's there, since you'll find them on disk with no git history to explain them, and all six are rewritten whenever their content is out of date, so upgrading the package updates them. .mvn/ is the exception: Maven only asks whether the directory exists, so there is no content to write a comment into or to keep up to date — the directory is created once and then left alone, including anything Maven or a developer later adds inside it (a maven-wrapper.properties for a service that grows its own mvnw, for instance).

Each barrier drops a constraint the checkout never opted into, and only supplies what a checkout lacks. A checkout carrying its own Directory.Build.props, Directory.Packages.props, .editorconfig, global.json or .mvn/ is found first and keeps its own settings, including central package management if that's how that repository builds.

Four of these are worth a note:

  • .editorconfig needs its own barrier rather than riding on the Directory.Build.props one, because analyzer severity written as dotnet_diagnostic.<id>.severity = error comes from the .editorconfig itself — not from EnforceCodeStyleInBuild or TreatWarningsAsErrors. Without it, your repository's code style raises the checkout's own analyzers to errors.

  • global.json has two halves that resolve from different anchors, so the barrier covers one of them completely and the other conditionally. msbuild-sdks resolves by walking up from the project, so it is stopped outright. sdk.version resolves by walking up from the current working directory, so it is stopped only for a build or run launched from inside the checkout — which is the working directory Aspire gives a project resource. A build you launch with the AppHost directory as its working directory still sees your repository's SDK pin.

  • nuget.config is the one barrier that isn't purely permissive, and the one that doesn't stop the walk. NuGet merges every config from the drive root down rather than stopping at the nearest, so this file can only override the section it names. It names packageSourceMapping, and NuGet's <clear /> discards every mapping accumulated before it — your user-level ~/.nuget/NuGet.Config and machine-level ones included, not just your repository's. Inside a checkout, package source mapping is therefore off unless the checkout brings its own, while every inherited source stays reachable; a package that reaches your global packages folder that way is then served from it to restores that do have mapping in force, including your AppHost's own, because that folder isn't itself subject to mapping.

    The default is this way round because a mapping the checkout was never written against fails its restore outright, naming a source rather than the inherited rule behind it. If you'd rather keep the mapping enforced inside checkouts and deal with those failures, set SERVICESOURCES_KEEP_PACKAGE_SOURCE_MAPPING=1: the file isn't written, an existing one is removed, and the other five barriers are unaffected.

  • The rest of your nuget.config still reaches checkouts for the same merging reason — your packageSources, disabledPackageSources, packageSourceCredentials and config sections among them. A repository that clears packageSources and adds only its own feed — the most common customisation there is — therefore still restricts what a checkout can restore, and like the mapping leak it hides behind a warm ~/.nuget/packages. Clearing packageSources from here isn't the answer: a checkout that legitimately needs your private feed would stop building.

Two upward searches are deliberately left alone, because neither has a neutral value that isn't also a decision: Directory.Build.rsp (MSBuild takes the first one found walking up from the project, so your repository's response-file arguments still apply) and .config/dotnet-tools.json (which affects dotnet tool run inside a checkout). Open an issue if either bites you.

Some JavaScript and Gradle leaks can't be barriered

The barrier pattern above only works for a tool that stops at the nearest file it finds while walking up. Several of the JavaScript and Gradle mechanisms a javascript or java service depends on don't work that way, and no file placed in .servicesources/ fixes them:

Mechanism Why a barrier doesn't work
npm's .npmrc Not actually a leak: npm's local config comes from the closest ancestor holding package.json or node_modules, which is the checkout itself, so your repository's .npmrc never reaches it.
pnpm's pnpm-workspace.yaml The nearest ancestor wins, so it looks barrierable — but a packages: [] file at .servicesources/ doesn't terminate the walk the way an empty Directory.Build.props does. It makes .servicesources a workspace root with zero matching projects, and pnpm install inside the checkout then reports Scope: all 0 workspace projects, exits 0, and installs nothing — a silent no-op that is worse than the leak it would replace. Neither a checkout-level .npmrc (ignore-workspace=true) nor the equivalent environment variable changes this; only the --ignore-workspace CLI flag does, and this tool does not control how the install command is invoked.
Yarn Berry's .yarnrc.yml Yarn merges rcfiles from the cwd and every ancestor rather than stopping at the nearest one, and there is no root: true equivalent to end the merge. Neutralizing it would mean enumerating every setting (yarnPath, npmRegistryServer, nodeLinker, npmScopes, …) and re-stating a default for each.
Node's node_modules resolution Node consults every ancestor's node_modules in turn; an empty directory does not stop the search the way an empty file stops MSBuild. A dependency your repository happens to have installed above the checkout can resolve into a service that never declared it — passing on your machine and failing in the service's own CI.
Gradle's settings-file search Searched in the cwd and every ancestor up to the filesystem root, stopping at the first hit — so a single-project repository carrying gradlew and build.gradle but no settings.gradle is captured by your repository's. A barrier gains nothing here: Gradle reports "not part of the build defined by settings file … must have its own settings file" regardless of what a barrier file said, so the failure is loud and already names the fix.

Gradle is the one loud failure here, naming its own fix. The other three are not: Yarn silently applies whatever your repository's .yarnrc.yml says (a registry, a linker mode, a scope) with no error at all; node_modules silently resolves a dependency the checkout never declared, the same "works on your machine, fails in CI" shape as the NuGet gap above; and pnpm's is the quietest of all — a full install that reports success while installing nothing. Open an issue if one of them costs you real time.

Several services from one repository

A catalog entry maps one service to one thing to run, so a repository holding several services gets one entry per service — each naming the same repository, and each selecting its own part of the tree (project for the default dotnet kind, or the kind's own options block, such as appDirectory, for the kinds below):

services:
  orders:
    repository: https://github.com/example/monorepo
    project: src/Orders.Api/Orders.Api.csproj
    defaultRef: main
  payments:
    repository: https://github.com/example/monorepo
    project: src/Payments.Api/Payments.Api.csproj
    defaultRef: main

The catalog is the same either way; what differs is how many checkouts of that repository end up on your machine, which each developer chooses in servicesources.local.json:

  • One managed checkout per service — omit path. Managed checkouts are keyed by service name, so orders and payments each get their own independent clone of the repository, at .servicesources/checkouts/orders/ and .servicesources/checkouts/payments/. Each can sit on its own ref and neither can disturb the other, but the repository is cloned once per service, and an edit to shared code in one checkout is invisible to the other.

  • One checkout shared by every service — set path. Clone the repository yourself, then point each service at that same directory; the entry's project (or appDirectory) is resolved relative to it:

    {
      "services": {
        "orders":   { "source": "local", "local": { "path": "/home/dev/code/monorepo" } },
        "payments": { "source": "local", "local": { "path": "/home/dev/code/monorepo" } }
      }
    }
    

    This is usually what you want when the services share code: one clone, one branch, and an edit to a shared project is picked up by every service at once. The trade-off is that the clone is yours to manage — nothing is ever cloned, fetched or checked out on your behalf — and local.ref cannot be combined with local.path.

Mixing the two is fine: services you're actively editing can share one path checkout while the rest stay on managed clones.

Non-.NET local services: kind

A "local" service is resolved as a .NET project by default. Set kind in the catalog to run the checkout some other way — the git clone/checkout is identical, only what gets built out of the resulting directory changes:

services:
  frontend:
    repository: https://github.com/example/frontend
    kind: javascript          # optional; defaults to "dotnet"
    javascript:               # per-kind options block, named after the kind
      appDirectory: .
      runScript: dev

kind: dotnet (the default) uses the entry's project property and needs no options block. project is required for that kind, and is a path relative to the service's checkout that must stay inside it — the rule java.jarPath and java.workingDirectory follow below, and for the same reason: the catalog is shared configuration you clone rather than write, so it does not get to name a project elsewhere on a developer's machine. An absolute path (/srv/Api.csproj, C:\repos\Api.csproj) or one that climbs out (../../shared/Api.csproj) is refused at that service's AddService() call, before its checkout is used. A project in another repository gets an entry of its own, naming that repository. The javascript keys are confined to the checkout too, by a check of their own that judges the resolved path rather than the written one. Any other kind is resolved by a registered handler, and its options live in a block named after the kind. Kind names are matched case-sensitively, and a kind with no registered handler fails at that service's AddService() call, before its checkout is used.

JavaScript: kind: javascript

Runs the checkout through Aspire.Hosting.JavaScript, which your AppHost references itself (13.5.2 or newer — see Installation). Reference it, then call UseJavaScript() once, before the first AddService() call:

using Aspire.Hosting.ServiceSources;

var builder = DistributedApplication.CreateBuilder(args);

builder.UseJavaScript();

var frontend = builder.AddService("frontend");
services:
  frontend:
    repository: https://github.com/example/frontend
    kind: javascript
    javascript:
      appType: vite         # javascript (default) | vite | nextjs | node | bun
      appDirectory: web     # directory holding package.json, relative to the repo root
      runScript: dev        # package.json script to run
      packageManager: pnpm  # npm | yarn | pnpm | bun
      port: 4321            # the port consumers reach the service on

Keep Aspire.Hosting.JavaScript on the same version as Aspire.Hosting. Aspire releases the two together and tests them that way. They were also coupled across a friend-assembly boundary until 13.5.0: Aspire.Hosting.JavaScript 13.4.6 against Aspire.Hosting 13.5.x restores and compiles clean, then throws MethodAccessException the first time a kind: javascript service resolves. This package floors both at 13.5.2, so you get a matched pair by default. If you raise Aspire.Hosting past that on its own, add a reference at whatever version your AppHost resolves for it — the version below is an example, not a version to copy:

<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.5.3" />

Every option is optional:

  • appType — which integration runs the app: javascript (the default, AddJavaScriptApp), vite, nextjs, node, or bun. node and bun execute a file directly rather than a package.json script, so they require scriptPath; the other three run a script and reject it.
  • appDirectory — the directory holding the app's package.json, relative to the repository root, which is also the default. It must stay inside the checkout, and — for every app type that runs a package.json script — it is checked to actually hold one, so pointing it at the wrong directory of a monorepo is reported against the service rather than surfacing later as an npm could not read package.json.
  • runScript — the package.json script to run; the integrations default this to dev. For node/bun it overrides the scriptPath they would otherwise execute directly, which needs a package.json in appDirectory — without one those two app types run scriptPath and nothing else, so a runScript set there is rejected rather than silently ignored.
  • scriptPath — the entry-point file (e.g. server.js) relative to appDirectory. Required by appType: node and appType: bun, and rejected for the others. Like appDirectory it must stay inside the checkout, and it is checked to exist so a typo is reported against the service rather than surfacing later as a cannot find module crash.
  • packageManagernpm, yarn, pnpm, or bun, used to install dependencies before the app starts (a fresh clone has no node_modules). Left unset, the integration's own default applies: npm for most app types, Bun for appType: bun.
  • port / targetPort — the port consumers reach the service on, and the port the app itself listens on. Both are allocated by Aspire when unset.
  • portEnv — the environment variable the app reads its listen port from; defaults to PORT. Rejected for vite/nextjs, whose integrations bind the dev server's port themselves.

The service always gets an http endpoint, so the builder AddService() returns can be passed to a consumer's WithReference(...) like any other — or to GetServiceEndpoint(), which is how a consumer names that endpoint without knowing which source produced it (naming a service's endpoint). Node and Bun must be on PATH for the app types that use them.

Java: kind: java

Runs the checkout through the .NET Aspire Community Toolkit's Java integration, which your AppHost references itself as CommunityToolkit.Aspire.Hosting.Java (13.3.0 or newer — see Installation). Reference it, then call UseJava() once, before the first AddService() call — AddService() resolves eagerly, so a kind: java service registered after it has already run has nowhere to look up its handler:

using Aspire.Hosting.ServiceSources;

var builder = DistributedApplication.CreateBuilder(args);

builder.UseJava();

var catalog = builder.AddService("catalog");

servicesources.yaml:

services:
  catalog:
    repository: https://github.com/example/catalog
    kind: java
    java:
      mavenGoal: spring-boot:run
      port: 8080

The checkout is cloned exactly as for any other "local" service (path, ref, and defaultRef all behave identically), then handed to that integration to run.

java: block options

Field Required Description
mavenGoal one of these three Run via the Maven wrapper, e.g. spring-boot:run.
gradleTask one of these three Run via the Gradle wrapper, e.g. bootRun.
jarPath one of these three Run a pre-built jar with java -jar, relative to workingDirectory. May climb out of it — a monorepo's shared build output directory — but must stay inside the checkout.
port yes The port the app listens on. Becomes the service's HTTP endpoint, so consumers can WithReference(...) or GetServiceEndpoint() it.
workingDirectory no (defaults to the repository root) Where in the checkout the project lives — the directory holding pom.xml / build.gradle, and by default the mvnw/gradlew wrapper too. Must stay inside the checkout.
wrapperPath no (defaults to the wrapper in workingDirectory) Where the mvnw/gradlew wrapper script lives, relative to the repository root — for the monorepo that commits a single wrapper at its root while the service itself sits further down. Name it without an extension (gradlew, not gradlew.bat) and it works for the whole team: on Windows the .cmd/.bat wrapper beside it is the one run. Only meaningful with mavenGoal or gradleTask.
args no Extra arguments for whichever run mode is configured — passed to the Maven wrapper, the Gradle wrapper, or the jar.

mavenGoal, gradleTask, and jarPath are mutually exclusive: exactly one must be set. A monorepo service, running a Gradle task with an extra argument:

services:
  catalog:
    repository: https://github.com/example/monorepo
    kind: java
    java:
      workingDirectory: services/catalog
      gradleTask: bootRun
      wrapperPath: gradlew
      args: ["--args=--spring.profiles.active=dev"]
      port: 8080

A multi-project Gradle repository (like a multi-module Maven one) commits a single wrapper at its root rather than one per project, which is what wrapperPath: gradlew names here — without it the wrapper is looked for in services/catalog, beside the project.

mavenGoal and gradleTask run the repository's own mvnw/gradlew wrapper, so a JDK must be on the developer's machine but Maven/Gradle itself need not be. That wrapper has to be in the checkout — there is no fallback to a system-wide mvn/gradle — so a checkout without one is reported as such, rather than left to surface as a failure to start the app. On Windows the wrapper run is mvnw.cmd/gradlew.bat, whether it was found by default or named by wrapperPath: the extensionless scripts beside them are POSIX shell scripts that Windows cannot exec.

Every problem with the block — unknown properties, a missing or out-of-range port, no run mode or more than one, a workingDirectory, wrapperPath or jarPath escaping the repository, a wrapperPath set alongside jarPath, a workingDirectory that isn't in the checkout, a wrapper script that isn't there — is reported by the AddService("catalog") call itself, before the service has added anything to the app model. The last two are read against the checkout, so under UseDeferredCheckout(), where there isn't one yet, they are reported after the clone lands as this service's resource state instead — the same two checks saying the same two things.

Reaching the rest of the Java integration. The java: block covers how to start the app; it deliberately doesn't mirror every modifier the Community Toolkit offers. Anything else is reachable from the AppHost with As<JavaAppExecutableResource>(), which hands back the real resource builder:

builder.AddService("catalog")
    .As<JavaAppExecutableResource>()
    .WithMavenBuild()                      // compile before starting
    .WithJvmArgs(["-Xmx512m"])
    .WithOtelAgent("/path/to/opentelemetry-javaagent.jar");

Use Configure<T>(...) instead for anything that should survive a developer switching that service to a non-local source — As<T>() throws if the service no longer resolves to a Java resource, which is the point when the AppHost genuinely requires one.

UseJava() is exported to Aspire's Type System, so a TypeScript AppHost can call useJava() before addService(...) the same way.

Implementing a kind

A kind implements ILocalResourceKind and registers it from an extension method:

public sealed class JavaScriptKind : ILocalResourceKind
{
    private sealed class Options
    {
        public string? AppDirectory { get; set; }
        public string? RunScript { get; set; }
    }

    // Optional, and worth implementing whenever Resolve parses rawConfig or reads the checkout:
    // this runs immediately before Resolve, against the same repoRoot, and before this service has
    // added anything to the app model — so a typo'd options block, or one naming a directory the
    // repository doesn't have, is reported without a half-created resource behind it. Not the only
    // place to put these checks if your kind supports deferred checkouts — see below.
    public void Validate(string serviceName, string repoRoot, object? rawConfig)
    {
        var options = LocalKindConfig.Parse<Options>(rawConfig, serviceName);

        if (options?.AppDirectory is { } appDirectory
            && !Directory.Exists(Path.Combine(repoRoot, appDirectory)))
        {
            throw new ServiceSourcesConfigurationException(
                $"Service '{serviceName}': appDirectory '{appDirectory}' is not in the checkout.");
        }
    }

    public IResourceBuilder<IResourceWithServiceDiscovery> Resolve(
        IDistributedApplicationBuilder builder, string serviceName, string repoRoot, object? rawConfig)
    {
        // repoRoot is the already-cloned, already-checked-out directory.
        var options = LocalKindConfig.Parse<Options>(rawConfig, serviceName);
        ...
    }
}

public static IDistributedApplicationBuilder UseJavaScript(this IDistributedApplicationBuilder builder) =>
    builder.AddLocalKind("javascript", new JavaScriptKind());

LocalKindConfig.Parse<T> turns the opaque options block into a typed object, and rejects an unknown property or a block that isn't a mapping with a ServiceSourcesConfigurationException naming the service. AddLocalKind must be called before the AddService() call for a service of that kind — resolution is eager, so registering later is too late — accepts each kind name at most once, and cannot re-register "dotnet" or use a name that collides with a well-known service property (repository, project, defaultRef, kind, kubernetes, url, container) — a block by one of those names would be read as that property rather than as the kind's options.

It also refuses a handler that declares a public Validate taking a service name first and an options block somewhere, which doesn't match the interface member — the pre-repoRoot Validate(string, object?), the parameter added in the wrong position, the wrong return type — naming the kind and the method it found. Validate is a defaulted interface member, so any of those compile clean and simply stop implementing it, and everything they rejected would be silently accepted instead. Registration is the only place left to say so; the build won't. A Validate of your own is left alone unless it looks like that attempt: a private helper, one taking your own options type, and one like Validate(string message) that carries no options block at all all register exactly as they did before.

Supporting UseDeferredCheckout(). Two more members, both optional and both defaulting to "no", decide whether a service of your kind can start before its checkout lands. Leave them alone and your kind keeps working exactly as it does now, always on the eager path:

// Answered before anything is registered, so core can decide which services to clone ahead of
// demand. Must touch no filesystem, add nothing to the app model, and never throw - it is called
// for services that may never be added. Answer from the options block alone.
public bool SupportsDeferredCheckout(object? rawConfig) => true;

// Resolve for a checkout that hasn't happened yet: repoRoot is the directory the clone *will*
// land in, and nothing is there yet.
public DeferredLocalResource? ResolveDeferred(
    IDistributedApplicationBuilder builder, string serviceName, string repoRoot, object? rawConfig)
{
    // The same resource Resolve would build, but from the options block alone.
    var options = LocalKindConfig.Parse<Options>(rawConfig, serviceName);
    IResourceBuilder<IResourceWithServiceDiscovery> app = ...;

    return new DeferredLocalResource
    {
        Service = app,
        // Your checks that need the working tree. Core runs this after the clone and reports a
        // failure as that service's resource state.
        ValidateCheckout = () => ValidateWrapperScript(repoRoot),
    };
}

Build the resource exactly as Resolve would, but read no file under repoRoot — hand those checks back as ValidateCheckout. Endpoints are the one thing that can't be added later, so a kind that can only learn its endpoints by reading the repository should return null. Holding the resource back and starting it once the checkout lands is core's job, and it covers every resource the call adds to the app model, not just the one returned as Service.

Validate your options block here too. Validate is paired with Resolve, and core calls neither for a service it defers — there is no checkout for Validate to judge the service against, so ResolveDeferred runs in their place. A kind that can answer true from SupportsDeferredCheckout and rejects a bad block only in Validate has arranged for that block never to be checked at all under UseDeferredCheckout(). Parse it here as well and throw ServiceSourcesConfigurationException. Nothing warns you: implementing both Validate and ResolveDeferred is the ordinary, correct arrangement — the built-in java kind does — so there is no signal to refuse the way a mismatched Validate signature is refused.

Returning null from ResolveDeferred after SupportsDeferredCheckout said true is honoured — legitimate for a kind that can only tell once it has looked at everything — but it isn't free. The checkout prefetch acts on SupportsDeferredCheckout, so a service that answered true is left out of the clones started ahead of demand, and declining here drops it onto the eager path with no clone already running: it is cloned inline, alone, on the AddService() thread rather than alongside the others. Decide in SupportsDeferredCheckout wherever you can, where the answer is free. A block too malformed to answer for is false, which routes it to the eager path where Validate reports it properly.

Private repositories

Clone and fetch for a managed checkout (no path override) authenticate the same way, in order:

  1. Whatever your git already does. Clone and fetch run the git on your PATH, so every credential.helper you have configured — Git Credential Manager, osxkeychain, libsecret, a cached PAT, a .netrc-backed helper — is consulted exactly as it is for a git clone you type yourself. For an SSH remote that means your SSH agent and ~/.ssh/config. Nothing to configure here: if git clone <repository> works in the environment the AppHost runs in, so does this.
  2. SERVICESOURCES_GIT_USERNAME/SERVICESOURCES_GIT_TOKEN/SERVICESOURCES_GIT_HOST environment variables, if the helpers above yield nothing (e.g. no helper configured) — or if what they yielded was refused, see below. SERVICESOURCES_GIT_HOST is required — the host (and port, if the URL has one, e.g. git.internal.example:8443), matched case-insensitively, that the token is for. Without it the token is offered to no host at all, since a catalog can list services from more than one host and this token belongs to only one of them. SERVICESOURCES_GIT_TOKEN alone (alongside SERVICESOURCES_GIT_HOST) is enough for hosts that accept any username alongside a personal access token (GitHub, GitLab, Azure DevOps); set SERVICESOURCES_GIT_USERNAME too if your host requires a specific one. Supplied to git as a credential helper of last resort, so it never overrides a helper you configured yourself, and the token is read from the environment rather than passed on a command line where other users on the machine could read it.

The order is a ladder, not a one-shot choice. git stops at the first helper that answers, so if the host refuses that credential the clone would normally fail there — with the environment token never offered. It is therefore re-run once with the configured helpers cleared, giving SERVICESOURCES_GIT_TOKEN its turn. Only after that does the failure stand.

A credential the host actually refuses is reported back to your helper with git credential reject — by git itself, as part of failing — so Git Credential Manager, osxkeychain, libsecret and friends erase their stored copy and resolve afresh next time instead of serving the same dead token on every run. Rotating a token therefore takes effect on the next resolution; nothing is cached inside the AppHost process for a restart to clear.

Nothing ever prompts. GIT_TERMINAL_PROMPT=0 is set on every invocation, and SSH runs with BatchMode=yes unless you've set your own GIT_SSH_COMMAND, so a repository whose credentials don't resolve fails immediately instead of hanging builder.AddService() on a prompt nobody is there to answer.

Credentials are never read from servicesources.yaml (committed) or servicesources.local.json — there's no field for them in either file, by design, so a secret can't accidentally end up in the committed catalog. The one way to get one in there anyway is to embed it in the repository URL itself (https://user:token@host/org/repo); git accepts that form, but it commits the token along with the catalog, so use one of the two mechanisms above instead. Should such a URL be configured regardless, every message this tool prints strips the userinfo from it first, so the token doesn't spread from the catalog into your console and logs.

A clone or fetch that fails for what looks like an authentication reason raises an error naming the service, the repository, and authentication as the likely cause, rather than a generic "failed to clone" message. This includes a "not found" response: GitHub, GitLab and Azure DevOps all answer an unauthenticated request for a private repository with 404 rather than 401, so as not to leak whether it exists, so the error covers both readings — bad credentials, or a repository the credentials in use can't see. A rate-limited response is deliberately left out, even though hosts answer it with the same 403 as a token that's missing a scope: there the credential is fine and the fix is to wait, so it's reported as the transport failure it is.

When the ladder resolves nothing — no helper yields a credential, and the environment rung has nothing to offer either because SERVICESOURCES_GIT_TOKEN is unset or because SERVICESOURCES_GIT_HOST doesn't name this host — the error says so specifically instead of blaming authentication, because nothing was ever offered for the host to refuse. Watch for this when the helper works in your shell but not under the AppHost: helpers run in whatever environment the AppHost process inherits, which is not necessarily your interactive one.

SSH works. A repository written as git@host:org/repo, host:org/repo or ssh://... is handed to git as written and resolved by your SSH agent and ~/.ssh/config, the same as any other clone. Because nothing may block on a prompt, SSH runs with BatchMode=yes: a key whose passphrase isn't already held by an agent, and a host that isn't in known_hosts yet, fail immediately rather than waiting. Connect to the host once by hand to settle either, or set your own GIT_SSH_COMMAND, which is left untouched if you do.

"kubernetes" source

Point a service at an already-running instance in a Kubernetes dev cluster via kubectl port-forward, instead of running it locally at all.

servicesources.yaml:

services:
  orders:
    kubernetes:
      service: orders-svc
      port: 8080

servicesources.local.json:

{
  "services": {
    "orders": {
      "source": "kubernetes",
      "kubernetes": { "context": "dev-west", "namespace": "orders", "port": 8080 }
    }
  }
}

Requires kubectl on PATH, authenticated against the named context.

Add scheme: https if the pod behind that port serves TLS:

services:
  orders:
    kubernetes:
      service: orders-svc
      port: 8443
      scheme: https

kubectl port-forward is a byte-transparent TCP tunnel, so the TLS handshake terminates at the pod and https://localhost:<port> is the URL that actually works — the scheme is what the service speaks, not a claim about the tunnel. It defaults to http, and names the endpoint consumers reference: with scheme: https the service exposes an endpoint named https, so orders.GetEndpoint("https") resolves. See naming a service's endpoint.

What the tunnel can't fix is certificate hostname validation — the client connects to localhost while the certificate names the in-cluster service — so a consumer that validates certificates needs the usual dev-certificate handling for that.

Set scheme in the developer config to override the catalog for just that developer, alongside a port override:

{
  "services": {
    "orders": {
      "source": "kubernetes",
      "kubernetes": { "context": "dev-west", "port": 8443, "scheme": "https" }
    }
  }
}

"url" source

Point a service at a fixed, already-known URL — e.g. a Kubernetes ingress, a staging deployment, or any other reachable HTTP(S) endpoint. There's no underlying resource for Aspire to run; the endpoint resolves straight to the configured URL.

Three consequences follow from the service running out of band. The AppHost's Configure calls are skipped and logged. A container can't WithReference it — a project or executable can — which fails with a clear error rather than a DCP stack trace; see #58. And a consumer's WaitFor on it resolves immediately instead of waiting:

var orders = builder.AddService("orders");

builder.AddProject<Projects.Storefront>("storefront")
    .WaitFor(orders);   // no-op while 'orders' is "url"

There is no lifetime to order against — the URL is already up, or it isn't, and nothing this AppHost starts will change that — so the wait is dropped rather than satisfied. WaitForCompletion goes the same way, since a service running out of band is never going to exit. Every consumer is covered, containers included: a container that references a url service is still refused as above, but one that only waits on it starts normally.

The drop is logged, alongside any Configure calls the same service skipped:

warn: Aspire.Hosting.ServiceSources
      Service 'orders': skipped WaitFor from 'storefront' because its source is 'url' — it
      resolves to a fixed, already-running URL with no local process to configure. ...

The one wait not reported is the WaitForStart Aspire adds itself for each resource an AddConnectionString expression references — nobody wrote it, so there is no line to point at.

Note what this does not promise: the URL is not fetched, so the consumer starts whether or not anything is listening. A WaitFor written against a "local" service keeps its full meaning the moment the service is switched back, which is the point — a developer choosing "url" in their own servicesources.local.json must not hang an AppHost they don't own. See #170.

servicesources.yaml:

services:
  orders:
    url:
      url: https://orders.example.com

servicesources.local.json:

{
  "services": {
    "orders": { "source": "url" }
  }
}

Set url in the developer config instead to override the catalog's URL for just that developer (e.g. pointing at a personal tunnel or local proxy):

{
  "services": {
    "orders": { "source": "url", "url": { "url": "https://orders.dev.internal" } }
  }
}

"container" source

Run a published container image locally via Aspire's own container-runtime integration — image pull and lifecycle are managed entirely by Aspire.

servicesources.yaml:

services:
  orders:
    container:
      image: ghcr.io/company/orders
      port: 8080
      defaultTag: latest

servicesources.local.json:

{
  "services": {
    "orders": { "source": "container" }
  }
}

Set tag in the developer config to override the catalog's defaultTag for just that developer:

{
  "services": {
    "orders": { "source": "container", "container": { "tag": "v1.4.2" } }
  }
}

Add scheme: https if the image serves TLS on port. Like port, it's catalog-only — the image decides what it serves, so there's nothing per-developer to override — and it defaults to http:

services:
  orders:
    container:
      image: ghcr.io/company/orders
      port: 8443
      scheme: https

Combining sources on one catalog entry

A single servicesources.yaml entry can carry blocks for every source at once — the catalog just describes how each source would resolve the service; each developer's servicesources.local.json picks which one actually applies to them:

services:
  orders:
    repository: https://github.com/example/orders
    project: src/Orders.Api/Orders.Api.csproj
    kubernetes:
      service: orders-svc
      port: 8080
    url:
      url: https://orders.example.com
    container:
      image: ghcr.io/example/orders
      port: 8080
      defaultTag: latest

A developer editing the service picks "local"; one debugging against a shared dev cluster picks "kubernetes"; one who just needs it reachable picks "url" or "container" — same catalog entry, same AddService("orders") call in the AppHost, no code changes either way. Each developer's own servicesources.local.json just names which source applies to them — editing orders locally:

{ "services": { "orders": { "source": "local" } } }

debugging against a shared dev cluster:

{ "services": { "orders": { "source": "kubernetes", "kubernetes": { "context": "dev-west", "namespace": "orders", "port": 8080 } } } }

or just needing it reachable, not caring how:

{ "services": { "orders": { "source": "url" } } }

The source value is matched without regard to case, so "local", "Local" and "LOCAL" all name the same source. A name none of the four has is refused at composition time, naming the ones that exist. (The kind names in servicesources.yaml are the exception — those are case-sensitive, because anything may register one and two registrations must not be able to collide by spelling.)

Overriding servicesources.local.json

The file is read through the AppHost's own IConfiguration, as the lowest-precedence source in the standard provider chain, under the key ServiceSources:Services:<service>. It is still the place a developer normally writes a source selection, and a .NET or TypeScript AppHost authors it identically — but every provider above it can override an entry without the file being touched:

Layer Overrides the file?
servicesources.local.json — (the base)
appsettings.json yes
appsettings.{Environment}.json yes
User secrets yes (requires a UserSecretsId in the AppHost csproj; without one the layer is simply absent)
Environment variables yes
Command-line arguments yes

The appsettings layers need the file in the AppHost's output directory. An AppHost project ships no appsettings.json, so unlike a web project it has no item copying that pattern to bin/, and a file placed beside the .csproj is silently never found — there is no error, the layer is simply absent. Add it explicitly:

<ItemGroup>
  <Content Include="appsettings*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

The ServiceSources:* keys reach the AppHost's own IConfiguration on its first ServiceSources call, not before. servicesources.local.json is a file of ours, read from the AppHost directory and re-keyed into the chain by whichever ServiceSources method the AppHost calls first — a UseX() registration, or the first AddService(). A read placed above all of them sees the chain without that layer, so a selection written only in the file comes back null, silently, since a missing key is not an error:

// null — nothing of ours has been called yet, so the file is not in the chain.
var source = builder.Configuration["ServiceSources:Services:orders:source"];

builder.UseJavaScript();

// "local" — the file joined the chain on the line above.
source = builder.Configuration["ServiceSources:Services:orders:source"];

Reading these keys from an AppHost should be rare. Scoping a declaration to one source is what sends an AppHost looking for them, and Configure<T> already does that scoping for you.

The immediate payoff is a single run with a different source and no edit to a file you'd have to remember to change back. source itself isn't nested under a block, so this still works verbatim:

ServiceSources__Services__orders__Source=url dotnet run

Overriding a field works the same way, but gains its source's block segment — ServiceSources__Services__orders__Local__Ref, ServiceSources__Services__orders__Container__Tag, and so on. (__ is the .NET configuration separator for :, and is what you want on every platform.) Setting one of these to a blank value unsets the field, rather than setting it to an empty string — ServiceSources__Services__orders__Local__Path= leaves the service with no path at all, even one servicesources.local.json (or a layer in between) configured. It does not fall back to that lower layer's value: configuration merges the layers before this package sees them, so the blank is what arrives and the field ends up absent — which for path means the service gets its managed checkout, exactly as if no layer had ever named one.

Blank means empty, exactly, whatever the field's type. A value of one or more spaces is refused rather than read as either an unset field or a value of its own: ServiceSources__Services__orders__Kubernetes__Port= drops the port, and ServiceSources__Services__orders__Local__Path=" " is an error naming the spelling that works rather than an override silently discarded — which is what a stray space surviving a CI variable used to cost, leaving the service on its managed checkout with nothing said.

A service whose name contains a hyphen — order-service, say — makes a variable name a shell won't accept as an inline assignment, so pass it through env instead:

env 'ServiceSources__Services__order-service__Source=url' dotnet run

The key itself is fine either way; it's only the one-line NAME=value command form that needs this.

The nesting is what makes this override story work at all: switching source from a higher layer leaves the previous source's block sitting in the file, unread rather than removed. Nothing has to be deleted from servicesources.local.json to switch a service away from the source it names there — the fields for every other source can sit in the file unused, ready for the next switch back.

CI is the other case. A build agent has no developer to pick sources for it, and cloning every service to run one test is waste, so pin them from the environment and ship no file at all:

env:
  ServiceSources__Services__orders__Source: container
  ServiceSources__Services__payments__Source: container

Named profiles fall out of the same mechanism. Put the cluster-facing selection in appsettings.Cluster.json next to the AppHost:

{
  "ServiceSources": {
    "Services": {
      "orders": {
        "source": "kubernetes",
        "kubernetes": { "context": "dev-west", "namespace": "orders", "port": 8080 }
      }
    }
  }
}

and choose it per run by passing the environment as an argument to the AppHost:

aspire run -- --environment Cluster     # everything after -- goes to the AppHost
dotnet run -- --environment Cluster     # or launching the AppHost directly

DOTNET_ENVIRONMENT=Cluster does not work under aspire run. The CLI sets ASPNETCORE_ENVIRONMENT and DOTNET_ENVIRONMENT to Development itself when it launches the AppHost, so a value exported in your shell is overwritten and the profile is silently not selected — you get the base file's selection with no indication that the profile was ignored. The variable route only works when you run the AppHost yourself with dotnet run. The command-line form above works in both.

Note the extra ServiceSources root: inside the AppHost's shared configuration the entries are namespaced, while servicesources.local.json keeps its bare services root because it is a file of ours, read from the AppHost directory and re-keyed as it joins the chain.

Two failures are reported differently on purpose, because a typo in a configuration key produces an empty section rather than an error:

  • Nothing configured anywhereServiceSources:Services is empty in every source. The message says so, names the servicesources.local.json path it looked for and whether it was found, and lists every source consulted.
  • This one service isn't configured — other services resolved, this one has no entry. The message names ServiceSources:Services:<service>:source and the environment variable that would set it.

Configuring a resolved service

AddService() returns a builder over the real resource Aspire runs, so the AppHost can inject its own configuration — connection strings, generated secrets, a sibling's endpoint, wait ordering. Values like these come from the AppHost's own graph and can't be written into servicesources.yaml/servicesources.local.json.

The resolved resource's type depends on the source, which each developer chooses, so name the capability you need and it is checked at composition time:

var backend = builder.AddService("backend")
    .Configure<IResourceWithEnvironment>(r => r
        .WithReference(ordersDb)
        .WithEnvironment("DBPASSWORD", postgres.Resource.PasswordParameter)
        .WithEnvironment("ENCRYPTIONKEY", builder.AddParameter("EncryptionKey", new GenerateParameterDefault(), secret: true))
        .WithEnvironment("Services__CommonAuth", commonAuth.GetServiceEndpoint()))
    .Configure<IResourceWithWaitSupport>(r => r.WaitForCompletion(migrationService));

As<T>() is the same cast without the callback, and reaches anything Configure would — including a non-dotnet kind's own extension methods:

backend.As<JavaScriptAppResource>().WithRunScript("dev");

Configure is skipped for the "url" and "kubernetes" sources, and the skip is logged at startup. Both resolve to something already running elsewhere — a "url" service has no local process at all, and a "kubernetes" service is a kubectl port-forward in front of a remote one, so environment variables applied here would configure kubectl rather than the service. Those services are expected to be configured wherever they actually run.

The one exception is wait ordering on a "kubernetes" service, which still applies: Configure<IResourceWithWaitSupport> (and WaitForService / WaitForServiceCompletion) reach a real, registered kubectl port-forward executable, and holding that back until a migration finishes is exactly what the AppHost asked for. Only configuration that would land on the wrong process is dropped. A "url" service skips wait ordering too, since it has no registered resource for Aspire to hold back.

That is this service waiting for something else. The other direction — something else waiting for this service, consumer.WaitFor(service), which is ordinary Aspire rather than a Configure call — is dropped for "url" and honoured for every other source, including "kubernetes". That drop is reported in the same message as the service's skipped Configure calls. See the "url" source.

Skipping rather than failing is deliberate: a developer switching a service to a remote source in their own servicesources.local.json must not break a Program.cs they don't own. You'll see:

warn: Aspire.Hosting.ServiceSources
      Service 'backend': skipped Configure<IResourceWithEnvironment> because its source is
      'kubernetes' — it resolves to a 'kubectl port-forward' in front of an already-running
      service, so the configuration would reach kubectl rather than the service. ...

As<T>() throws for those sources instead of skipping — it has to return a builder, and handing back the kubectl executable would silently configure the wrong process. Prefer Configure for anything that should survive a source switch. It follows the same wait-ordering exception: As<IResourceWithWaitSupport>() on a "kubernetes" service returns the port-forward's builder rather than throwing.

From a guest-language AppHost

Configure<T> is generic, and Aspire's Type System erases a generic method's type parameter to its constraint — which for Configure<T> erases the capability being requested, since that is all T says. So guest languages get a set of non-generic equivalents instead, one per shape, each with its own name (two exports that project to the same generated name collide, and only one survives):

const payments = await builder
  .addService('payments')
  .withServiceEnvironment('DEMO_INJECTED_BY_APPHOST', 'true')
  .withServiceReference(inventory);
TypeScript C# equivalent
withServiceEnvironment(name, value) .Configure<IResourceWithEnvironment>(r => r.WithEnvironment(name, value))
withServiceEnvironmentFromParameter(name, parameter) …WithEnvironment(name, parameter)
withServiceEnvironmentFromEndpoint(name, endpoint) …WithEnvironment(name, endpoint)
withServiceReference(other) …WithReference(other)
withServiceConnectionString(source) …WithReference(source)
waitForService(dependency) .Configure<IResourceWithWaitSupport>(r => r.WaitFor(dependency))
waitForServiceCompletion(dependency, { exitCode }) …WaitForCompletion(dependency, exitCode)
withServiceArg(arg) .Configure<IResourceWithArgs>(r => r.WithArgs(arg))
withServiceHttpsEndpoint() .Configure<IResourceWithEndpoints>(r => r.WithHttpsEndpoint())
withServiceHttpEndpoint() …WithHttpEndpoint()

They delegate to Configure<T>, so out-of-band sources are skipped and logged exactly as above — including the wait-ordering exception, which waitForService and waitForServiceCompletion inherit. In C# they're hidden from IntelliSense — use Configure<T>, which reaches every Aspire extension method rather than just these.

Backing services: databases, brokers and caches

A service usually depends on a database or a broker, and a developer wants the same choice for it that they have for the service: run it locally, or connect to the one already running in the shared dev cluster. AddBackingService() is that choice.

var ordersDb = builder.AddBackingService("orders-db",
    local: () => builder.AddPostgres("orders-pg").AddDatabase("orders-db", "orders"));

builder.AddService("orders")
    .Configure<IResourceWithEnvironment>(r => r.WithReference(ordersDb))
    .Configure<IResourceWithWaitSupport>(r => r.WaitFor(ordersDb));

WaitFor stops meaning anything under "direct". The WaitFor above waits properly under "local", where a real database resource sits behind it, and under "kubernetes", where a health check on the forwarded port holds the consumer back until the tunnel is actually listening. Switch that backing service to "direct" and it is satisfied immediately instead: the resource is a connection string, and Aspire marks it running as soon as that string is available — which is at once, without the instance you pointed at having been checked. Nothing fails and nothing hangs; the consumer simply starts earlier than you asked it to. Tracked as #220.

Two things are different from AddService():

  • There is no catalog. A backing service is declared by the AddBackingService() call itself, so nothing goes in servicesources.yaml — an AppHost that only connects to a database needs no catalog file at all.
  • The local case is your code, not ours. Provisioning a database locally is already expressed perfectly well as builder.AddPostgres(...), by the person who knows what it should be, so the "local" source runs that factory and returns its result unchanged. Re-expressing image and version as catalog fields would be a worse version of Aspire's own integrations.

Because the local case is a factory, this works for anything with a connection string — AddSqlServer(...).AddDatabase(...), AddRabbitMQ(...), AddRedis(...) — with no support needed per backend.

Sources

Configured under a new backingServices: section of servicesources.local.json, alongside services: and read through the same configuration layers, so every override in Overriding servicesources.local.json applies here too:

{
  "services": { "orders": { "source": "local" } },
  "backingServices": {
    "orders-db": {
      "source": "direct",
      "direct": { "connectionString": "Host=localhost;Port=5432;Database=orders;Username=dev" }
    }
  }
}
source Meaning
"local" Run the local factory. The default — a backing service with no entry resolves here, so an AppHost nobody has configured runs as it reads.
"direct" Connect to direct.connectionString. Covers a database the developer started by hand and a cluster database published through an ingress: from the AppHost's side both are an address to connect to, with no process to manage.
"kubernetes" Open a kubectl port-forward to a Service in a dev cluster, and connect to the local end of it. See Reaching a backing service in a cluster.

"direct" is named for the one thing that distinguishes it — nothing in the way — rather than for where the database runs. It is not "remote", because the common case is a localhost the developer started themselves, and not "external", which Aspire already uses for an external HTTP service.

Each source's settings live in a block named for it, exactly as a service entry's do, so a higher configuration layer can switch source without a field from the source you switched away from being read alongside it.

Write the address as reached from outside Aspire. "direct" hands the connection string to consumers as given — nothing about it is rewritten, because there is nothing for this AppHost to manage. That is worth spelling out for the case a developer reaches for while experimenting: pointing "direct" at a container the same AppHost runs. The host and port shown for a container endpoint in the dashboard, and by aspire describe, are Aspire's endpoint proxy — not the container's own published port. The proxy listens only while that AppHost is running, and a fresh run assigns it a new port, so a connection string written against it stops working as soon as either changes. Take the real published port from your container runtime (docker port / podman port) — or keep "local", which is what "a database this AppHost runs" already means.

Reaching a backing service in a cluster

"kubernetes" connects to a database, broker or cache running in a dev cluster, through a kubectl port-forward this AppHost opens and Aspire manages for the life of the run:

{
  "backingServices": {
    "orders-db": {
      "source": "kubernetes",
      "kubernetes": {
        "service": "orders-pg-rw",         // the Kubernetes Service to forward to
        "port": 5432,                       // the port it listens on inside the cluster; a block
                                            // of named ports forwards several — see below
        "context": "dev-west",              // the kubectl context to forward through
        "namespace": "orders",              // optional; "default" when omitted
        "connectionString": "Host=localhost;Port=${port};Database=orders;Username=dev;Password=hunter2"
      }
    }
  }
}

Write ${port}, not a number. The local end of the tunnel is allocated when the AppHost starts, so that two backing services forwarded at once cannot collide — which means it is not a number you can write down. ${port} is replaced with it before any consumer sees the string. A connection string that names no ${port} is refused at startup rather than run, because the alternative fails silently: Port=5432 copied out of a manifest addresses port 5432 on your machine, where your own database container may well be listening, and the AppHost would connect to the wrong database with every resource reporting healthy.

Two resources appear in the dashboard: the backing service itself, and the kubectl process underneath it as orders-db-tunnel. kubectl's own output — a bad context, a Service that does not exist, an expired credential — lands in that resource's logs.

The health badge is on the backing service, not on the tunnel. For the few seconds before the forward is up, the tunnel shows as running while the backing service above it shows as unhealthy; that is the right way round, because the backing service is what consumers wait for. The check is deliberately not duplicated onto the tunnel: Aspire probes once per resource carrying it, and every probe is a connection kubectl logs — which would bury the output you go there to read. A kubectl that exits outright shows up as a failed resource regardless.

  • A Service, not a pod. A pod name carries a replica-set suffix that changes on every rollout; kubectl port-forward against a Service picks a backing pod itself.
  • context is required, and deliberately not defaulted to whatever kubectl is currently pointed at. Defaulting would make the AppHost's behaviour depend on a shell you may not have opened today, and the failure would be a connection to the wrong cluster rather than an error.
  • namespace defaults to default, which is not kubectl's own default — kubectl uses the namespace configured on the context. Same reasoning: what the AppHost does should not depend on a kubectl config set-context --current --namespace=… nobody recorded.
  • kubectl must be on PATH. Nothing is bundled, and this source runs the same binary you do.
Several ports through one tunnel

A broker usually wants two: the one the application speaks, and a management port you open in a browser. Write port as a block that names each one, and reach them as ${port:<name>}:

{
  "backingServices": {
    "orders-events": {
      "source": "kubernetes",
      "kubernetes": {
        "service": "rabbitmq",
        "port": { "amqp": 5672, "management": 15672 },
        "context": "dev-west",
        "connectionString": "amqp://dev:hunter2@localhost:${port:amqp}/"
      }
    }
  }
}

One kubectl process carries every pair, because kubectl port-forward accepts several against one Service — two entries would mean two processes and two tunnels to the same Service. Each forwarded port gets its own health check, all of them on the backing service, so a WaitFor waits for the whole tunnel and the dashboard says which half is missing while it comes up.

Not every forwarded port has to appear in the connection string: the management port above is forwarded so you can open it, and nothing dials it from the app.

${port} and ${port:<name>} do not mix. A port written as a number forwards one unnamed port and takes ${port}; a block that names its ports takes ${port:<name>} for each. Writing the other one is refused at startup, naming the ports this backing service actually forwards — including a "did you mean" when the name is close to one of them.

Two spellings of one port name that differ only in case — amqp and AMQP — are the same configuration key. In a single servicesources.local.json that is a duplicate key, and the JSON parser refuses the whole file; spread across two layers they merge instead, and the casing you see is whichever layer wrote last.

Reading credentials out of a Kubernetes secret is covered under Connection-string placeholders, with ${secret:<name>:<key>}.

The local factory's resource must be named after the backing service

Aspire's WithReference(...) keys the connection string on the referenced resource's own name, and under "local" that resource is whatever your factory built. So the names have to agree, and AddBackingService refuses them when they do not:

// Good — one name everywhere. Switching source changes the value and nothing else.
builder.AddBackingService("orders-db", () => builder.AddPostgres("pg").AddDatabase("orders-db", "orders"));
// → ConnectionStrings__orders-db, under every source

// Refused at startup, naming both names.
builder.AddBackingService("orders-db", () => builder.AddPostgres("pg").AddDatabase("orders"));
// → would be ConnectionStrings__orders under "local", ConnectionStrings__orders-db under "direct"

AddDatabase("orders-db", "orders") names the Aspire resource and the actual database separately, which is what to reach for when the two want different names. Casing counts. .NET folds it when reading configuration, so a .NET consumer would not notice — but the environment variable itself differs, and a JavaScript or Java service reads process.env / System.getenv case-sensitively.

This is a rule rather than advice because the alternative remedy is not available everywhere. In C# a consumer can pin the key from its own side:

builder.AddService("orders")
    .Configure<IResourceWithEnvironment>(r => r.WithReference(ordersDb, "OrdersDb"));
// → ConnectionStrings__OrdersDb, under every source

WithReference's second argument overrides the source resource's name for the connection string. Reach for it when the app already reads a particular name — but note it is C#-only today, because the generated shim takes the source alone (#209). That is this package's gap rather than a limit of guest languages: a project's own withReference already accepts { connectionName } from TypeScript. Until the shim offers the same, naming the factory's resource after the backing service is the one answer every AppHost can give, which is why it is the one enforced.

If the resource is not yours to rename — a shared helper, or one handed to you — return a connection string of your own that forwards it:

builder.AddBackingService("orders-db", () =>
{
    var shared = SharedHelpers.AddOrdersDatabase(builder);   // names its resource whatever it likes
    return builder.AddConnectionString("orders-db", ReferenceExpression.Create($"{shared}"));
});

The forwarding resource carries the same value under the name the rule wants, so the key stays put across a source switch. This used to be the case WithReference(db, connectionName) covered; the rule makes that unreachable for a backing service, since the throw happens first, so the wrap is what replaces it.

The wait mostly survives the wrap, but loses its health check. What AddBackingService hands back is the forwarding ConnectionStringResource rather than the database the factory built — and Aspire does follow the reference: the wrapper itself sits in Waiting until the database it forwards is running, so a consumer's WaitFor(ordersDb) still holds back for Postgres to start.

What it stops honouring is the database's health check. Waiting on the database directly waits for it to be healthy; waiting on the wrapper is satisfied once the database is merely running. Measured on a live host with a real Postgres — the consumer waiting on the wrapper started about seven seconds before the one waiting on the database, while a consumer waiting on nothing started three seconds before either.

So rename the resource wherever you can; the wrap is a smaller loss than it looks, but it is not free. Tracked as #220, together with the "direct" case, where the connection string references nothing and the wait is therefore satisfied at once.

Connection-string placeholders

A connectionString is normally a literal. Braces reserve nothingDriver={PostgreSQL}, Server={host}\instance and PWD={secret} all pass through exactly as written, doubled braces included, so ODBC values keep their own doubling rule intact (PWD={pa}}ss} is the password pa}ss, and stays that).

Placeholders open on ${, which no connection-string dialect uses. Two are recognised and reserved for the sources that can resolve them; a source that cannot rejects one with a message saying why:

  • ${port} — the local end of the tunnel, under "kubernetes", where it is required unless the whole connection string is one ${secret:…} (below), which carries a port already. "direct" forwards nothing, so there write the port the backing service already listens on.

  • ${port:<name>} — one of several ports forwarded through the one tunnel, where port is written as a block that names each. See Several ports through one tunnel.

  • ${secret:<name>:<key>} — a value read from a Kubernetes secret, under "kubernetes". The fetch is deferred: the placeholder becomes a parameter Aspire resolves when something first asks for the value, so an unreachable cluster costs one failed parameter rather than an AppHost that will not start. The value is marked secret, so the dashboard masks it, and reading the same placeholder twice fetches once. "direct" has no cluster to resolve one against and refuses it, naming "kubernetes" as the source that does.

    A secret's name and its keys are letters, digits, -, . and _, with the name starting with a letter or a digit — the cluster's own rule, checked here so that nothing else can be smuggled into the kubectl command that reads it.

    A secret holding the whole connection string works too, which is the shape a hand-authored Sealed Secret usually has. Write the template as exactly one placeholder:

    {
      "backingServices": {
        "orders-db": {
          "source": "kubernetes",
          "kubernetes": {
            "service": "orders-pg-rw",
            "port": 5432,
            "context": "dev-west",
            "namespace": "orders",
            "connectionString": "${secret:orders-cs:connectionString}"
          }
        }
      }
    }
    

    Then the port-forward listens on the same port port names rather than an allocated one — there is nothing in the template to substitute a local port into — and the in-cluster host the secret was written against is rewritten to localhost, in any of the four forms a pod resolves (orders-pg-rw, .orders, .svc, .svc.cluster.local), wherever a connection string can put a host. Because the allocated port is given up, a local port already in use is refused up front — and for the same reason, this mode takes a single port rather than a block that names several: with only the one number to match against, give it a single port instead. So is a secret whose own port is not the one being forwarded, and one that names the service in no form this can rewrite. Per-field placeholders stay preferred wherever the secret offers them.

A malformed placeholder — ${secret:orders-creds}, with no key — fails when the AppHost starts, naming the backing service and the configuration key, rather than reaching the app as text.

A ${ begins a placeholder only when the word after it — up to the first : or }, or to the end — is exactly port or secret, in any casing. Equality, not a prefix, so everything else is text: ${portal}, ${secretariat}, ${secrets:a} and ${DB_PASS} all pass through untouched — which is what keeps a connection string working when something else in your toolchain is the one expanding ${…}.

The remaining cost is that ${port} and ${secret:…} themselves cannot be written as literal text in any casing: a keyword-shaped token that this package cannot read fails at startup rather than passing through, and there is no escape. Nothing has wanted one. $ is not otherwise special, so $${port} is available as an escape if that ever changes — it is a literal $ followed by a placeholder today.

The syntax was {port} during development and moved before release (#207). Reserving a shape inside braces left PWD={secret} — ODBC for a password that happens to be the word — impossible to write. Escaping could not fix it: doubling is the syntax ODBC already uses, and collapsing it silently corrupted working connection strings in both directions.

Setting a template from a shell: quote it with single quotes

${…} is also what a POSIX shell, docker-compose and a GitHub Actions run: block use for their own variables, so a template set through an environment variable can be expanded away before the AppHost ever sees it. Double quotes do not help — they protect the ; and not the ${:

# Wrong: double quotes, so the shell substitutes ${port} (unset) and the AppHost gets "Host=db;Port="
env "ServiceSources__BackingServices__orders-db__Direct__ConnectionString=Host=db;Port=${port}" aspire run

# Right: single quotes, so ${port} reaches the AppHost intact
env 'ServiceSources__BackingServices__orders-db__Direct__ConnectionString=Host=db;Port=${port}' aspire run

env rather than export, because a backing service whose name contains a hyphen — orders-db here — makes an environment variable name that is not a valid shell identifier, and both export NAME=… and the NAME=… command prefix refuse it outright. env 'NAME=value' command accepts any name, and so do docker-compose's environment:, launchSettings.json and a workflow's env: block, none of which put the name through a shell.

That is a separate question from the value. launchSettings.json leaves a value alone entirely; docker-compose does its own ${…} interpolation, so escape it there as $${port}; and a workflow's run: block is a shell, so it needs the single quotes above.

Under "direct" nothing reports the mangled case, because what arrives is a valid template that simply has no placeholder in it — which is also what someone writing a literal port produces, and that is a perfectly good "direct" connection string. Under "kubernetes" it is reported, since a ${port} is required there: the error names the shell alongside the spelling, because a template that lost its placeholder and one that never had it arrive looking the same.

This does not apply to servicesources.local.json, appsettings.json or user secrets, where $ is an ordinary character — which is where a template normally lives.

Configuration that nothing reads is reported

A backing service with no entry legitimately runs from its local factory, so an entry whose key matches no AddBackingService() call cannot be told apart at read time from one that was never written:

"backingServices": {
  "orders_db": { "source": "direct", "direct": { "connectionString": "…" } }  // note the underscore
}

orders-db would revert to "local" and start the container you were trying to avoid. Once the AppHost is composed the set of names AddBackingService() was called with is known, so this is reported as a warning at startup, naming the entry and the declared name it resembles. A misspelled backingServices root key — which does the same to every backing service at once — is reported the same way.

Both are warnings rather than errors, because a shared servicesources.local.json may legitimately carry entries for backing services only some configurations add. There is no way to switch them off; if you find yourself wanting one, say so on #206.

From a guest-language AppHost

addBackingService is exported, and the local factory crosses the boundary as an ordinary callback:

const ordersDb = await builder.addBackingService('orders-db',
    async () => builder.addPostgres('orders-pg').addDatabase('orders-db', 'orders'));

Naming a service's endpoint

A consumer that wants the service's URL asks for an endpoint. Aspire names endpoints, and GetEndpoint("https") looks like the obvious spelling — but the endpoint name a resolved service exposes is decided by whichever source resolved it:

Source Endpoint name
"local", kind: dotnet whatever the launch profile's applicationUrl declares (http, https, or both)
"local", non-dotnet kinds (javascript, java) http
"url" the configured URL's scheme
"kubernetes", "container" the configured scheme, http unless set

So naming a scheme resolves only while the service happens to be on a source that produces it. Switch that service and the consumer breaks — and it breaks late: composition succeeds, and the throw comes from Aspire's ExpressionResolver when the consumer's environment is gathered, so it surfaces as a FailedToStart on the consumer naming a service the consumer never changed:

System.InvalidOperationException: The endpoint `https` is not defined for the resource
`common-auth`. Available endpoints: `http`.

GetServiceEndpoint() is the portable spelling. It asks for the endpoint the service exposes and survives a source switch:

var commonAuth = builder.AddService("common-auth");

builder.AddProject<Projects.Web>("web")
    .WithEnvironment("Services__CommonAuth", commonAuth.GetServiceEndpoint());

It resolves to the endpoint named https if there is one, else http, else the service's only endpoint whatever it's named — the same order Aspire's own service discovery resolves "https+http://" in, so a service exposing both hands back the endpoint Aspire would have picked itself. It throws at composition time, naming the service and its source, if the service exposes no endpoint at all or exposes several with none named http or https; in that last case there's no single endpoint to mean, so name the one you want with GetEndpoint("<name>").

The endpoint is chosen when you call it, so call it after any Configure that adds one. The EndpointReference it returns is lazy in the usual way — the URL resolves once Aspire has allocated the port.

WithReference(service) plus service discovery is portable too, and is the better fit when the consumer speaks service discovery: it injects every endpoint the service has under services__<name>__<scheme>__<index>, and a client resolving https+http://common-auth picks whichever is there. GetServiceEndpoint() is for the case a plain URL in a plain environment variable is what the consumer reads.

GetEndpoint("<scheme>") still has its place — a service you know will never move off "local", or an endpoint you added yourself through Configure<IResourceWithEndpoints>. Just don't reach for it across a service whose source a developer chooses.

From a guest-language AppHost it's getServiceEndpoint(), and the value flows into Aspire's own withEnvironment:

await builder
  .addExecutable('probe', process.execPath, '.', ['-e', probeScript])
  .withEnvironment('INVENTORY_URL', inventory.getServiceEndpoint());

Sample

samples/DemoAppHost is a minimal working AppHost demonstrating all three easily-runnable sources: orders via a real managed "local" git checkout (a small project cloned from dotnet/aspire-samples), inventory via the "url" source (pointing at httpbin.org, a live public test API), and payments via the "container" source (the nginxdemos/hello hello-world image) — run it to see the whole flow end to end. ("kubernetes" isn't demoed here since it needs a real cluster and kubectl; see its section above.)

It also carries a catalog service showing kind: java — a "local" checkout of Spring PetClinic run with its own Maven wrapper. builder.UseJava() is wired up, but AddService("catalog") is commented out and the service is left out of servicesources.local.json.example, since unlike the three above it needs a JDK. To run it, do both: uncomment the call and add "catalog": { "source": "local" } to your servicesources.local.json. Leaving it out of that file by default is what keeps the sample from cloning PetClinic on its first run: the sample does not call UseDeferredCheckout(), so the first AddService clones every "local" entry there that has no checkout yet, whether or not you add it.

cd samples/DemoAppHost
cp servicesources.local.json.example servicesources.local.json
aspire run

A TypeScript AppHost equivalent — proving AddService() is correctly exported and registers with Aspire's Type System from a guest language, and that a resolved service can be configured from TypeScript — lives in samples/DemoAppHostTypeScript. Both of its services use the "container" source so that payments can withServiceReference(inventory): a "url" service runs out of band, and a container consumer of one is rejected up front. A third resource, the probe executable, hands the same inventory handle to Aspire's own withReference() and to getServiceEndpoint(), and prints what each injected — so it shows as Exited, not Running, and those two log lines are where you see both the native service-discovery path and the portable endpoint accessor working. (Note: this sample needs Aspire CLI 13.5.3 or newer — see the compatibility note below the code block.)

cd samples/DemoAppHostTypeScript
npm install
cp servicesources.local.json.example servicesources.local.json
aspire restore
aspire run

Requires Aspire CLI 13.5.3+: the CLI pins its own Aspire version for the host project it generates, so a CLI older than this package's Aspire floor (13.5.2) fails aspire restore with NU1605: Detected package downgrade: Aspire.Hosting from 13.5.2 to 13.5.1 before codegen even runs. 13.5.3 is the first release that pins high enough. On it, the generated SDK type-checks clean under strict tsc and the sample runs end-to-end — withReference() on the addService() result injects the resolved service's discovery variables into the consuming resource, e.g. services__inventory__http__0=http://inventory.dev.internal:80 pointing at the running inventory container.

This sample used to require an unreleased 13.6.0, and that requirement is gone. Aspire's TypeScript codegen does not emit a *Promise/*PromiseImpl wrapper pair for a bare Aspire interface (IResourceBuilder<IResourceWithServiceDiscovery>, which is what AddService returns), so the generated SDK referenced an undeclared ResourceWithServiceDiscoveryPromise and failed with six TS2552 errors — reported as microsoft/aspire#19507 and fixed upstream by microsoft/aspire#19577 under the 13.6 milestone.

That upstream fix is no longer what makes this work. The generator emits the wrapper pair when the bare interface appears as an extension-method receiver rather than only as a return type, and the ten [AspireExport] configuration shims above declare exactly that receiver — so they carry the wrapper pair for addService too. Removing [AspireExport] from those shims brings all six errors back on a current CLI, which is how the cause was isolated; the measurement is in docs/superpowers/specs/2026-08-30-19507-already-fixed-findings.md.

Switching between CLI builds can leave a stale code generator under .aspire/, so remove that directory before regenerating: microsoft/aspire#19603.

When configuration is wrong

Every problem this package detects — a missing project file, an unregistered kind, a checkout it won't overwrite, a clone it can't authenticate — is raised as a ServiceSourcesConfigurationException whose message names the service, what failed, and what to do about it. Because these are raised from AddService(), they usually reach you as an unhandled exception that takes the AppHost down before Aspire starts, so that message is the error output. It prints as the message plus one line per underlying cause:

Unhandled exception. Service 'reportdata': failed to clone repository 'https://github.com/acme/reportdata' into
'/src/report-service/src/Report.AppHost/.servicesources/checkouts/reportdata' — authentication failed, or the
repository is not visible to the credentials in use. Configure credentials via a git credential helper (`git
credential fill` must resolve them for this host) or the SERVICESOURCES_GIT_USERNAME/SERVICESOURCES_GIT_TOKEN/
SERVICESOURCES_GIT_HOST environment variables.
  caused by: unexpected http status code: 404
  (set SERVICESOURCES_FULL_ERRORS=1 for the full exception detail, including stack traces)

The stack frames behind it are this package's own plumbing and don't help with a misconfiguration, so they're left out — and the last line says how to get them back, because for a failure this package didn't anticipate they are the diagnosis. When you need them — you suspect a bug in this package rather than in your configuration, and want to file it — set SERVICESOURCES_FULL_ERRORS=1 to get the runtime's complete dump, type names, inner-exception blocks, stack traces and all.

Status

Early stage, evolving fast. "local", "kubernetes", "url", and "container" sources are all implemented — see docs/superpowers/ for design and implementation history, including the phase 2 backlog (repo auto-update, config discovery walk-up, dependency/infrastructure resolution, and more).

Changes are recorded in CHANGELOG.md; how a release is cut is in RELEASING.md; the trust model a resolved service runs under is in SECURITY.md.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on KoalaSoft.Aspire.Hosting.ServiceSources:

Package Downloads
KoalaSoft.Aspire.Hosting.ServiceSources.Java

Java support for KoalaSoft.Aspire.Hosting.ServiceSources — lets an AddService() "local" source clone and run a Java service (Maven goal, Gradle task, or a jar) via the .NET Aspire Community Toolkit's Java integration.

KoalaSoft.Aspire.Hosting.ServiceSources.JavaScript

JavaScript support for KoalaSoft.Aspire.Hosting.ServiceSources — runs a "local"-sourced service with kind "javascript" through Aspire.Hosting.JavaScript.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.5.1 87 9/7/2026
0.4.1 138 9/5/2026
0.4.0 117 9/3/2026
0.3.1 128 8/27/2026
0.3.0 96 8/27/2026
0.2.0 107 8/18/2026
0.1.0 96 8/18/2026

_There is no 0.5.0. That tag's release failed to publish to nuget.org and GitHub's immutable-release
protection then blocked reusing the name, so this version carries what would have been 0.5.0._

### Breaking

- **`ILocalResourceKind.Validate` takes the service's resolved checkout directory**
 (#63). The signature gains a `repoRoot` parameter in the position `Resolve` already has it:

 ```csharp
 public void Validate(string serviceName, object? rawConfig)                  // before
 public void Validate(string serviceName, string repoRoot, object? rawConfig)  // after
 ```

 **Nothing fails to build, so read this even though your AppHost still compiles.** `Validate` is
 a defaulted interface member: a kind still declaring the old two-parameter method compiles clean
 against the new interface, it simply stops implementing it, and core calls the do-nothing default
 in its place. Every rejection that method made would quietly stop running, and the typo'd options
 block it used to name would reach `Resolve` and surface as "the handler failed while creating its
 resource" instead. There is no compiler diagnostic for that, so `AddLocalKind` now refuses a
 handler that declares a `Validate` not matching the interface member, naming the kind and the
 method it found. Registering the kind is what tells you; the build will not.

 To migrate, add the parameter. A kind that only parsed its options block needs nothing else. A
 kind that never implemented `Validate` at all is unaffected — the default stands, and the check
 above says nothing about it, as it does not about a migrated kind that keeps an old-shaped method
 of its own. `Resolve`, `SupportsDeferredCheckout` and `ResolveDeferred` are untouched.

 **A second silent change, and this one has no registration-time refusal to catch it: `Validate`
 is no longer called for a service on the deferred path.** It is paired with `Resolve`, which core
 does not call there either — under
 [`UseDeferredCheckout()`](README.md#first-run-usedeferredcheckout) there is no checkout for it to
 judge the service against, so `ResolveDeferred` is called instead. **If your kind can answer
 `true` from `SupportsDeferredCheckout` and validates its options block only in `Validate`, that
 block stops being validated at all for a deferred service.** Parse and reject it from
 `ResolveDeferred` too, and hand the working-tree checks back as
 `DeferredLocalResource.ValidateCheckout` as before. No trip wire is possible for this one:
 implementing both `Validate` and `ResolveDeferred` is the ordinary, correct arrangement — the
 built-in `java` kind is one — so anything detectable here would fire on working code. Saying it
 is the only warning there is. Both shipped kinds already validate in `ResolveDeferred` and are
 unaffected; a kind that leaves `SupportsDeferredCheckout` at its `false` default never reaches
 this path at all.

 What the parameter buys is the check a kind could not make before: `repoRoot` is the same
 directory `Resolve` is about to get, already cloned and checked out, so a `workingDirectory`,
 an entry-point file or a lockfile that the repository doesn't actually have can be reported from
 `Validate` — where nothing of the service is in the app model yet — instead of from halfway
 through building the resource. That is what the built-in `dotnet` kind has always done with its
 `project` file, and core's "report it from `ILocalResourceKind.Validate` instead" message is now
 advice a handler author can act on.

 Handing it a checkout that has to be there is also what drops `Validate` off the deferred path
 above, and it moves the call: core runs `Validate` after resolving the checkout rather than
 before. That changes when a bad options block is reported, for **every** `"local"` service and
 not just the first — see the **Changed** entry below. The "the handler failed while creating its
 resource" wrapper on the deferred path now points at `DeferredLocalResource.ValidateCheckout`
 rather than at `Validate`, which core does not call there.

- **The `.JavaScript` and `.Java` satellite packages are gone. Their kinds ship in
 `KoalaSoft.Aspire.Hosting.ServiceSources`, and your AppHost references Aspire's hosting
 package for the language directly** (#187). To migrate, drop the satellite, reference core,
 and add the hosting package you actually use:

 ```bash
 dotnet remove package KoalaSoft.Aspire.Hosting.ServiceSources.JavaScript
 dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources
 dotnet add package Aspire.Hosting.JavaScript
 ```

 | `kind` | Package to reference | Minimum |
 | --- | --- | --- |
 | `java` | `CommunityToolkit.Aspire.Hosting.Java` | 13.3.0 |
 | `javascript` | `Aspire.Hosting.JavaScript` | 13.5.2 |

 Nothing in `servicesources.yaml`, `servicesources.local.json`, the options blocks or the public
 API changes: `UseJavaScript()` and `UseJava()` keep their names, signatures and namespace, and a
 guest-language AppHost keeps `useJavaScript()`/`useJava()`. Only where they come from changes.

 Leaving a satellite reference in place does not keep working — it resolves a core that no longer
 has a satellite to pair with. There is nothing to keep in step any more, though: the `NU1107`
 lockstep between core and a satellite (#79) is gone with them, and the version of the hosting
 package is yours to pick at or above the minimum above.

 The dependency isolation the satellites existed for (#89) is unchanged, and is now enforced
 without a package: core compiles against both hosting packages but references them with
 `PrivateAssets="all"`, so they appear in no nuspec and an AppHost declaring no `javascript`
 service inherits neither that package nor the Aspire floor it carries. Assemblies resolve
 per-method at JIT time, so the assembly is needed only when a service of that kind actually
 resolves.

 Forget one and you are told which. A referenced version below the minimum fails the build,
 naming the package and the version that resolved — `SERVICESOURCES001` for
 `Aspire.Hosting.JavaScript`, `SERVICESOURCES002` for `CommunityToolkit.Aspire.Hosting.Java`. A
 prerelease of the minimum counts as below it, since a preview cut before that release carries
 its assembly version and would otherwise bind and then fail on a missing member. The check
 reaches a package reached transitively as well as a direct one. Missing entirely, the first
 `AddService()` for a service of that kind fails with a message naming the package to install —
 which is the only report a guest-language AppHost gets, since it consumes core through a project
 reference the Aspire CLI generates and that imports no build-time checks. Where the package
 arrived transitively and neither remedy is yours to apply, `ServiceSourcesSkipGuestLanguageFloorCheck=true`
 turns the build-time check off for that project and leaves the run-time report standing.

- **`SERVICESOURCES_GIT_TOKEN` is now scoped to one host, and requires
 `SERVICESOURCES_GIT_HOST` to be set at all** (#223). The environment-token credential helper
 used to answer every `git credential fill` request it was consulted for, regardless of which
 host git was asking about — a catalog listing services from more than one host would offer this
 token to all of them, not just the one it was meant for. It now reads the host git asks about
 and only answers when it matches `SERVICESOURCES_GIT_HOST` (host, and port if the URL has one —
 the same `host:port` shape `git credential` itself uses, case-insensitively).

 **This breaks a deployment that relies on `SERVICESOURCES_GIT_TOKEN` alone.** Without
 `SERVICESOURCES_GIT_HOST` the helper now answers nothing, where it previously answered
 everything — the safe default, not the compatible one. To migrate, set
 `SERVICESOURCES_GIT_HOST` to the host (and port) the token is for, e.g.
 `git.internal.example` or `git.internal.example:8443`. `SERVICESOURCES_GIT_USERNAME` is
 unaffected; nothing changes for a repository resolved through your own git credential helper
 (Git Credential Manager, `osxkeychain`, `libsecret`, ...) rather than this environment-variable
 rung.

### Added

- **A backing service can be reached in a dev cluster: `source: "kubernetes"`** (#144). The
 database, broker or cache a service connects to now has the third source the service side has had
 all along — a `kubectl port-forward` this AppHost opens and Aspire manages, with the connection
 string addressing its local end:

 ```jsonc
 {
   "backingServices": {
     "orders-db": {
       "source": "kubernetes",
       "kubernetes": {
         "service": "orders-pg-rw",
         "port": 5432,
         "context": "dev-west",
         "namespace": "orders",
         "connectionString": "Host=localhost;Port=${port};Database=orders;Username=dev;Password=hunter2"
       }
     }
   }
 }
 ```

 The AppHost's own code is unchanged — the same `AddBackingService("orders-db", local: …)` call,
 the same handle, and the same `ConnectionStrings__orders-db` the app reads. The `kubectl` process
 appears in the dashboard as `orders-db-tunnel`, beneath the backing service it serves, and carries
 kubectl's output.

 **`WaitFor` on a backing service means something again under this source.** The local end of the
 tunnel carries a TCP health check, and `WaitFor` waits for running *and* healthy — so a consumer
 holds back until something is actually listening. Without it the connection-string resource
 reports running as soon as its template resolves, which is immediately: measured against a tunnel
 that took 8 seconds to come up, the consumer started at 3.4s rather than 11.5s, about five seconds
 before anything was there to connect to. It is part of the source rather than an option, since a
 wait that silently does nothing is worse than no wait.

 **Write `${port}`, not a number**, and a connection string that names none is refused at startup —
 with one exception, added below in the same release: a template that is exactly one `${secret:…}`
 carries a port already.
 The local port is allocated so that two forwarded backing services cannot collide, so it is not a
 number anyone can write down — and the failure the refusal prevents is silent: `Port=5432` copied
 out of a manifest addresses that port on the developer's own machine, where their own database
 container may well be listening, and the AppHost would connect to the wrong database with every
 resource reporting healthy. The message names the shell as well as the spelling, because a
 template whose `${port}` was expanded away before the AppHost ran — `${…}` is a shell variable
 too, and double quotes do not protect it — arrives looking exactly like one that never had it.

 **Several ports go through one tunnel** (#233). `port` takes either a number or a block that
 names each port, and a connection string reaches a named one as `${port:<name>}`:

 ```jsonc
 "orders-events": {
   "source": "kubernetes",
   "kubernetes": {
     "service": "rabbitmq",
     "port": { "amqp": 5672, "management": 15672 },
     "context": "dev-west",
     "connectionString": "amqp://dev:hunter2@localhost:${port:amqp}/"
   }
 }
 ```

 One `kubectl` invocation carries every pair, because it can — measured against a two-port Service
 in `kind`, where one process forwarded both and carried real traffic on each. Two entries would
 mean two processes and two tunnels to the same Service. Each forwarded port gets its own health
 check, and all of them hang off the backing service, so a `WaitFor` waits for the whole tunnel
 rather than for whichever port happened to be registered.

 A single `port` is unchanged, and `${port}` remains its spelling. The two do not mix: `${port}`
 against a block that names its ports is refused naming the ports it forwards, and `${port:<name>}`
 against a single port is refused saying so — each with the spelling that entry actually takes,
 rather than advice that would earn a second startup failure contradicting the first.

- **A connection string can read its credentials out of a Kubernetes secret: `${secret:<name>:<key>}`**
 (#144). The password a backing service needs no longer has to be written into
 `servicesources.local.json` beside the host and the port:

 ```jsonc
 {
   "backingServices": {
     "orders-db": {
       "source": "kubernetes",
       "kubernetes": {
         "service": "orders-pg-rw",
         "port": 5432,
         "context": "dev-west",
         "namespace": "orders",
         "connectionString": "Host=localhost;Port=${port};Database=orders;Username=dev;Password=${secret:orders-pg-app:password}"
       }
     }
   }
 }
 ```

 **The fetch is deferred, not synchronous.** Each placeholder becomes a parameter whose value is
 read when something first asks for it, rather than while the AppHost is being composed — so a
 developer who has not logged in to the cluster yet gets one failed parameter in the dashboard
 instead of an AppHost that will not start, and nothing runs `kubectl` on the path that local
 project resolution was deliberately moved off. The value is marked secret, so the dashboard masks
 it. Reading the same placeholder twice fetches once.

 **A secret holding the whole connection string works too**, which is the shape a hand-authored
 Sealed Secret usually has — there are no per-field keys to fall back on, and re-shaping one means
 re-sealing against the cluster's key and a commit to a repo a platform team often owns. Write the
 template as exactly one placeholder:

 ```jsonc
 {
   "backingServices": {
     "orders-db": {
       "source": "kubernetes",
       "kubernetes": {
         "service": "orders-pg-rw",
         "port": 5432,
         "context": "dev-west",
         "namespace": "orders",
         "connectionString": "${secret:orders-cs:connectionString}"
       }
     }
   }
 }
 ```

 Then the port-forward listens on the *same* port `port` names rather than an allocated one,
 because there is nothing in the template to substitute a local port into, and the in-cluster host
 the secret was written against — `orders-pg-rw`, `.orders`, `.svc`, or the fully qualified
 `.svc.cluster.local` — is rewritten to `localhost`. Giving up the allocated port is the real cost
 of the mode, so a local port already in use is refused by name up front rather than left to the
 tunnel's log — and, since there is then only the one number to match against, this mode is refused
 against a block that names its ports the same way `${port}` is: give it a single `port` instead.
 Two more things are refused rather than served quietly: a secret whose own port is not the one
 being forwarded — the tunnel follows `port`, not the secret, and unchecked the app would dial a
 port nothing serves while every resource reported healthy — and a secret that names the service in
 no form this can rewrite, which would otherwise reach the app still addressed at the cluster.
 Per-field placeholders stay preferred wherever the secret offers them.

- **`prepare` — a `"local"` checkout can bootstrap itself before its kind judges it** (#118). A
 managed checkout is assumed to be runnable the moment it is cloned, which is not true of a
 repository whose runnable artifact or data asset is produced by a script it commits and then
 gitignores. Such a checkout resolved cleanly and then failed, and nothing in the catalog could ask
 the repository to produce what it is perfectly capable of producing:

 ```yaml
 services:
   routing:
     repository: https://github.com/example/routing
     kind: java
     prepare:
       command: ["./prepare.sh"]
       windowsCommand: ["pwsh", "-File", "prepare.ps1"]   # optional; replaces command on Windows
       mode: oncePerCommit                                # the default | once | always | never
     java:
       jarPath: graphhopper-web-11.0.jar
       port: 8989
 ```

 The command runs inside the materialized checkout, with the checkout as its working directory and
 no shell in between, at the one point that works: after the working tree is complete and
 reconciled onto its configured `ref`, and before the kind is allowed to judge it — so a kind
 cannot reject a checkout for missing precisely the files the step was about to produce. It sits
 there on both resolution paths, and no kind knows it exists. `command` is a list rather than a
 string, so there is nothing to quote; a first element that looks like a path is confined to the
 checkout, and a bare name goes through `PATH`.

 **It runs once, not per start.** A hash of the resolved command and the commit it ran against is
 recorded — only on success — at `<checkout>/.git/servicesources-prepare.json`, which is invisible
 to the service repository's `git status` and dies with the checkout, so a deleted-and-recloned
 checkout re-prepares. `mode` picks how coarse the guard is, and the choice between `once` and
 `oncePerCommit` is the question "does the repository define this step?" rather than "how often":
 a bootstrap whose script the repository commits wants `oncePerCommit`, so a team bumping it
 reaches every developer, while one pinned by the catalog wants `once`, so a one-line README commit
 doesn't cost a four-minute graph import. `always` is for a command that decides its own work,
 which is what this delegates incremental rebuild to rather than approximating it. **Whatever the
 mode, the command has to be safe to re-run**: nothing is recorded for a step that failed halfway,
 so the next start runs it again against a checkout holding whatever the first attempt produced.

 On the run that creates the checkout under `UseDeferredCheckout()`, the step's output streams into
 the service's own resource log and the service carries a **Preparing** state while it runs, so a
 country-sized import reads as an initialization phase rather than as a hang — and a failure there
 costs that one service rather than the AppHost. Every other run reports to the AppHost's standard
 output — under `aspire run`, the documented way to start an AppHost, that means the CLI's own log
 under `~/.aspire/logs/` rather than the dashboard — **and a capped copy, its first and last lines
 with anything dropped between them marked as elided, also lands in the service's resource log**
 once the dashboard exists (#214), so the record still ends up where the service is even on a
 run the console alone would have hidden it from. **Ctrl-C during a cold checkout's bootstrap
 reaches the command's own process tree** on the run that creates it under
 `UseDeferredCheckout()`, so interrupting a long import there ends it rather than leaving it, and
 its children, running with no AppHost to belong to — composition has no token to hand the eager
 path, the one most AppHosts take by default, so Ctrl-C during every other run's bootstrap leaves
 the command orphaned instead (#279). There is no timeout: a legitimate bootstrap can take an
 hour.

 **`aspire publish` does not run it.** Publish composes the model, writes the manifest and exits,
 and a bootstrap produces what a service needs in order to *run* — so nothing in a manifest depends
 on it, and paying a multi-gigabyte download on every CI publish to emit one would be pure cost.
 The block is still validated there, so a typo'd mode or a command pointing outside the checkout
 still fails a publish; only the execution is gated. The skip is reported, because it has one
 consequence worth naming: the step runs *before* the kind judges the checkout, so a service whose
 committed files are not enough for its kind on their own — a generated `.csproj`, a generated
 project directory — is reported as missing them. Run the AppHost once, then publish.

 A service resolved through `local.path` **never inherits the catalog's block**: nothing establishes
 that the directory is even a checkout of the repository the catalog names, and it is the
 developer's own working tree. The catalog's block is ignored rather than rejected — it is the
 team's field and correct for every developer on a managed checkout — and a startup notice names
 the command that was not run, verbatim, so it can be pasted into `servicesources.local.json`. Any
 block declared there silences it, `{"mode": "never"}` included. That file can also override a
 catalog step per developer: `mode` on its own, or the `command`/`windowsCommand` pair replaced
 together.

 Not included, deliberately: no task runner, no ordering between steps, no cross-developer caching
 of what a step produced, no timeout, and no injected environment variables. One command, one
 marker, per service. See the
 [`prepare` section](README.md#prepare-a-checkout-that-has-to-bootstrap-itself).

- **`AddBackingService()` — the database, broker or cache a service connects to, source-switched
 the same way the service is** (#144). A service usually depends on a database, and a developer
 wants the same choice for it that they have for the service: run it locally, or connect to the one
 already running. The AppHost declares it once, and each developer decides in their own
 `servicesources.local.json`:

 ```csharp
 var ordersDb = builder.AddBackingService("orders-db",
     local: () => builder.AddPostgres("orders-pg").AddDatabase("orders-db", "orders"));

 builder.AddService("orders")
     .Configure<IResourceWithEnvironment>(r => r.WithReference(ordersDb))
     .Configure<IResourceWithWaitSupport>(r => r.WaitFor(ordersDb));
 ```

 **Known limitation: that `WaitFor` stops meaning anything under `"direct"`** (#220). It waits
 properly under `"local"`, where a real database resource sits behind it. Under `"direct"` the
 resource is a connection string, which Aspire marks running as soon as the string is available, so
 the wait is satisfied at once and the consumer starts without the instance you pointed at having
 been checked. It does not hang and nothing fails; the ordering simply stops being enforced.

 Two sources ship in this release, configured under a new `backingServices:` section read through
 the same configuration layers as `services:`. `"local"` runs the factory the AppHost supplied and
 returns its result unchanged — it is the default, so a backing service with no entry runs as the
 AppHost reads. `"direct"` connects to a `direct.connectionString` the developer supplies, which
 covers both a database they started by hand and a cluster database published through an ingress:
 from the AppHost's side both are an address with no process to manage. A `"kubernetes"` source
 that opens a `kubectl port-forward` is the next stage of the same issue and is not in this
 release.

 There is no catalog side to this. A backing service is declared by the `AddBackingService()` call
 itself, so `servicesources.yaml` is untouched — and an AppHost that only connects to a database
 needs no catalog file at all. Local provisioning stays the AppHost's own code, because
 `builder.AddPostgres(...)` already expresses it better than catalog fields would; that also means
 it works unchanged for anything carrying a connection string, `AddRabbitMQ` and `AddRedis`
 included.

 **The resource your local factory returns must be named after the backing service**, and
 `AddBackingService` refuses it otherwise (#200). Aspire's `WithReference(...)` keys the
 connection string on the referenced resource's own name, which under `"local"` is whatever the
 factory built — so `() => builder.AddPostgres("pg").AddDatabase("orders")` behind a backing
 service called `orders-db` would give a consumer `ConnectionStrings__orders` locally and
 `ConnectionStrings__orders-db` under `"direct"`, moving the key the app reads when the developer
 switches, with only the app to report it. `AddDatabase("orders-db", "orders")` names the resource
 and the database separately. Casing counts: .NET folds it when reading configuration, but the
 environment variable itself does not, and this package runs JavaScript and Java services, where
 `process.env` and `System.getenv` are case-sensitive.

 A consumer that needs a particular key can still pin it from its own side —
 `WithReference(ordersDb, "OrdersDb")` gives `ConnectionStrings__OrdersDb` under every source. That
 is not a way around the rule: the exported shim takes the source alone, so a guest-language AppHost
 has no such argument (#209). That is this package's own gap and not a limit of guest languages —
 a project's `withReference` already accepts `{ connectionName }` from TypeScript — but until the
 shim offers the same, renaming the factory's resource is the one remedy every AppHost has, which
 is why it is the one enforced.

 **Write `direct.connectionString` as an address reached from outside Aspire.** It is handed on as
 written; nothing about it is rewritten. The case that catches people out is pointing `"direct"` at
 a container the same AppHost runs: the host and port the dashboard and `aspire describe` report
 for a container endpoint belong to Aspire's endpoint proxy, which lives only as long as that
 AppHost and is reassigned on the next start — not to the container's own published port.

 `direct.connectionString` is normally a literal, and **braces reserve nothing** — `Driver={PostgreSQL}`,
 `Server={host}\instance` and `PWD={secret}` all reach the app exactly as written, doubled braces
 included, so ODBC's own doubling rule stays intact: `PWD={pa}}ss}` is the password `pa}ss` and
 remains it. Placeholders open on `${`, which no connection-string dialect uses. `${port}` and
 `${secret:<name>:<key>}` are recognised, reserved for the sources that can resolve them, and
 rejected under `"direct"` with a message saying why; a malformed one fails at startup naming the
 backing service and the key, quoting the spelling you wrote, rather than reaching the app as text.
 A `${` begins a placeholder only when the word after it — up to the first `:` or `}`, or to the
 end — is *exactly* `port` or `secret`, in any casing. Equality rather than a prefix, so
 `${portal}`, `${secretariat}`, `${secrets:a}` and `${DB_PASS}` are text, which keeps a connection
 string working when something else in your toolchain expands `${…}`. What stays reserved is
 `${port}` and `${secret:…}` themselves, which cannot be written as literal text and have no
 escape; `$` is not otherwise special, so `$${port}` is available as one if anything ever needs it
 (#207).

 **Setting a template through an environment variable needs single quotes.** `${…}` is also what a
 POSIX shell, docker-compose and a GitHub Actions `run:` block expand, and double quotes do not
 protect it — they cover the `;` and not the `${`. A template that loses its placeholder that way
 arrives as a valid template with no placeholder in it, which nothing can report. The file,
 appsettings and user secrets are unaffected, and are where a template normally lives.

 A `source` of nothing but whitespace is refused rather than read as the default, the same way a
 whitespace *field* has been refused since `0.4.0` and for the same reason: it is the empty
 spelling that unsets a key, missed by a character. This also applies to a service's `source`,
 which previously reported having no source configured without mentioning the spaces that caused
 it.

 **Configuration that nothing reads is reported** (#206). A backing service with no entry
 legitimately runs locally, so an entry whose key matches no `AddBackingService()` call cannot be
 told apart at read time from one that was never written — a typo in the *key* (`orders_db` against
 `orders-db`) would silently revert that backing service to `"local"`, starting the container the
 developer was trying to avoid. Once the AppHost is composed the declared names are known, so this
 is warned about at startup, naming the entry and the declared name it resembles. A misspelled
 `backingServices` root key, which does the same to every backing service at once, is warned about
 the same way. Warnings rather than errors, because a shared file may legitimately carry entries
 for backing services only some configurations add; there is no opt-out.

 **The `services:` side of the same problem is warned about too** (#215). A `services` entry
 naming no service `servicesources.yaml` declares used to be silently skipped — a typo one edit from
 a real service name (`planning-fronend` against `planning-frontend`) bound, validated, and was
 never looked up, with nothing to say so. Reported at the same `BeforeStartEvent` point as the
 backing-service check above, naming the entry and the catalog name it resembles. A misspelled
 `services` root key is warned about the same way. Left out on purpose: an entry the catalog *does*
 declare but that no `AddService()` call adds is not silent today — `LocalCheckoutPrefetch` already
 reports the cost of cloning it speculatively — so that half stays with the over-cloning work it
 belongs to (#217). Warnings rather than errors, and again no opt-out, for the same reason as the
 backing-service side.

- **A service whose resource never runs is reported in the AppHost's own console** (#150). A
 `"local"` checkout that fails to compile used to produce nothing there at all: Aspire's build of
 a checkout is `dotnet run`'s own, so the compiler's output goes to that resource's console in the
 dashboard, and from the terminal the service simply never appeared. There is now one line per
 failing resource, naming the service, naming the state Aspire reported for it, and pointing at
 the dashboard:

 ```text
 fail: Aspire.Hosting.ServiceSources[0]
       Service 'orders' is configured as 'local' and its resource is not running: it reported
       'Finished' with exit code 1. This console does not carry that resource's output, so
       nothing here says why — its own console in the Aspire dashboard does, at the dashboard
       URL logged above. …
 ```

 Read off the resource's state rather than off any one failure path, so it covers a build that
 won't compile, a deferred clone that never landed, and a service of any source — not `"local"`
 alone. Reported for `FailedToStart` and for a terminal state with a non-zero exit code, one line
 per failing replica.

 It errs towards silence rather than towards a false alarm, since a channel that sometimes lies is
 one developers learn to ignore. Not reported: a terminal state whose exit code was never
 reported, an orderly Ctrl-C, a resource stopped from the dashboard, and `RuntimeUnhealthy` —
 which names an unreachable container runtime rather than a failed service, and which an AppHost
 started before its container runtime is up reports for every container-backed service before
 starting them all normally. It says only *that* the service isn't running: the output that says
 why belongs to the process Aspire launched, whose streams this package does not own. Needs no
 opt-in, and nothing about how a failure reaches the dashboard changes.

- **`withServiceConnectionString` takes a `connectionName`** (#209). A guest-language AppHost
 previously had no way to choose the environment variable a connection string arrives under — the
 exported shim's `WithReference` had no such argument, so the key always followed the referenced
 resource's own name. The only remedy was renaming that resource, which is not always available:
 an app whose configuration already reads a particular name, or a resource that is not the
 caller's to rename, had nothing to reach for. TypeScript now takes the same argument a project's
 `withReference` already does:

 ```typescript
 service.withServiceConnectionString(db, { connectionName: 'OrdersDb' });
 // ConnectionStrings__OrdersDb, regardless of db's own resource name
 ```

 `connectionName` is optional and defaults to the source resource's own name, matching today's
 behavior when omitted.

- **`withServiceHttpsEndpoint()` and `withServiceHttpEndpoint()`** (#208). The exported shim set
 covered environment, references, waits and args, but nothing let a guest-language AppHost declare
 an endpoint on the resolved service — every other shape has a TypeScript equivalent of
 `Configure<T>`, this one didn't. It becomes load-bearing the moment a deferred checkout is the
 default: Aspire reads endpoints from a launch profile while composing, and a first-run checkout
 that hasn't landed on disk yet has no launch profile for it to read, so nothing declares the
 endpoint unless the AppHost does.

 ```typescript
 builder.addService('common-auth').withServiceHttpsEndpoint();
 ```

 Like every shim here, it delegates to `Configure<IResourceWithEndpoints>`, so it inherits that
 method's skip-and-warn behavior for free: safe to write unconditionally, since a service a
 developer has switched to `url` or `kubernetes` in `servicesources.local.json` is skipped rather
 than misconfigured.

### Changed

- **`javascript.appDirectory` and `javascript.scriptPath` are now confined to the checkout by the
 same lexical check as `project`, `prepare.command` and every `java.*` path** (#235). They used
 to run a resolved check of their own — `Path.GetFullPath` followed by a root-prefix comparison —
 which disagreed with the lexical one in three ways, all now gone: an absolute value resolved and
 was reported as merely "outside the checkout" rather than named as absolute; a path segment made
 only of dots and spaces (see #241) was never checked here, so it reached these two paths where
 every other confined path already refused it; and a value written with `\` separators was never
 normalized, so `appDirectory: src\frontend` resolved to a single oddly-named directory on Linux
 and macOS instead of `src/frontend`.

… truncated for nuget.org's 35000-character release notes limit. Full entry in CHANGELOG.md.

Full changelog: https://github.com/flojon/aspire-servicesources/blob/main/CHANGELOG.md