From cb5b28042264336e56562e72f8a76391c389bd98 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 16:11:41 -0400 Subject: [PATCH 01/48] Add aspire-init-typescript and aspire-init-csharp skills These are one-time skills that complete the Aspire initialization after `aspire init` drops the skeleton apphost and aspire.config.json. The agent runs the appropriate skill to scan the repo, wire up projects, configure dependencies, and validate that `aspire start` works. The skills self-remove on success. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init-csharp/SKILL.md | 284 ++++++++++++++++++ .../skills/aspire-init-typescript/SKILL.md | 270 +++++++++++++++++ 2 files changed, 554 insertions(+) create mode 100644 .agents/skills/aspire-init-csharp/SKILL.md create mode 100644 .agents/skills/aspire-init-typescript/SKILL.md diff --git a/.agents/skills/aspire-init-csharp/SKILL.md b/.agents/skills/aspire-init-csharp/SKILL.md new file mode 100644 index 00000000000..8030be42d2a --- /dev/null +++ b/.agents/skills/aspire-init-csharp/SKILL.md @@ -0,0 +1,284 @@ +--- +name: aspire-init-csharp +description: "One-time skill for completing Aspire initialization in a C# AppHost workspace. Run this after `aspire init` has dropped the skeleton apphost.cs and aspire.config.json. This skill scans the repository for projects, wires up the AppHost, creates ServiceDefaults, configures project references, and validates that `aspire start` works. Self-removes on success." +--- + +# Aspire Init — C# AppHost + +This is a **one-time setup skill**. It completes the Aspire initialization that `aspire init` started. After this skill finishes successfully, it should be deleted — the evergreen `aspire` skill handles ongoing AppHost work. + +## Prerequisites + +Before running this skill, `aspire init` must have already: + +- Dropped a skeleton `apphost.cs` (single-file) or an AppHost project directory (if a .sln was found) +- Created `aspire.config.json` at the repository root + +Verify both exist before proceeding. + +## Understanding the two modes + +`aspire init` drops different things depending on whether a solution file was found: + +- **No .sln/.slnx**: A single-file `apphost.cs` using the `#:sdk` directive. This is a self-contained file with no project directory. +- **With .sln/.slnx**: A full AppHost project directory containing a `.csproj` and `apphost.cs`. This project has been added to the solution. + +Check which mode you're in by looking at `aspire.config.json` — the `appHost.path` field tells you. + +## Workflow + +Follow these steps in order. If any step fails, diagnose and fix before continuing. + +### Step 1: Scan the repository + +Analyze the repository to discover all projects and services that could be modeled in the AppHost. + +**For .NET projects:** + +Find all `*.csproj` and `*.fsproj` files. For each, determine: + +- **OutputType**: Run `dotnet msbuild -getProperty:OutputType` — `Exe` or `WinExe` means it's a runnable service +- **TargetFramework**: Run `dotnet msbuild -getProperty:TargetFramework` — must be `net8.0` or newer +- **IsAspireHost**: Run `dotnet msbuild -getProperty:IsAspireHost` — skip if `true` (that's the AppHost itself) + +Classify each project: + +- **Runnable services**: OutputType is `Exe`/`WinExe`, TFM is net8.0+, not an AppHost +- **Class libraries**: OutputType is `Library` — these are not modeled in the AppHost directly +- **Test projects**: skip (directories named `test`/`tests`, or projects referencing xUnit/NUnit/MSTest) + +**For non-.NET projects:** + +Also look for: + +- **Node.js/TypeScript apps**: directories with `package.json` + start script +- **Python apps**: directories with `pyproject.toml` or `requirements.txt` + entry point +- **Dockerfiles**: standalone `Dockerfile` entries that represent services +- **Docker Compose**: `docker-compose.yml` entries (note: these may need manual translation) + +### Step 2: Present findings and confirm with the user + +Show the user what you found. For each discovered project/service, show: + +- Name (project name or directory name) +- Type (.NET service, Node.js app, Dockerfile, etc.) +- Framework/TFM (e.g., net10.0, Node 20, Python 3.12) +- Whether it exposes HTTP endpoints + +Ask the user: + +1. Which projects to include in the AppHost (pre-select all runnable .NET services) +2. Which projects should receive ServiceDefaults references (pre-select all .NET services) + +### Step 3: Create ServiceDefaults project + +If no ServiceDefaults project exists in the repo, create one using the dotnet template: + +```bash +dotnet new aspire-servicedefaults -n .ServiceDefaults -o +``` + +Where `` is alongside the AppHost (e.g., `src/` or solution root). + +If a solution file exists, add the ServiceDefaults project to it: + +```bash +dotnet sln add +``` + +If a ServiceDefaults project already exists (look for a project that references `Microsoft.Extensions.ServiceDiscovery` or `Aspire.ServiceDefaults`), skip creation and use the existing one. + +### Step 4: Wire up the AppHost + +Edit the `apphost.cs` to add resource definitions for each selected project. + +**Single-file mode** (no solution): + +```csharp +#:sdk Aspire.AppHost.Sdk@ +#:property IsAspireHost=true + +// Project references +#:project ../src/Api/Api.csproj +#:project ../src/Web/Web.csproj + +var builder = DistributedApplication.CreateBuilder(args); + +var api = builder.AddProject("api"); + +var web = builder.AddProject("web") + .WithReference(api) + .WaitFor(api); + +builder.Build().Run(); +``` + +**Full project mode** (with solution): + +Edit the `apphost.cs` in the AppHost project: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var api = builder.AddProject("api"); + +var web = builder.AddProject("web") + .WithReference(api) + .WaitFor(api); + +builder.Build().Run(); +``` + +And add project references to the AppHost `.csproj`: + +```bash +dotnet add reference +dotnet add reference +``` + +**For non-.NET services in a C# AppHost**, use the appropriate hosting integration: + +```csharp +// Node.js app (requires Aspire.Hosting.NodeJs package) +var frontend = builder.AddNpmApp("frontend", "../frontend", "start"); + +// Dockerfile-based service +var worker = builder.AddDockerfile("worker", "../worker"); + +// Python app (requires Aspire.Hosting.Python package) +var pyApi = builder.AddPythonApp("py-api", "../py-api", "app.py"); +``` + +For non-.NET resources, add the required hosting NuGet packages: + +```bash +dotnet add package Aspire.Hosting.NodeJs +dotnet add package Aspire.Hosting.Python +``` + +**Important rules:** + +- Use `aspire docs search` and `aspire docs get` to look up the correct builder API for each resource type before writing code. Do not guess API shapes. +- Use meaningful resource names derived from the project name. +- Wire up `WithReference()` and `WaitFor()` for services that depend on each other (ask the user if dependency relationships are unclear). + +### Step 5: Add ServiceDefaults references + +For each .NET project that the user selected for ServiceDefaults: + +```bash +dotnet add reference +``` + +Then check each project's `Program.cs` (or equivalent entry point) and add the ServiceDefaults call if not already present: + +```csharp +builder.AddServiceDefaults(); +``` + +This should be added early in the builder pipeline, before `builder.Build()`. Look for the `WebApplicationBuilder` or `HostApplicationBuilder` creation and add it after. + +Also add the corresponding endpoint mapping before `app.Run()`: + +```csharp +app.MapDefaultEndpoints(); +``` + +**Important**: Be careful with code placement. Look at the existing code structure: + +- If using top-level statements, add `builder.AddServiceDefaults()` after `var builder = WebApplication.CreateBuilder(args);` +- If using `Startup.cs` pattern, add to `ConfigureServices` +- If using `Program.Main` method, add in the appropriate location +- Do not duplicate if already present + +### Step 6: Wire up OpenTelemetry for non-.NET services + +For non-.NET services included in the AppHost, OpenTelemetry should be configured so the Aspire dashboard can show their traces, metrics, and logs. This is the equivalent of what ServiceDefaults does for .NET. + +**Node.js/TypeScript services:** + +Suggest adding OpenTelemetry packages: + +```bash +npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-otlp-grpc +``` + +And instrumentation setup that reads the `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable (injected by Aspire automatically). + +**Python services:** suggest `opentelemetry-distro` and `opentelemetry-exporter-otlp`. + +**Important**: Ask the user before modifying any non-.NET service code. OTel setup may conflict with existing instrumentation. + +### Step 7: Configure NuGet (if needed) + +If the AppHost uses a non-stable Aspire SDK channel (preview, daily, etc.), ensure the appropriate NuGet feed is configured: + +Check `aspire.config.json` for the `channel` field. If it's not `stable`, a `NuGet.config` may need to be created or updated with the appropriate feed URL. + +For single-file mode, this is handled automatically by the `#:sdk` directive. For full project mode, ensure the NuGet.config is in scope. + +### Step 8: Trust development certificates + +```bash +aspire certs trust +``` + +This ensures HTTPS works locally for the Aspire dashboard and service-to-service communication. + +### Step 9: Validate + +```bash +aspire start +``` + +Wait for the AppHost to start. Check that: + +1. The dashboard URL is printed +2. All modeled resources appear in `aspire describe` +3. No startup errors in `aspire logs` +4. .NET services show health check endpoints (from ServiceDefaults) + +If `aspire start` fails: + +1. Read the error output carefully +2. Check `aspire logs` for resource-specific failures +3. Common issues: + - Missing project references — ensure all `dotnet add reference` commands succeeded + - Missing NuGet packages — run `dotnet restore` on the AppHost + - TFM mismatches — ensure all referenced projects target compatible frameworks + - Build errors — run `dotnet build` on the AppHost project to see compiler output + - Port conflicts — check for hardcoded ports that clash + +Iterate until `aspire start` succeeds and all resources are healthy. + +### Step 10: Update solution file (if applicable) + +If a solution file exists, verify all new projects are included: + +```bash +dotnet sln list +``` + +Ensure both the AppHost and ServiceDefaults projects appear. If not, add them: + +```bash +dotnet sln add +dotnet sln add +``` + +### Step 11: Clean up + +After successful validation: + +1. Stop the running AppHost: `aspire stop` +2. **Delete this skill** — remove the `aspire-init-csharp/` skill directory from all locations where it was installed (check `.agents/skills/`, `.github/skills/`, `.claude/skills/`) +3. Confirm the evergreen `aspire` skill is present for ongoing AppHost work + +## Key rules + +- **Use `aspire docs search` before guessing APIs** — look up the correct builder methods for unfamiliar resource types +- **Ask the user before modifying service code** (especially for ServiceDefaults injection and OTel setup) +- **Respect existing project structure** — don't reorganize the repo, work with what's there +- **This is a one-time skill** — delete it after successful init +- **If stuck, use `aspire doctor`** to diagnose environment issues +- **For C# APIs, use `dotnet-inspect` skill** if available to verify method signatures and overloads diff --git a/.agents/skills/aspire-init-typescript/SKILL.md b/.agents/skills/aspire-init-typescript/SKILL.md new file mode 100644 index 00000000000..5d4d40ff798 --- /dev/null +++ b/.agents/skills/aspire-init-typescript/SKILL.md @@ -0,0 +1,270 @@ +--- +name: aspire-init-typescript +description: "One-time skill for completing Aspire initialization in a TypeScript AppHost workspace. Run this after `aspire init` has dropped the skeleton apphost.ts and aspire.config.json. This skill scans the repository, wires up projects in the AppHost, configures package.json/tsconfig/eslint, sets up OpenTelemetry for non-.NET services, installs dependencies, and validates that `aspire start` works. Self-removes on success." +--- + +# Aspire Init — TypeScript AppHost + +This is a **one-time setup skill**. It completes the Aspire initialization that `aspire init` started. After this skill finishes successfully, it should be deleted — the evergreen `aspire` skill handles ongoing AppHost work. + +## Prerequisites + +Before running this skill, `aspire init` must have already: + +- Dropped a skeleton `apphost.ts` at the configured location +- Created `aspire.config.json` at the repository root + +Verify both files exist before proceeding. + +## Workflow + +Follow these steps in order. If any step fails, diagnose and fix before continuing. + +### Step 1: Scan the repository + +Analyze the repository to discover all projects and services that could be modeled in the AppHost. + +Look for: + +- **Node.js/TypeScript apps**: directories with `package.json` containing a `start` script, `dev` script, or `main`/`module` entry point +- **.NET projects**: `*.csproj` or `*.fsproj` files (check `OutputType` — `Exe`/`WinExe` are runnable services) +- **Python apps**: directories with `pyproject.toml`, `requirements.txt`, or a `main.py`/`app.py` entry point +- **Go apps**: directories with `go.mod` +- **Java apps**: directories with `pom.xml` or `build.gradle` +- **Dockerfiles**: standalone `Dockerfile` or `docker-compose.yml` entries that represent services +- **Static frontends**: directories with Vite, Next.js, Create React App, or other frontend framework configs + +Ignore: + +- The AppHost directory itself +- `node_modules/`, `.modules/`, `dist/`, `build/`, `bin/`, `obj/`, `.git/` +- Test projects (directories named `test`, `tests`, `__tests__`, or with test-only package.json scripts) + +### Step 2: Present findings and confirm with the user + +Show the user what you found. For each discovered project/service, show: + +- Name (directory name or project name) +- Type (Node.js app, .NET service, Python app, Dockerfile, etc.) +- Entry point (e.g., `src/index.ts`, `Program.cs`, `app.py`) +- Whether it exposes HTTP endpoints (check for `express`, `fastify`, `koa`, `next`, `vite`, ASP.NET, Flask, etc.) + +Ask the user which projects to include in the AppHost. Pre-select all discovered runnable services. + +### Step 3: Wire up apphost.ts + +Edit the skeleton `apphost.ts` to add resource definitions for each selected project. Use the appropriate Aspire builder methods: + +```typescript +import { createBuilder } from './.modules/aspire.js'; + +const builder = await createBuilder(); + +// Example patterns — use the appropriate one for each discovered project type: + +// Node.js/TypeScript app +const api = await builder + .addNodeApp("api", "./api", "src/index.ts") + .withHttpEndpoint({ env: "PORT" }); + +// Vite frontend +const frontend = await builder + .addViteApp("frontend", "./frontend") + .withReference(api) + .waitFor(api); + +// .NET project +const dotnetSvc = await builder + .addProject("catalog", "./src/Catalog/Catalog.csproj"); + +// Dockerfile-based service +const worker = await builder + .addDockerfile("worker", "./worker"); + +// Python app +const pyApi = await builder + .addPythonApp("py-api", "./py-api", "app.py"); + +await builder.build().run(); +``` + +**Important rules:** + +- Use `aspire docs search` and `aspire docs get` to look up the correct builder API for each resource type before writing code. Do not guess API shapes. +- Check `.modules/aspire.ts` (after Step 5) to confirm available APIs. +- Use meaningful resource names derived from the directory/project name. +- Wire up `withReference()` and `waitFor()` for services that depend on each other (ask the user if dependency relationships are unclear). +- Expose HTTP endpoints with `withHttpEndpoint()` for services that serve HTTP traffic. +- Use `withExternalHttpEndpoints()` for user-facing frontends. + +### Step 4: Configure package.json + +If a root `package.json` already exists, **augment it** — do not overwrite. Add: + +```json +{ + "type": "module", + "scripts": { + "start": "npx tsc && node --enable-source-maps apphost.js" + }, + "dependencies": { + // Added by aspire restore — do not manually add Aspire packages + } +} +``` + +If no root `package.json` exists, create one with: + +```json +{ + "name": "-apphost", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "npx tsc && node --enable-source-maps apphost.js" + } +} +``` + +**Important rules:** + +- Never overwrite existing `scripts`, `dependencies`, or `devDependencies` — merge only. +- Set `"type": "module"` if not already set (required for ESM imports in apphost.ts). +- Do not manually add Aspire SDK packages to dependencies — `aspire restore` handles this. + +### Step 5: Run aspire restore + +```bash +aspire restore +``` + +This generates the `.modules/` directory with TypeScript SDK bindings. After restore completes, inspect `.modules/aspire.ts` to confirm the available API surface matches what you used in apphost.ts. + +If restore fails, diagnose the error. Common issues: + +- Missing `aspire.config.json` — ensure it exists at repo root +- Wrong `appHost.path` in config — ensure it points to the correct `apphost.ts` +- Network issues downloading SDK packages + +### Step 6: Configure tsconfig.json + +If a root `tsconfig.json` already exists, augment it to include the AppHost compilation: + +- Ensure `".modules/**/*.ts"` is in the `include` array +- Ensure `"apphost.ts"` is in the `include` array (or covered by an existing glob) +- Ensure `"module"` is set to `"nodenext"` or `"node16"` (ESM required) +- Ensure `"moduleResolution"` matches the module setting + +If no `tsconfig.json` exists, check if `aspire restore` created one. If not, create a minimal one: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "strict": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": ["apphost.ts", ".modules/**/*.ts"] +} +``` + +If the repo has a separate `tsconfig.apphost.json`, ensure it's referenced properly. If using TypeScript project references, add it as a reference. + +### Step 7: Handle ESLint configuration + +If the project uses ESLint with `typescript-eslint` project service: + +1. Check if `.eslintrc.*` or `eslint.config.*` exists +2. If it uses `parserOptions.project` or `parserOptions.projectService`, ensure the AppHost tsconfig is discoverable +3. Common fix: add `tsconfig.apphost.json` to `parserOptions.project` array, or configure `projectService.allowDefaultProject` to include `apphost.ts` + +If no ESLint config exists, skip this step. + +**Do not create ESLint configuration from scratch** — only augment existing configs to recognize the AppHost files. + +### Step 8: Wire up OpenTelemetry for non-.NET services + +For each non-.NET service included in the AppHost, configure OpenTelemetry so the Aspire dashboard can show traces, metrics, and logs. This is the equivalent of what ServiceDefaults does for .NET projects. + +**Node.js/TypeScript services:** + +Check if the service already has OpenTelemetry configured. If not, suggest adding: + +```bash +npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-otlp-grpc +``` + +And an instrumentation file (e.g., `instrumentation.ts`): + +```typescript +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-otlp-grpc'; + +const sdk = new NodeSDK({ + traceExporter: new OTLPTraceExporter(), + instrumentations: [getNodeAutoInstrumentations()], +}); + +sdk.start(); +``` + +The OTLP endpoint URL is injected by Aspire via environment variables — the service just needs to read `OTEL_EXPORTER_OTLP_ENDPOINT`. + +**Python services**: suggest `opentelemetry-distro` and `opentelemetry-exporter-otlp`. + +**Other languages**: point the user to the OpenTelemetry docs for their language and note that the OTLP endpoint will be injected via environment variables by Aspire. + +**Important**: Ask the user before modifying any service code. OTel setup may conflict with existing instrumentation. Present it as a recommendation, not an automatic change. + +### Step 9: Install dependencies + +```bash +npm install +``` + +Run this from the repo root (or wherever package.json lives) to install all dependencies including any added by aspire restore. + +### Step 10: Validate + +```bash +aspire start +``` + +Wait for the AppHost to start. Check that: + +1. The dashboard URL is printed +2. All modeled resources appear in `aspire describe` +3. No startup errors in `aspire logs` + +If `aspire start` fails: + +1. Read the error output carefully +2. Check `aspire logs` for resource-specific failures +3. Common issues: + - Missing dependencies — run `npm install` again + - TypeScript compilation errors — check tsconfig and fix type issues + - Port conflicts — ensure no hardcoded ports clash + - Missing environment variables — check if services need specific env vars + +Iterate until `aspire start` succeeds and all resources are healthy. + +### Step 11: Clean up + +After successful validation: + +1. Stop the running AppHost: `aspire stop` +2. **Delete this skill** — remove the `aspire-init-typescript/` skill directory from all locations where it was installed (check `.agents/skills/`, `.github/skills/`, `.claude/skills/`) +3. Confirm the evergreen `aspire` skill is present for ongoing AppHost work + +## Key rules + +- **Never overwrite existing files** — always augment/merge +- **Use `aspire docs search` before guessing APIs** — look up the correct builder methods +- **Ask the user before modifying service code** (especially for OTel setup) +- **This is a one-time skill** — delete it after successful init +- **If stuck, use `aspire doctor`** to diagnose environment issues From 4fd6956f1281a2feb0edb99fc2ddbe2dae97c284 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 16:25:12 -0400 Subject: [PATCH 02/48] Register aspire-init skills in CLI infrastructure Add AspireInitTypeScript and AspireInitCSharp skill definitions to SkillDefinition.cs with embedded resource roots. Add resource strings, embed skill files in the csproj, and update the evergreen aspire skill to reference the new init skills. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire/SKILL.md | 2 +- .../Agents/CommonAgentApplicators.cs | 20 +++++++++++++ src/Aspire.Cli/Agents/SkillDefinition.cs | 28 ++++++++++++++++++- src/Aspire.Cli/Aspire.Cli.csproj | 6 ++++ .../Resources/AgentCommandStrings.Designer.cs | 18 ++++++++++++ .../Resources/AgentCommandStrings.resx | 6 ++++ .../Resources/xlf/AgentCommandStrings.cs.xlf | 10 +++++++ .../Resources/xlf/AgentCommandStrings.de.xlf | 10 +++++++ .../Resources/xlf/AgentCommandStrings.es.xlf | 10 +++++++ .../Resources/xlf/AgentCommandStrings.fr.xlf | 10 +++++++ .../Resources/xlf/AgentCommandStrings.it.xlf | 10 +++++++ .../Resources/xlf/AgentCommandStrings.ja.xlf | 10 +++++++ .../Resources/xlf/AgentCommandStrings.ko.xlf | 10 +++++++ .../Resources/xlf/AgentCommandStrings.pl.xlf | 10 +++++++ .../xlf/AgentCommandStrings.pt-BR.xlf | 10 +++++++ .../Resources/xlf/AgentCommandStrings.ru.xlf | 10 +++++++ .../Resources/xlf/AgentCommandStrings.tr.xlf | 10 +++++++ .../xlf/AgentCommandStrings.zh-Hans.xlf | 10 +++++++ .../xlf/AgentCommandStrings.zh-Hant.xlf | 10 +++++++ 19 files changed, 208 insertions(+), 2 deletions(-) diff --git a/.agents/skills/aspire/SKILL.md b/.agents/skills/aspire/SKILL.md index 3ea0280344a..787cbe71186 100644 --- a/.agents/skills/aspire/SKILL.md +++ b/.agents/skills/aspire/SKILL.md @@ -12,7 +12,7 @@ Resources are typically defined in an AppHost such as, `AppHost.cs`, `apphost.ts ## Use this skill for - Starting, restarting, and stopping AppHosts with `aspire start` and `aspire stop` -- Initializing Aspire in an existing app with `aspire init` +- Initializing Aspire in an existing app with `aspire init` (drops skeleton files; use the `aspire-init-typescript` or `aspire-init-csharp` skill to complete wiring) - Inspecting resources, logs, traces, and docs - Adding integrations with `aspire add` - Recovering missing TypeScript AppHost support files with `aspire restore` diff --git a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs index 145c894bf32..2ee21350c90 100644 --- a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs +++ b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs @@ -20,6 +20,26 @@ internal static class CommonAgentApplicators /// internal const string AspireSkillResourceRoot = "skills.aspire"; + /// + /// The name of the Aspire init TypeScript skill. + /// + internal const string AspireInitTypeScriptSkillName = "aspire-init-typescript"; + + /// + /// The embedded resource root for the Aspire init TypeScript skill. + /// + internal const string AspireInitTypeScriptSkillResourceRoot = "skills.aspire-init-typescript"; + + /// + /// The name of the Aspire init C# skill. + /// + internal const string AspireInitCSharpSkillName = "aspire-init-csharp"; + + /// + /// The embedded resource root for the Aspire init C# skill. + /// + internal const string AspireInitCSharpSkillResourceRoot = "skills.aspire-init-csharp"; + /// /// The name of the dotnet-inspect skill. /// diff --git a/src/Aspire.Cli/Agents/SkillDefinition.cs b/src/Aspire.Cli/Agents/SkillDefinition.cs index f5319c54729..bdefefc0b64 100644 --- a/src/Aspire.Cli/Agents/SkillDefinition.cs +++ b/src/Aspire.Cli/Agents/SkillDefinition.cs @@ -46,6 +46,32 @@ internal sealed class SkillDefinition isDefault: false, applicableLanguages: [KnownLanguageId.CSharp]); + /// + /// One-time skill for completing Aspire initialization in a TypeScript AppHost workspace. + /// Installed by aspire init when the user selects TypeScript. + /// + public static readonly SkillDefinition AspireInitTypeScript = new( + CommonAgentApplicators.AspireInitTypeScriptSkillName, + AgentCommandStrings.SkillDescription_AspireInitTypeScript, + skillContent: null, + embeddedResourceRoot: CommonAgentApplicators.AspireInitTypeScriptSkillResourceRoot, + installExcludedRelativePaths: [], + isDefault: false, + applicableLanguages: [KnownLanguageId.TypeScript]); + + /// + /// One-time skill for completing Aspire initialization in a C# AppHost workspace. + /// Installed by aspire init when the user selects C#. + /// + public static readonly SkillDefinition AspireInitCSharp = new( + CommonAgentApplicators.AspireInitCSharpSkillName, + AgentCommandStrings.SkillDescription_AspireInitCSharp, + skillContent: null, + embeddedResourceRoot: CommonAgentApplicators.AspireInitCSharpSkillResourceRoot, + installExcludedRelativePaths: [], + isDefault: false, + applicableLanguages: [KnownLanguageId.CSharp]); + private SkillDefinition(string name, string description, string? skillContent, string? embeddedResourceRoot, IReadOnlyList installExcludedRelativePaths, bool isDefault, IReadOnlyList? applicableLanguages = null) { Name = name; @@ -150,5 +176,5 @@ private static bool PathMatchesOrIsUnder(string relativePath, string excludedPat /// /// Gets all available skill definitions. /// - public static IReadOnlyList All { get; } = [Aspire, PlaywrightCli, DotnetInspect]; + public static IReadOnlyList All { get; } = [Aspire, PlaywrightCli, DotnetInspect, AspireInitTypeScript, AspireInitCSharp]; } diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index a3633f025f6..dc87a449f49 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -221,6 +221,12 @@ false + + false + + + false + diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs index d0f2fbbb164..85ae817ea22 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs @@ -258,6 +258,24 @@ internal static string SkillDescription_DotnetInspect { } } + /// + /// Looks up a localized string similar to One-time setup: wire up TypeScript AppHost with discovered projects. + /// + internal static string SkillDescription_AspireInitTypeScript { + get { + return ResourceManager.GetString("SkillDescription_AspireInitTypeScript", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to One-time setup: wire up C# AppHost with discovered projects. + /// + internal static string SkillDescription_AspireInitCSharp { + get { + return ResourceManager.GetString("SkillDescription_AspireInitCSharp", resourceCulture); + } + } + /// /// Looks up a localized string similar to Standard (.agents/skills/). /// diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.resx b/src/Aspire.Cli/Resources/AgentCommandStrings.resx index 0548612ba06..86e844d8506 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.resx @@ -126,6 +126,12 @@ Query .NET API surfaces across NuGet packages and platform libraries + + One-time setup: wire up TypeScript AppHost with discovered projects + + + One-time setup: wire up C# AppHost with discovered projects + Standard (.agents/skills/) diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf index 71d9b63f4cf..b36db8f6557 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf @@ -107,6 +107,16 @@ Aspire CLI commands and workflows for distributed apps + + One-time setup: wire up C# AppHost with discovered projects + One-time setup: wire up C# AppHost with discovered projects + + + + One-time setup: wire up TypeScript AppHost with discovered projects + One-time setup: wire up TypeScript AppHost with discovered projects + + Query .NET API surfaces across NuGet packages and platform libraries Query .NET API surfaces across NuGet packages and platform libraries diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf index 720f670939a..6d350e2acd8 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf @@ -107,6 +107,16 @@ Aspire CLI commands and workflows for distributed apps + + One-time setup: wire up C# AppHost with discovered projects + One-time setup: wire up C# AppHost with discovered projects + + + + One-time setup: wire up TypeScript AppHost with discovered projects + One-time setup: wire up TypeScript AppHost with discovered projects + + Query .NET API surfaces across NuGet packages and platform libraries Query .NET API surfaces across NuGet packages and platform libraries diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf index d4cfe909ef1..0871408b1e1 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf @@ -107,6 +107,16 @@ Aspire CLI commands and workflows for distributed apps + + One-time setup: wire up C# AppHost with discovered projects + One-time setup: wire up C# AppHost with discovered projects + + + + One-time setup: wire up TypeScript AppHost with discovered projects + One-time setup: wire up TypeScript AppHost with discovered projects + + Query .NET API surfaces across NuGet packages and platform libraries Query .NET API surfaces across NuGet packages and platform libraries diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf index 7a719362e39..419f52bae33 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf @@ -107,6 +107,16 @@ Aspire CLI commands and workflows for distributed apps + + One-time setup: wire up C# AppHost with discovered projects + One-time setup: wire up C# AppHost with discovered projects + + + + One-time setup: wire up TypeScript AppHost with discovered projects + One-time setup: wire up TypeScript AppHost with discovered projects + + Query .NET API surfaces across NuGet packages and platform libraries Query .NET API surfaces across NuGet packages and platform libraries diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf index d24021b53db..a84bcf61b79 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf @@ -107,6 +107,16 @@ Aspire CLI commands and workflows for distributed apps + + One-time setup: wire up C# AppHost with discovered projects + One-time setup: wire up C# AppHost with discovered projects + + + + One-time setup: wire up TypeScript AppHost with discovered projects + One-time setup: wire up TypeScript AppHost with discovered projects + + Query .NET API surfaces across NuGet packages and platform libraries Query .NET API surfaces across NuGet packages and platform libraries diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf index 26730349062..543a9e0eb24 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf @@ -107,6 +107,16 @@ Aspire CLI commands and workflows for distributed apps + + One-time setup: wire up C# AppHost with discovered projects + One-time setup: wire up C# AppHost with discovered projects + + + + One-time setup: wire up TypeScript AppHost with discovered projects + One-time setup: wire up TypeScript AppHost with discovered projects + + Query .NET API surfaces across NuGet packages and platform libraries Query .NET API surfaces across NuGet packages and platform libraries diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf index 7e269910abf..a574c211a16 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf @@ -107,6 +107,16 @@ Aspire CLI commands and workflows for distributed apps + + One-time setup: wire up C# AppHost with discovered projects + One-time setup: wire up C# AppHost with discovered projects + + + + One-time setup: wire up TypeScript AppHost with discovered projects + One-time setup: wire up TypeScript AppHost with discovered projects + + Query .NET API surfaces across NuGet packages and platform libraries Query .NET API surfaces across NuGet packages and platform libraries diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf index 8cdbcad4d76..197e0e91503 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf @@ -107,6 +107,16 @@ Aspire CLI commands and workflows for distributed apps + + One-time setup: wire up C# AppHost with discovered projects + One-time setup: wire up C# AppHost with discovered projects + + + + One-time setup: wire up TypeScript AppHost with discovered projects + One-time setup: wire up TypeScript AppHost with discovered projects + + Query .NET API surfaces across NuGet packages and platform libraries Query .NET API surfaces across NuGet packages and platform libraries diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf index e231ff2e6b2..d055510cdb5 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf @@ -107,6 +107,16 @@ Aspire CLI commands and workflows for distributed apps + + One-time setup: wire up C# AppHost with discovered projects + One-time setup: wire up C# AppHost with discovered projects + + + + One-time setup: wire up TypeScript AppHost with discovered projects + One-time setup: wire up TypeScript AppHost with discovered projects + + Query .NET API surfaces across NuGet packages and platform libraries Query .NET API surfaces across NuGet packages and platform libraries diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf index da163e32666..ac148d1317c 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf @@ -107,6 +107,16 @@ Aspire CLI commands and workflows for distributed apps + + One-time setup: wire up C# AppHost with discovered projects + One-time setup: wire up C# AppHost with discovered projects + + + + One-time setup: wire up TypeScript AppHost with discovered projects + One-time setup: wire up TypeScript AppHost with discovered projects + + Query .NET API surfaces across NuGet packages and platform libraries Query .NET API surfaces across NuGet packages and platform libraries diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf index 9aa889cb49b..c8841b37363 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf @@ -107,6 +107,16 @@ Aspire CLI commands and workflows for distributed apps + + One-time setup: wire up C# AppHost with discovered projects + One-time setup: wire up C# AppHost with discovered projects + + + + One-time setup: wire up TypeScript AppHost with discovered projects + One-time setup: wire up TypeScript AppHost with discovered projects + + Query .NET API surfaces across NuGet packages and platform libraries Query .NET API surfaces across NuGet packages and platform libraries diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf index f932e79959a..100f373635a 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf @@ -107,6 +107,16 @@ Aspire CLI commands and workflows for distributed apps + + One-time setup: wire up C# AppHost with discovered projects + One-time setup: wire up C# AppHost with discovered projects + + + + One-time setup: wire up TypeScript AppHost with discovered projects + One-time setup: wire up TypeScript AppHost with discovered projects + + Query .NET API surfaces across NuGet packages and platform libraries Query .NET API surfaces across NuGet packages and platform libraries diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf index f73d91e03f6..5a54306ecef 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf @@ -107,6 +107,16 @@ Aspire CLI commands and workflows for distributed apps + + One-time setup: wire up C# AppHost with discovered projects + One-time setup: wire up C# AppHost with discovered projects + + + + One-time setup: wire up TypeScript AppHost with discovered projects + One-time setup: wire up TypeScript AppHost with discovered projects + + Query .NET API surfaces across NuGet packages and platform libraries Query .NET API surfaces across NuGet packages and platform libraries From 5094840c7db0d4d6e8a60b970d612acf557ebefe Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 16:33:03 -0400 Subject: [PATCH 03/48] Gut InitCommand into skill-driven skeleton launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip InitCommand from ~980 lines to ~260 lines. Remove all solution manipulation, template installation, project reference wiring, and RPC scaffolding. New flow: prompt for language, detect .sln, drop bare apphost skeleton + aspire.config.json, install the appropriate init skill, then chain to agent init. Also add Sparkles (dizzy 💫) to KnownEmojis. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/InitCommand.cs | 1009 ++++----------------- src/Aspire.Cli/Interaction/KnownEmojis.cs | 1 + 2 files changed, 164 insertions(+), 846 deletions(-) diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 0eb3f96154e..79846103f4e 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -3,122 +3,52 @@ using System.CommandLine; using System.Globalization; -using Aspire.Cli.Certificates; +using Aspire.Cli.Agents; using Aspire.Cli.Configuration; -using Aspire.Cli.DotNet; -using Aspire.Cli.Exceptions; using Aspire.Cli.Interaction; -using Aspire.Cli.NuGet; -using Aspire.Cli.Packaging; using Aspire.Cli.Projects; using Aspire.Cli.Resources; -using Aspire.Cli.Scaffolding; using Aspire.Cli.Telemetry; -using Aspire.Cli.Templating; using Aspire.Cli.Utils; -using Microsoft.Extensions.Configuration; -using NuGetPackage = Aspire.Shared.NuGetPackageCli; -using Semver; -using Spectre.Console; namespace Aspire.Cli.Commands; -internal sealed class InitCommand : BaseCommand, IPackageMetaPrefetchingCommand +/// +/// Drops a skeleton AppHost and aspire.config.json, then installs the appropriate +/// init skill for an agent to complete the wiring. This is a thin launcher — the +/// heavy lifting (project discovery, dependency configuration, validation) is +/// delegated to the aspire-init-typescript or aspire-init-csharp skill. +/// +internal sealed class InitCommand : BaseCommand { internal override HelpGroup HelpGroup => HelpGroup.AppCommands; - private readonly IDotNetCliRunner _runner; - private readonly ICertificateService _certificateService; - private readonly ITemplateVersionPrompter _templateVersionPrompter; - private readonly ITemplateProvider _templateProvider; - private readonly IPackagingService _packagingService; - private readonly ISolutionLocator _solutionLocator; - private readonly IDotNetSdkInstaller _sdkInstaller; - private readonly ICliUpdateNotifier _updateNotifier; private readonly CliExecutionContext _executionContext; - private readonly IConfigurationService _configurationService; private readonly ILanguageService _languageService; - private readonly ILanguageDiscovery _languageDiscovery; - private readonly IScaffoldingService _scaffoldingService; + private readonly ISolutionLocator _solutionLocator; private readonly AgentInitCommand _agentInitCommand; private readonly ICliHostEnvironment _hostEnvironment; - private static readonly Option s_sourceOption = new("--source", "-s") - { - Description = NewCommandStrings.SourceArgumentDescription, - Recursive = true - }; - private static readonly Option s_versionOption = new("--version") - { - Description = NewCommandStrings.VersionArgumentDescription, - Recursive = true - }; - - private readonly Option _channelOption; private readonly Option _languageOption; - /// - /// InitCommand prefetches template package metadata. - /// - public bool PrefetchesTemplatePackageMetadata => true; - - /// - /// InitCommand prefetches CLI package metadata for update notifications. - /// - public bool PrefetchesCliPackageMetadata => true; - public InitCommand( - IDotNetCliRunner runner, - ICertificateService certificateService, - ITemplateVersionPrompter templateVersionPrompter, - ITemplateProvider templateProvider, - IPackagingService packagingService, + ILanguageService languageService, ISolutionLocator solutionLocator, AspireCliTelemetry telemetry, - IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, IInteractionService interactionService, - IConfigurationService configurationService, - ILanguageService languageService, - ILanguageDiscovery languageDiscovery, - IScaffoldingService scaffoldingService, AgentInitCommand agentInitCommand, - ICliHostEnvironment hostEnvironment, - IConfiguration configuration) + ICliHostEnvironment hostEnvironment) : base("init", InitCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - _runner = runner; - _certificateService = certificateService; - _templateVersionPrompter = templateVersionPrompter; - _templateProvider = templateProvider; - _packagingService = packagingService; - _solutionLocator = solutionLocator; - _sdkInstaller = sdkInstaller; - _updateNotifier = updateNotifier; _executionContext = executionContext; - _configurationService = configurationService; _languageService = languageService; - _languageDiscovery = languageDiscovery; - _scaffoldingService = scaffoldingService; + _solutionLocator = solutionLocator; _agentInitCommand = agentInitCommand; _hostEnvironment = hostEnvironment; - Options.Add(s_sourceOption); - Options.Add(s_versionOption); - - // Customize description based on whether staging channel is enabled - var isStagingEnabled = KnownFeatures.IsStagingChannelEnabled(features, configuration); - _channelOption = new Option("--channel") - { - Description = isStagingEnabled - ? NewCommandStrings.ChannelOptionDescriptionWithStaging - : NewCommandStrings.ChannelOptionDescription, - Recursive = true - }; - Options.Add(_channelOption); - _languageOption = new Option("--language") { Description = InitCommandStrings.LanguageOptionDescription @@ -130,851 +60,238 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { using var activity = Telemetry.StartDiagnosticActivity(this.Name); - // Get the language selection (from command line, config, or prompt). + // Step 1: Get the language selection. var explicitLanguage = parseResult.GetValue(_languageOption); var selectedProject = await _languageService.GetOrPromptForProjectAsync(explicitLanguage, saveSelection: true, cancellationToken); - // For non-C# languages, skip solution detection and create polyglot apphost. - if (selectedProject.LanguageId != KnownLanguageId.CSharp) - { - // Get the language info for scaffolding - var languageInfo = _languageDiscovery.GetLanguageById(selectedProject.LanguageId); - if (languageInfo is null) - { - InteractionService.DisplayError($"Unknown language: {selectedProject.LanguageId}"); - return ExitCodeConstants.FailedToCreateNewProject; - } - - InteractionService.DisplayEmptyLine(); - InteractionService.DisplayMessage(KnownEmojis.Information, $"Creating {languageInfo.DisplayName} AppHost..."); - InteractionService.DisplayEmptyLine(); - var polyglotResult = await CreatePolyglotAppHostAsync(languageInfo, cancellationToken); - if (polyglotResult != 0) - { - InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ProjectCouldNotBeCreated, ExecutionContext.LogFilePath)); - return polyglotResult; - } - - return await _agentInitCommand.PromptAndChainAsync(_hostEnvironment, InteractionService, polyglotResult, _executionContext.WorkingDirectory, cancellationToken); - } + var isCSharp = selectedProject.LanguageId == KnownLanguageId.CSharp; + var workingDirectory = _executionContext.WorkingDirectory; - // For C#, we need the .NET SDK - if (!await SdkInstallHelper.EnsureSdkInstalledAsync(_sdkInstaller, InteractionService, Telemetry, cancellationToken)) + // Step 2: Detect solution (C# only — determines single-file vs full project). + FileInfo? solutionFile = null; + if (isCSharp) { - return ExitCodeConstants.SdkNotInstalled; + solutionFile = await _solutionLocator.FindSolutionFileAsync(workingDirectory, cancellationToken); } - // Create the init context to build up a model of the operation - var initContext = new InitContext(); - - // Use SolutionLocator to find solution files, walking up the directory tree - initContext.SelectedSolutionFile = await _solutionLocator.FindSolutionFileAsync(_executionContext.WorkingDirectory, cancellationToken); + // Step 3: Drop the skeleton AppHost + aspire.config.json. + var dropResult = isCSharp + ? await DropCSharpSkeletonAsync(workingDirectory, solutionFile, cancellationToken) + : await DropPolyglotSkeletonAsync(selectedProject.LanguageId, workingDirectory, cancellationToken); - int initResult; - DirectoryInfo workspaceRoot; - if (initContext.SelectedSolutionFile is not null) - { - InteractionService.DisplayEmptyLine(); - InteractionService.DisplayMessage(KnownEmojis.Information, string.Format(CultureInfo.CurrentCulture, InitCommandStrings.SolutionDetected, initContext.SelectedSolutionFile.Name)); - InteractionService.DisplayEmptyLine(); - initResult = await InitializeExistingSolutionAsync(initContext, parseResult, cancellationToken); - workspaceRoot = initContext.SolutionDirectory ?? _executionContext.WorkingDirectory; - } - else - { - InteractionService.DisplayEmptyLine(); - InteractionService.DisplayMessage(KnownEmojis.Information, InitCommandStrings.NoSolutionFoundCreatingSingleFileAppHost); - InteractionService.DisplayEmptyLine(); - initResult = await CreateEmptyAppHostAsync(parseResult, cancellationToken); - workspaceRoot = _executionContext.WorkingDirectory; - } - - if (initResult != 0) + if (dropResult != ExitCodeConstants.Success) { InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ProjectCouldNotBeCreated, ExecutionContext.LogFilePath)); - return initResult; + return dropResult; } - return await _agentInitCommand.PromptAndChainAsync(_hostEnvironment, InteractionService, initResult, workspaceRoot, cancellationToken); - } - - private async Task InitializeExistingSolutionAsync(InitContext initContext, ParseResult parseResult, CancellationToken cancellationToken) - { - var solutionFile = initContext.SelectedSolutionFile!; - - // Verify that the solution directory does not contain project files. - // If the solution and a project file are in the same directory, the AppHost - // and ServiceDefaults directories would be created inside that project which - // is not supported. - var solutionDirectory = solutionFile.Directory!; - var projectFileInSolutionDir = solutionDirectory.EnumerateFiles() - .FirstOrDefault(f => DotNetAppHostProject.ProjectExtensions.Contains(f.Extension, StringComparer.OrdinalIgnoreCase)); - - if (projectFileInSolutionDir is not null) + // Step 4: Install the appropriate init skill. + var initSkill = isCSharp ? SkillDefinition.AspireInitCSharp : SkillDefinition.AspireInitTypeScript; + var skillInstalled = await InstallInitSkillAsync(workingDirectory, initSkill, cancellationToken); + if (!skillInstalled) { - InteractionService.DisplayError( - string.Format( - CultureInfo.CurrentCulture, - InitCommandStrings.SolutionAndProjectInSameDirectory, - solutionFile.Name, - projectFileInSolutionDir.Name)); + InteractionService.DisplayError("Failed to install init skill."); return ExitCodeConstants.FailedToCreateNewProject; } - initContext.GetSolutionProjectsOutputCollector = new OutputCollector(); - var (getSolutionExitCode, solutionProjects) = await InteractionService.ShowStatusAsync("Reading solution...", async () => - { - var options = new ProcessInvocationOptions - { - StandardOutputCallback = initContext.GetSolutionProjectsOutputCollector.AppendOutput, - StandardErrorCallback = initContext.GetSolutionProjectsOutputCollector.AppendError - }; + InteractionService.DisplayEmptyLine(); + InteractionService.DisplayMessage(KnownEmojis.Sparkles, $"Init skill '{initSkill.Name}' installed. Ask your agent to run it to complete setup."); + InteractionService.DisplayEmptyLine(); - return await _runner.GetSolutionProjectsAsync( - solutionFile, - options, - cancellationToken); - }); + // Step 5: Chain to aspire agent init for MCP server + evergreen skill configuration. + var workspaceRoot = solutionFile?.Directory ?? workingDirectory; + return await _agentInitCommand.PromptAndChainAsync(_hostEnvironment, InteractionService, ExitCodeConstants.Success, workspaceRoot, cancellationToken); + } - if (getSolutionExitCode != 0) + private async Task DropCSharpSkeletonAsync(DirectoryInfo workingDirectory, FileInfo? solutionFile, CancellationToken cancellationToken) + { + if (solutionFile is not null) { - InteractionService.DisplayLines(initContext.GetSolutionProjectsOutputCollector.GetLines()); - InteractionService.DisplayError("Failed to get projects from solution."); - return getSolutionExitCode; + return await DropCSharpProjectSkeletonAsync(solutionFile, cancellationToken); } - initContext.SolutionProjects = solutionProjects; - - _ = await InteractionService.ShowStatusAsync("Evaluating existing projects...", async () => - { - await EvaluateSolutionProjectsAsync(initContext, cancellationToken); + return await DropCSharpSingleFileSkeletonAsync(workingDirectory, cancellationToken); + } - // HACK: Need to fix up InteractionService to support Task return from status operations. - return 0; - }); + private Task DropCSharpSingleFileSkeletonAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken) + { + _ = cancellationToken; - if (initContext.AlreadyHasAppHost) + var appHostPath = Path.Combine(workingDirectory.FullName, "apphost.cs"); + if (File.Exists(appHostPath)) { - InteractionService.DisplayMessage(KnownEmojis.CheckMark, InitCommandStrings.SolutionAlreadyInitialized); - return ExitCodeConstants.Success; + InteractionService.DisplayMessage(KnownEmojis.CheckMark, "apphost.cs already exists — skipping."); + return Task.FromResult(ExitCodeConstants.Success); } - // If there are executable projects, prompt user to select which ones to add to appHost - if (initContext.ExecutableProjects.Count > 0) - { - var addExecutableProjectsMessage = """ - # Add existing projects to AppHost? - - The following projects were found in the solution that can be - hosted in Aspire. Select the ones that you would like to be - added to the AppHost project. You can add or remove them - later as needed. - """; - - InteractionService.DisplayEmptyLine(); - InteractionService.DisplayMarkdown(addExecutableProjectsMessage); - InteractionService.DisplayEmptyLine(); - - var selectedProjects = await InteractionService.PromptForSelectionsAsync( - "Select projects to add to the AppHost:", - initContext.ExecutableProjects, - project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name).EscapeMarkup(), - optional: true, - cancellationToken: cancellationToken); - initContext.ExecutableProjectsToAddToAppHost = selectedProjects; - - // If projects were selected, prompt for which should have ServiceDefaults added - if (initContext.ExecutableProjectsToAddToAppHost.Count > 0) - { - InteractionService.DisplayEmptyLine(); - InteractionService.DisplayMessage(KnownEmojis.Information, "The following projects will be added to the AppHost:"); - InteractionService.DisplayEmptyLine(); + // Drop bare single-file apphost + var appHostContent = """ + #:sdk Aspire.AppHost.Sdk - foreach (var project in initContext.ExecutableProjectsToAddToAppHost) - { - InteractionService.DisplayMessage(KnownEmojis.CheckBoxWithCheck, project.ProjectFile.Name); - } + var builder = DistributedApplication.CreateBuilder(args); - var addServiceDefaultsMessage = """ - # Add ServiceDefaults reference to selected projects? + // The aspire-init-csharp skill will wire up your projects here. - Do you want to add a reference to the ServiceDefaults project to - the executable projects that will be added to the AppHost? The - ServiceDefaults project contains helper code to make it easier - for you to configure telemetry and service discovery in Aspire. - """; + builder.Build().Run(); + """; + File.WriteAllText(appHostPath, appHostContent); + InteractionService.DisplayMessage(KnownEmojis.CheckMark, "Created apphost.cs"); - InteractionService.DisplayEmptyLine(); - InteractionService.DisplayMarkdown(addServiceDefaultsMessage); - InteractionService.DisplayEmptyLine(); + // Drop aspire.config.json + DropAspireConfig(workingDirectory, "apphost.cs", language: null); - var serviceDefaultsActions = new Dictionary - { - { "all", "Add to all previously added projects" }, - { "choose", "Let me choose" }, - { "none", "Do not add to any projects" } - }; - - var selection = await InteractionService.PromptForSelectionAsync( - "Add ServiceDefaults reference?", - serviceDefaultsActions, - (action) => action.Value, - cancellationToken - ); - - switch (selection.Key) - { - case "all": - initContext.ProjectsToAddServiceDefaultsTo = initContext.ExecutableProjectsToAddToAppHost; - break; - case "choose": - initContext.ProjectsToAddServiceDefaultsTo = await InteractionService.PromptForSelectionsAsync( - "Select projects to add ServiceDefaults reference to:", - initContext.ExecutableProjectsToAddToAppHost, - project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name).EscapeMarkup(), - optional: true, - cancellationToken: cancellationToken); - break; - case "none": - initContext.ProjectsToAddServiceDefaultsTo = Array.Empty(); - break; - } - } - } - - // Get template version/channel selection using the same logic as NewCommand - var selectedTemplateDetails = await GetProjectTemplatesVersionAsync(parseResult, cancellationToken); + return Task.FromResult(ExitCodeConstants.Success); + } - // Create or update NuGet.config for explicit channels in the solution directory - // This matches the behavior of 'aspire new' when creating in-place - var nugetConfigPrompter = new NuGetConfigPrompter(InteractionService); - await nugetConfigPrompter.PromptToCreateOrUpdateAsync( - ExecutionContext.WorkingDirectory, - selectedTemplateDetails.Channel, - cancellationToken); + private Task DropCSharpProjectSkeletonAsync(FileInfo solutionFile, CancellationToken cancellationToken) + { + _ = cancellationToken; - // Create a temporary directory for the template output - var tempProjectDir = Path.Combine(Path.GetTempPath(), $"aspire-init-{Guid.NewGuid()}"); - Directory.CreateDirectory(tempProjectDir); + var solutionDir = solutionFile.Directory!; + var solutionName = Path.GetFileNameWithoutExtension(solutionFile.Name); + var appHostDirName = $"{solutionName}.AppHost"; + var appHostDirPath = Path.Combine(solutionDir.FullName, appHostDirName); - try + if (Directory.Exists(appHostDirPath)) { - // Create temporary NuGet config if using explicit channel - using var temporaryConfig = selectedTemplateDetails.Channel.Type == PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(selectedTemplateDetails.Channel.Mappings!) : null; - - // Install templates first if needed - initContext.InstallTemplateOutputCollector = new OutputCollector(); - var templateInstallResult = await InteractionService.ShowStatusAsync( - "Getting templates...", - async () => - { - var options = new ProcessInvocationOptions - { - StandardOutputCallback = initContext.InstallTemplateOutputCollector.AppendOutput, - StandardErrorCallback = initContext.InstallTemplateOutputCollector.AppendError - }; - - return await _runner.InstallTemplateAsync( - packageName: "Aspire.ProjectTemplates", - version: selectedTemplateDetails.Package.Version, - nugetConfigFile: temporaryConfig?.ConfigFile, - nugetSource: selectedTemplateDetails.Package.Source, - force: true, - options: options, - cancellationToken: cancellationToken); - }); - - if (templateInstallResult.ExitCode != 0) - { - InteractionService.DisplayLines(initContext.InstallTemplateOutputCollector.GetLines()); - InteractionService.DisplayError("Failed to install Aspire templates."); - return ExitCodeConstants.FailedToInstallTemplates; - } - - initContext.NewProjectOutputCollector = new OutputCollector(); - var createResult = await InteractionService.ShowStatusAsync( - "Creating Aspire projects from template...", - async () => - { - var options = new ProcessInvocationOptions - { - StandardOutputCallback = initContext.NewProjectOutputCollector.AppendOutput, - StandardErrorCallback = initContext.NewProjectOutputCollector.AppendError - }; - - return await _runner.NewProjectAsync( - "aspire", - initContext.SolutionName, - tempProjectDir, - ["--framework", initContext.RequiredAppHostFramework], - options, - cancellationToken); - }); - - if (createResult != 0) - { - InteractionService.DisplayLines(initContext.NewProjectOutputCollector.GetLines()); - InteractionService.DisplayError($"Failed to create Aspire projects. Exit code: {createResult}"); - return createResult; - } + InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"{appHostDirName}/ already exists — skipping."); + return Task.FromResult(ExitCodeConstants.Success); + } - // Find the created projects in the temporary directory - var tempDir = new DirectoryInfo(tempProjectDir); - var appHostProjects = tempDir.GetDirectories("*.AppHost", SearchOption.TopDirectoryOnly); - var serviceDefaultsProjects = tempDir.GetDirectories("*.ServiceDefaults", SearchOption.TopDirectoryOnly); + Directory.CreateDirectory(appHostDirPath); - if (appHostProjects.Length == 0 || serviceDefaultsProjects.Length == 0) - { - InteractionService.DisplayError("Failed to find created AppHost or ServiceDefaults projects in template output."); - return ExitCodeConstants.FailedToCreateNewProject; - } + // Drop bare apphost.cs + var appHostContent = """ + var builder = DistributedApplication.CreateBuilder(args); - var appHostProjectDir = appHostProjects[0]; - var serviceDefaultsProjectDir = serviceDefaultsProjects[0]; + // The aspire-init-csharp skill will wire up your projects here. - // Copy the projects to the solution directory - // Using copy instead of move to support cross-drive operations on Windows - var finalAppHostDir = Path.Combine(initContext.SolutionDirectory.FullName, appHostProjectDir.Name); - var finalServiceDefaultsDir = Path.Combine(initContext.SolutionDirectory.FullName, serviceDefaultsProjectDir.Name); + builder.Build().Run(); + """; + File.WriteAllText(Path.Combine(appHostDirPath, "apphost.cs"), appHostContent); - FileSystemHelper.CopyDirectory(appHostProjectDir.FullName, finalAppHostDir, overwrite: true); - FileSystemHelper.CopyDirectory(serviceDefaultsProjectDir.FullName, finalServiceDefaultsDir, overwrite: true); + // Drop minimal .csproj + var csprojContent = $""" + - // Delete the temporary directory - Directory.Delete(tempProjectDir, recursive: true); + + Exe + net10.0 + enable + enable + true + - // Add AppHost project to solution - var appHostProjectFile = new FileInfo(Path.Combine(finalAppHostDir, $"{appHostProjectDir.Name}.csproj")); - var serviceDefaultsProjectFile = new FileInfo(Path.Combine(finalServiceDefaultsDir, $"{serviceDefaultsProjectDir.Name}.csproj")); - initContext.AddAppHostToSolutionOutputCollector = new OutputCollector(); - var addAppHostResult = await InteractionService.ShowStatusAsync( - InitCommandStrings.AddingAppHostProjectToSolution, - async () => - { - var options = new ProcessInvocationOptions - { - StandardOutputCallback = initContext.AddAppHostToSolutionOutputCollector.AppendOutput, - StandardErrorCallback = initContext.AddAppHostToSolutionOutputCollector.AppendError - }; - - return await _runner.AddProjectToSolutionAsync( - solutionFile, - appHostProjectFile, - options, - cancellationToken); - }); - - if (addAppHostResult != 0) - { - InteractionService.DisplayLines(initContext.AddAppHostToSolutionOutputCollector.GetLines()); - InteractionService.DisplayError($"Failed to add AppHost project to solution. Exit code: {addAppHostResult}"); - return addAppHostResult; - } + + + - // Add ServiceDefaults project to solution - initContext.AddServiceDefaultsToSolutionOutputCollector = new OutputCollector(); - var addServiceDefaultsResult = await InteractionService.ShowStatusAsync( - InitCommandStrings.AddingServiceDefaultsProjectToSolution, - async () => - { - var options = new ProcessInvocationOptions - { - StandardOutputCallback = initContext.AddServiceDefaultsToSolutionOutputCollector.AppendOutput, - StandardErrorCallback = initContext.AddServiceDefaultsToSolutionOutputCollector.AppendError - }; - - return await _runner.AddProjectToSolutionAsync( - solutionFile, - serviceDefaultsProjectFile, - options, - cancellationToken); - }); - - if (addServiceDefaultsResult != 0) - { - InteractionService.DisplayLines(initContext.AddServiceDefaultsToSolutionOutputCollector.GetLines()); - InteractionService.DisplayError($"Failed to add ServiceDefaults project to solution. Exit code: {addServiceDefaultsResult}"); - return addServiceDefaultsResult; - } - - // Add selected projects to appHost - if (initContext.ExecutableProjectsToAddToAppHost.Count > 0) - { - initContext.AddProjectReferenceOutputCollectors = new List(); - foreach(var project in initContext.ExecutableProjectsToAddToAppHost) - { - var outputCollector = new OutputCollector(); - initContext.AddProjectReferenceOutputCollectors.Add(outputCollector); - - var addRefResult = await InteractionService.ShowStatusAsync( - $"Adding {project.ProjectFile.Name} to AppHost...", async () => - { - var options = new ProcessInvocationOptions - { - StandardOutputCallback = outputCollector.AppendOutput, - StandardErrorCallback = outputCollector.AppendError - }; - - return await _runner.AddProjectReferenceAsync( - appHostProjectFile, - project.ProjectFile, - options, - cancellationToken); - }); - - if (addRefResult != 0) - { - InteractionService.DisplayLines(outputCollector.GetLines()); - InteractionService.DisplayError($"Failed to add reference to {Path.GetFileNameWithoutExtension(project.ProjectFile.Name)}."); - return addRefResult; - } - } - } + + """; + File.WriteAllText(Path.Combine(appHostDirPath, $"{appHostDirName}.csproj"), csprojContent); - // Add ServiceDefaults references to selected projects - if (initContext.ProjectsToAddServiceDefaultsTo.Count > 0) - { - initContext.AddServiceDefaultsReferenceOutputCollectors = new List(); - foreach (var project in initContext.ProjectsToAddServiceDefaultsTo) - { - var outputCollector = new OutputCollector(); - initContext.AddServiceDefaultsReferenceOutputCollectors.Add(outputCollector); - - var addRefResult = await InteractionService.ShowStatusAsync( - $"Adding ServiceDefaults reference to {project.ProjectFile.Name}...", async () => - { - var options = new ProcessInvocationOptions - { - StandardOutputCallback = outputCollector.AppendOutput, - StandardErrorCallback = outputCollector.AppendError - }; - - return await _runner.AddProjectReferenceAsync( - project.ProjectFile, - serviceDefaultsProjectFile, - options, - cancellationToken); - }); - - if (addRefResult != 0) - { - InteractionService.DisplayLines(outputCollector.GetLines()); - InteractionService.DisplayError($"Failed to add ServiceDefaults reference to {Path.GetFileNameWithoutExtension(project.ProjectFile.Name)}."); - return addRefResult; - } - } - } + InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"Created {appHostDirName}/"); - // Trust certificates (result not used since we're not launching an AppHost) - _ = await _certificateService.EnsureCertificatesTrustedAsync(cancellationToken); + // Drop aspire.config.json at solution root + var relativeAppHostPath = Path.Combine(appHostDirName, "apphost.cs"); + DropAspireConfig(solutionDir, relativeAppHostPath, language: null); - InteractionService.DisplaySuccess(InitCommandStrings.AspireInitializationComplete); - return ExitCodeConstants.Success; - } - finally - { - // Clean up temporary directory - if (Directory.Exists(tempProjectDir)) - { - Directory.Delete(tempProjectDir, recursive: true); - } - } + return Task.FromResult(ExitCodeConstants.Success); } - private async Task CreatePolyglotAppHostAsync(LanguageInfo language, CancellationToken cancellationToken) + private Task DropPolyglotSkeletonAsync(string languageId, DirectoryInfo workingDirectory, CancellationToken cancellationToken) { - var workingDirectory = _executionContext.WorkingDirectory; - var appHostFileName = language.AppHostFileName; - - // Check if apphost already exists (only if the project type has a known filename) - if (appHostFileName is not null) - { - var appHostPath = Path.Combine(workingDirectory.FullName, appHostFileName); - if (File.Exists(appHostPath)) - { - InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"{appHostFileName} already exists in this directory."); - return ExitCodeConstants.Success; - } - } - - // Create the apphost project using the scaffolding service - var context = new ScaffoldContext(language, workingDirectory, ProjectName: null); - var scaffolded = await _scaffoldingService.ScaffoldAsync(context, cancellationToken); - if (!scaffolded) - { - return ExitCodeConstants.FailedToCreateNewProject; - } - - InteractionService.DisplaySuccess($"Created {appHostFileName}"); - InteractionService.DisplayMessage(KnownEmojis.Information, $"Run 'aspire run' to start your AppHost."); - return ExitCodeConstants.Success; - } - - private async Task CreateEmptyAppHostAsync(ParseResult parseResult, CancellationToken cancellationToken) - { - // Use single-file AppHost template - var initTemplates = await _templateProvider.GetInitTemplatesAsync(cancellationToken); - var singleFileTemplate = initTemplates.FirstOrDefault(t => t.Name == "aspire-apphost-singlefile"); - if (singleFileTemplate is null) - { - InteractionService.DisplayError("Single-file AppHost template not found."); - return ExitCodeConstants.FailedToCreateNewProject; - } - var template = singleFileTemplate; + _ = cancellationToken; - // For init command, use working directory without prompting for name/output - var inputs = new TemplateInputs + // Determine the apphost filename based on language + var (appHostFileName, languageConfigValue) = languageId switch { - Source = parseResult.GetValue(s_sourceOption), - Version = parseResult.GetValue(s_versionOption), - Channel = parseResult.GetValue(_channelOption), - UseWorkingDirectory = true + KnownLanguageId.TypeScript => ("apphost.ts", "typescript/nodejs"), + _ => throw new NotSupportedException($"Polyglot skeleton not yet supported for language: {languageId}") }; - var result = await template.ApplyTemplateAsync(inputs, parseResult, cancellationToken); - if (result.ExitCode == 0) + var appHostPath = Path.Combine(workingDirectory.FullName, appHostFileName); + if (File.Exists(appHostPath)) { - // Trust certificates (result not used since we're not launching an AppHost) - _ = await _certificateService.EnsureCertificatesTrustedAsync(cancellationToken); - InteractionService.DisplaySuccess(InitCommandStrings.AspireInitializationComplete); + InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"{appHostFileName} already exists — skipping."); + return Task.FromResult(ExitCodeConstants.Success); } - return result.ExitCode; - } + // Drop bare apphost.ts + var appHostContent = """ + import { createBuilder } from './.modules/aspire.js'; - private async Task EvaluateSolutionProjectsAsync(InitContext initContext, CancellationToken cancellationToken) - { - var executableProjects = new List(); + const builder = await createBuilder(); - initContext.EvaluateSolutionProjectsOutputCollector = new OutputCollector(); + // The aspire-init-typescript skill will wire up your projects here. - foreach (var project in initContext.SolutionProjects) - { - var options = new ProcessInvocationOptions - { - StandardOutputCallback = initContext.EvaluateSolutionProjectsOutputCollector.AppendOutput, - StandardErrorCallback = initContext.EvaluateSolutionProjectsOutputCollector.AppendError - }; - - // Get IsAspireHost, OutputType, and TargetFramework properties in a single call - var (exitCode, jsonDoc) = await _runner.GetProjectItemsAndPropertiesAsync( - project, - [], - ["IsAspireHost", "OutputType", "TargetFramework"], - options, - cancellationToken); - - if (exitCode == 0 && jsonDoc != null) - { - var rootElement = jsonDoc.RootElement; - if (rootElement.TryGetProperty("Properties", out var properties)) - { - // Check if this project is an AppHost - if (properties.TryGetProperty("IsAspireHost", out var isAspireHostElement)) - { - var isAspireHost = isAspireHostElement.GetString(); - if (isAspireHost?.Equals("true", StringComparison.OrdinalIgnoreCase) == true) - { - initContext.AlreadyHasAppHost = true; - return; - } - } - - // Check if this project is executable - if (properties.TryGetProperty("OutputType", out var outputTypeElement)) - { - var outputType = outputTypeElement.GetString(); - if (outputType == "Exe" || outputType == "WinExe") - { - // Get the target framework - var targetFramework = "net9.0"; // Default if not found - if (properties.TryGetProperty("TargetFramework", out var targetFrameworkElement)) - { - targetFramework = targetFrameworkElement.GetString() ?? "net9.0"; - } - - // Only add projects with supported TFMs - if (IsSupportedTfm(targetFramework)) - { - executableProjects.Add(new ExecutableProjectInfo - { - ProjectFile = project, - TargetFramework = targetFramework - }); - } - } - } - } - } - } + await builder.build().run(); + """; + File.WriteAllText(appHostPath, appHostContent); + InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"Created {appHostFileName}"); - initContext.ExecutableProjects = executableProjects; - } + // Drop aspire.config.json + DropAspireConfig(workingDirectory, appHostFileName, languageConfigValue); - /// - /// Determines if the specified target framework moniker is supported. - /// - /// The target framework moniker to check. - /// True if the TFM is supported; otherwise, false. - private static bool IsSupportedTfm(string tfm) - { - return tfm switch - { - "net8.0" => true, - "net9.0" => true, - "net10.0" => true, - _ => false - }; + return Task.FromResult(ExitCodeConstants.Success); } - private async Task<(NuGetPackage Package, PackageChannel Channel)> GetProjectTemplatesVersionAsync(ParseResult parseResult, CancellationToken cancellationToken) + private void DropAspireConfig(DirectoryInfo directory, string appHostPath, string? language) { - var allChannels = await InteractionService.ShowStatusAsync( - InitCommandStrings.ResolvingTemplateVersion, - async () => await _packagingService.GetChannelsAsync(cancellationToken)); - - // Check if --channel option was provided (highest priority) - var channelName = parseResult.GetValue(_channelOption); - - // If no --channel option, check for global channel setting - if (string.IsNullOrEmpty(channelName)) - { - channelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); - } - - IEnumerable channels; - bool hasChannelSetting = !string.IsNullOrEmpty(channelName); - - if (hasChannelSetting) - { - // If --channel option is provided or global channel setting exists, find the matching channel - // (--channel option takes precedence over global setting) - var matchingChannel = allChannels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); - if (matchingChannel is null) - { - throw new ChannelNotFoundException($"No channel found matching '{channelName}'. Valid options are: {string.Join(", ", allChannels.Select(c => c.Name))}"); - } - channels = new[] { matchingChannel }; - } - else + var configPath = Path.Combine(directory.FullName, AspireConfigFile.FileName); + if (File.Exists(configPath)) { - // No channel specified, use all channels for prompting - channels = allChannels; + InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"{AspireConfigFile.FileName} already exists — skipping."); + return; } - var packagesFromChannels = await InteractionService.ShowStatusAsync("Searching for available template versions...", async () => - { - var results = new List<(NuGetPackage Package, PackageChannel Channel)>(); - var packagesFromChannelsLock = new object(); + var languageLine = language is not null + ? $""" + , + "language": "{language}" + """ + : string.Empty; - await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => + var configContent = $$""" { - var templatePackages = await channel.GetTemplatePackagesAsync(_executionContext.WorkingDirectory, ct); - lock (packagesFromChannelsLock) - { - results.AddRange(templatePackages.Select(p => (p, channel))); - } - }); - - return results; - }); - - if (!packagesFromChannels.Any()) - { - throw new InvalidOperationException("No template versions found"); - } - - var orderedPackagesFromChannels = packagesFromChannels.OrderByDescending(p => SemVersion.Parse(p.Package.Version), SemVersion.PrecedenceComparer); - - // Check for explicit version specified via command line - if (parseResult.GetValue(s_versionOption) is { } version) - { - var explicitPackageFromChannel = orderedPackagesFromChannels.FirstOrDefault(p => p.Package.Version == version); - if (explicitPackageFromChannel.Package is not null) - { - return explicitPackageFromChannel; + "appHost": { + "path": "{{appHostPath}}"{{languageLine}} + } } - } - - // If channel was specified via --channel option or global setting (but no --version), - // automatically select the highest version from that channel without prompting - if (hasChannelSetting) - { - return orderedPackagesFromChannels.First(); - } - - var latestStable = orderedPackagesFromChannels.FirstOrDefault(p => !SemVersion.Parse(p.Package.Version).IsPrerelease); - - var templateSelectionMessage = $$""" - # Which version of Aspire do you want to use? - - Multiple versions of Aspire are available. If you want to use - the latest stable version choose ***{{latestStable.Package.Version}}***. - """; + """; - InteractionService.DisplayEmptyLine(); - InteractionService.DisplayMarkdown(templateSelectionMessage); - InteractionService.DisplayEmptyLine(); - - // Prompt user to select from available versions/channels - var selectedPackageFromChannel = await _templateVersionPrompter.PromptForTemplatesVersionAsync(orderedPackagesFromChannels, cancellationToken); - return selectedPackageFromChannel; + File.WriteAllText(configPath, configContent); + InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"Created {AspireConfigFile.FileName}"); } -} -/// -/// Represents information about an executable project including its file and target framework. -/// -internal sealed class ExecutableProjectInfo -{ - /// - /// Gets the project file. - /// - public required FileInfo ProjectFile { get; init; } - - /// - /// Gets the target framework moniker (e.g., "net9.0", "net10.0"). - /// - public required string TargetFramework { get; init; } -} - -/// -/// Context class for building up a model of the init operation before executing changes. -/// -internal sealed class InitContext -{ - /// - /// The solution file selected for initialization, or null if no solution was found. - /// - public FileInfo? SelectedSolutionFile { get; set; } - - /// - /// Gets the solution name (without extension) derived from the selected solution file. - /// - public string SolutionName => Path.GetFileNameWithoutExtension(SelectedSolutionFile!.Name); - - /// - /// Gets the directory containing the solution file. - /// - public DirectoryInfo SolutionDirectory => SelectedSolutionFile!.Directory!; - - /// - /// Gets the expected directory path for the AppHost project. - /// - public string ExpectedAppHostDirectory => Path.Combine(SolutionDirectory.FullName, $"{SolutionName}.AppHost"); - - /// - /// Gets the expected directory path for the ServiceDefaults project. - /// - public string ExpectedServiceDefaultsDirectory => Path.Combine(SolutionDirectory.FullName, $"{SolutionName}.ServiceDefaults"); - - /// - /// All projects in the solution. - /// - public IReadOnlyList SolutionProjects { get; set; } = Array.Empty(); - - /// - /// Indicates whether the solution already has an AppHost project. - /// - public bool AlreadyHasAppHost { get; set; } - - /// - /// List of executable projects found in the solution (excluding the AppHost). - /// - public IReadOnlyList ExecutableProjects { get; set; } = Array.Empty(); - - /// - /// Executable projects selected by the user to add to the AppHost. - /// - public IReadOnlyList ExecutableProjectsToAddToAppHost { get; set; } = Array.Empty(); - - /// - /// Projects selected by the user to add ServiceDefaults reference to. - /// - public IReadOnlyList ProjectsToAddServiceDefaultsTo { get; set; } = Array.Empty(); - - /// - /// Gets the required AppHost framework based on the highest TFM of all selected executable projects. - /// - public string RequiredAppHostFramework + private async Task InstallInitSkillAsync(DirectoryInfo workspaceRoot, SkillDefinition skill, CancellationToken cancellationToken) { - get - { - if (ExecutableProjectsToAddToAppHost.Count == 0) - { - return "net9.0"; // Default framework if no projects selected - } + // Install the init skill to the standard .agents/skills/ location (workspace level only). + var relativeSkillPath = Path.Combine(SkillLocation.Standard.RelativeSkillDirectory, skill.Name); + var fullSkillDir = Path.Combine(workspaceRoot.FullName, relativeSkillPath); - // Parse and compare TFMs to find the highest one using SemVersion - SemVersion? highestVersion = null; - var highestTfm = "net9.0"; + try + { + var skillFiles = await EmbeddedSkillResourceLoader.LoadTextFilesAsync(skill.EmbeddedResourceRoot!, cancellationToken); - foreach (var project in ExecutableProjectsToAddToAppHost) + foreach (var skillFile in skillFiles) { - var tfm = project.TargetFramework; - if (tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase)) + var fullPath = Path.Combine(fullSkillDir, skillFile.RelativePath); + var fileDir = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(fileDir) && !Directory.Exists(fileDir)) { - var versionString = tfm[3..]; - // Add patch version if not present for SemVersion parsing - // TFMs are in format "8.0", "9.0", "10.0", need to make them "8.0.0", "9.0.0", "10.0.0" - var dotCount = versionString.Count(c => c == '.'); - if (dotCount == 1) - { - versionString += ".0"; - } - - if (SemVersion.TryParse(versionString, SemVersionStyles.Strict, out var version)) - { - if (highestVersion is null || SemVersion.ComparePrecedence(version, highestVersion) > 0) - { - highestVersion = version; - highestTfm = tfm; - } - } + Directory.CreateDirectory(fileDir); } + + await File.WriteAllTextAsync(fullPath, skillFile.Content, cancellationToken); } - return highestTfm; + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + InteractionService.DisplayError($"Failed to install skill '{skill.Name}': {ex.Message}"); + return false; } } - - /// - /// OutputCollector for GetSolutionProjects operation. - /// - public OutputCollector? GetSolutionProjectsOutputCollector { get; set; } - - /// - /// OutputCollector for EvaluateSolutionProjects operation. - /// - public OutputCollector? EvaluateSolutionProjectsOutputCollector { get; set; } - - /// - /// OutputCollector for InstallTemplate operation. - /// - public OutputCollector? InstallTemplateOutputCollector { get; set; } - - /// - /// OutputCollector for NewProject operation. - /// - public OutputCollector? NewProjectOutputCollector { get; set; } - - /// - /// OutputCollector for AddAppHostToSolution operation. - /// - public OutputCollector? AddAppHostToSolutionOutputCollector { get; set; } - - /// - /// OutputCollector for AddServiceDefaultsToSolution operation. - /// - public OutputCollector? AddServiceDefaultsToSolutionOutputCollector { get; set; } - - /// - /// OutputCollectors for AddProjectReference operations (one per project reference added). - /// - public List? AddProjectReferenceOutputCollectors { get; set; } - - /// - /// OutputCollectors for AddServiceDefaultsReference operations (one per ServiceDefaults reference added). - /// - public List? AddServiceDefaultsReferenceOutputCollectors { get; set; } } diff --git a/src/Aspire.Cli/Interaction/KnownEmojis.cs b/src/Aspire.Cli/Interaction/KnownEmojis.cs index e6c05a37f4a..f215f2e7cc9 100644 --- a/src/Aspire.Cli/Interaction/KnownEmojis.cs +++ b/src/Aspire.Cli/Interaction/KnownEmojis.cs @@ -42,6 +42,7 @@ internal static class KnownEmojis public static readonly KnownEmoji PageFacingUp = new("page_facing_up"); public static readonly KnownEmoji Rocket = new("rocket"); public static readonly KnownEmoji RunningShoe = new("running_shoe"); + public static readonly KnownEmoji Sparkles = new("dizzy"); public static readonly KnownEmoji StopSign = new("stop_sign"); public static readonly KnownEmoji UpButton = new("up_button"); public static readonly KnownEmoji Warning = new("warning"); From 670a322a49f2f082fded97b677d132549edda4d2 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 19:59:36 -0400 Subject: [PATCH 04/48] Merge init skills into single unified aspire-init skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse aspire-init-typescript and aspire-init-csharp into one aspire-init skill. The unified skill handles both languages with conditional sections — the agent reads appHost.language from aspire.config.json to determine which path to follow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init-csharp/SKILL.md | 284 -------------- .../skills/aspire-init-typescript/SKILL.md | 270 ------------- .agents/skills/aspire-init/SKILL.md | 358 ++++++++++++++++++ .agents/skills/aspire/SKILL.md | 2 +- .../Agents/CommonAgentApplicators.cs | 18 +- src/Aspire.Cli/Agents/SkillDefinition.cs | 30 +- src/Aspire.Cli/Aspire.Cli.csproj | 5 +- src/Aspire.Cli/Commands/InitCommand.cs | 2 +- .../Resources/AgentCommandStrings.Designer.cs | 15 +- .../Resources/AgentCommandStrings.resx | 7 +- .../Resources/xlf/AgentCommandStrings.cs.xlf | 11 +- .../Resources/xlf/AgentCommandStrings.de.xlf | 11 +- .../Resources/xlf/AgentCommandStrings.es.xlf | 11 +- .../Resources/xlf/AgentCommandStrings.fr.xlf | 11 +- .../Resources/xlf/AgentCommandStrings.it.xlf | 11 +- .../Resources/xlf/AgentCommandStrings.ja.xlf | 11 +- .../Resources/xlf/AgentCommandStrings.ko.xlf | 11 +- .../Resources/xlf/AgentCommandStrings.pl.xlf | 11 +- .../xlf/AgentCommandStrings.pt-BR.xlf | 11 +- .../Resources/xlf/AgentCommandStrings.ru.xlf | 11 +- .../Resources/xlf/AgentCommandStrings.tr.xlf | 11 +- .../xlf/AgentCommandStrings.zh-Hans.xlf | 11 +- .../xlf/AgentCommandStrings.zh-Hant.xlf | 11 +- 23 files changed, 417 insertions(+), 717 deletions(-) delete mode 100644 .agents/skills/aspire-init-csharp/SKILL.md delete mode 100644 .agents/skills/aspire-init-typescript/SKILL.md create mode 100644 .agents/skills/aspire-init/SKILL.md diff --git a/.agents/skills/aspire-init-csharp/SKILL.md b/.agents/skills/aspire-init-csharp/SKILL.md deleted file mode 100644 index 8030be42d2a..00000000000 --- a/.agents/skills/aspire-init-csharp/SKILL.md +++ /dev/null @@ -1,284 +0,0 @@ ---- -name: aspire-init-csharp -description: "One-time skill for completing Aspire initialization in a C# AppHost workspace. Run this after `aspire init` has dropped the skeleton apphost.cs and aspire.config.json. This skill scans the repository for projects, wires up the AppHost, creates ServiceDefaults, configures project references, and validates that `aspire start` works. Self-removes on success." ---- - -# Aspire Init — C# AppHost - -This is a **one-time setup skill**. It completes the Aspire initialization that `aspire init` started. After this skill finishes successfully, it should be deleted — the evergreen `aspire` skill handles ongoing AppHost work. - -## Prerequisites - -Before running this skill, `aspire init` must have already: - -- Dropped a skeleton `apphost.cs` (single-file) or an AppHost project directory (if a .sln was found) -- Created `aspire.config.json` at the repository root - -Verify both exist before proceeding. - -## Understanding the two modes - -`aspire init` drops different things depending on whether a solution file was found: - -- **No .sln/.slnx**: A single-file `apphost.cs` using the `#:sdk` directive. This is a self-contained file with no project directory. -- **With .sln/.slnx**: A full AppHost project directory containing a `.csproj` and `apphost.cs`. This project has been added to the solution. - -Check which mode you're in by looking at `aspire.config.json` — the `appHost.path` field tells you. - -## Workflow - -Follow these steps in order. If any step fails, diagnose and fix before continuing. - -### Step 1: Scan the repository - -Analyze the repository to discover all projects and services that could be modeled in the AppHost. - -**For .NET projects:** - -Find all `*.csproj` and `*.fsproj` files. For each, determine: - -- **OutputType**: Run `dotnet msbuild -getProperty:OutputType` — `Exe` or `WinExe` means it's a runnable service -- **TargetFramework**: Run `dotnet msbuild -getProperty:TargetFramework` — must be `net8.0` or newer -- **IsAspireHost**: Run `dotnet msbuild -getProperty:IsAspireHost` — skip if `true` (that's the AppHost itself) - -Classify each project: - -- **Runnable services**: OutputType is `Exe`/`WinExe`, TFM is net8.0+, not an AppHost -- **Class libraries**: OutputType is `Library` — these are not modeled in the AppHost directly -- **Test projects**: skip (directories named `test`/`tests`, or projects referencing xUnit/NUnit/MSTest) - -**For non-.NET projects:** - -Also look for: - -- **Node.js/TypeScript apps**: directories with `package.json` + start script -- **Python apps**: directories with `pyproject.toml` or `requirements.txt` + entry point -- **Dockerfiles**: standalone `Dockerfile` entries that represent services -- **Docker Compose**: `docker-compose.yml` entries (note: these may need manual translation) - -### Step 2: Present findings and confirm with the user - -Show the user what you found. For each discovered project/service, show: - -- Name (project name or directory name) -- Type (.NET service, Node.js app, Dockerfile, etc.) -- Framework/TFM (e.g., net10.0, Node 20, Python 3.12) -- Whether it exposes HTTP endpoints - -Ask the user: - -1. Which projects to include in the AppHost (pre-select all runnable .NET services) -2. Which projects should receive ServiceDefaults references (pre-select all .NET services) - -### Step 3: Create ServiceDefaults project - -If no ServiceDefaults project exists in the repo, create one using the dotnet template: - -```bash -dotnet new aspire-servicedefaults -n .ServiceDefaults -o -``` - -Where `` is alongside the AppHost (e.g., `src/` or solution root). - -If a solution file exists, add the ServiceDefaults project to it: - -```bash -dotnet sln add -``` - -If a ServiceDefaults project already exists (look for a project that references `Microsoft.Extensions.ServiceDiscovery` or `Aspire.ServiceDefaults`), skip creation and use the existing one. - -### Step 4: Wire up the AppHost - -Edit the `apphost.cs` to add resource definitions for each selected project. - -**Single-file mode** (no solution): - -```csharp -#:sdk Aspire.AppHost.Sdk@ -#:property IsAspireHost=true - -// Project references -#:project ../src/Api/Api.csproj -#:project ../src/Web/Web.csproj - -var builder = DistributedApplication.CreateBuilder(args); - -var api = builder.AddProject("api"); - -var web = builder.AddProject("web") - .WithReference(api) - .WaitFor(api); - -builder.Build().Run(); -``` - -**Full project mode** (with solution): - -Edit the `apphost.cs` in the AppHost project: - -```csharp -var builder = DistributedApplication.CreateBuilder(args); - -var api = builder.AddProject("api"); - -var web = builder.AddProject("web") - .WithReference(api) - .WaitFor(api); - -builder.Build().Run(); -``` - -And add project references to the AppHost `.csproj`: - -```bash -dotnet add reference -dotnet add reference -``` - -**For non-.NET services in a C# AppHost**, use the appropriate hosting integration: - -```csharp -// Node.js app (requires Aspire.Hosting.NodeJs package) -var frontend = builder.AddNpmApp("frontend", "../frontend", "start"); - -// Dockerfile-based service -var worker = builder.AddDockerfile("worker", "../worker"); - -// Python app (requires Aspire.Hosting.Python package) -var pyApi = builder.AddPythonApp("py-api", "../py-api", "app.py"); -``` - -For non-.NET resources, add the required hosting NuGet packages: - -```bash -dotnet add package Aspire.Hosting.NodeJs -dotnet add package Aspire.Hosting.Python -``` - -**Important rules:** - -- Use `aspire docs search` and `aspire docs get` to look up the correct builder API for each resource type before writing code. Do not guess API shapes. -- Use meaningful resource names derived from the project name. -- Wire up `WithReference()` and `WaitFor()` for services that depend on each other (ask the user if dependency relationships are unclear). - -### Step 5: Add ServiceDefaults references - -For each .NET project that the user selected for ServiceDefaults: - -```bash -dotnet add reference -``` - -Then check each project's `Program.cs` (or equivalent entry point) and add the ServiceDefaults call if not already present: - -```csharp -builder.AddServiceDefaults(); -``` - -This should be added early in the builder pipeline, before `builder.Build()`. Look for the `WebApplicationBuilder` or `HostApplicationBuilder` creation and add it after. - -Also add the corresponding endpoint mapping before `app.Run()`: - -```csharp -app.MapDefaultEndpoints(); -``` - -**Important**: Be careful with code placement. Look at the existing code structure: - -- If using top-level statements, add `builder.AddServiceDefaults()` after `var builder = WebApplication.CreateBuilder(args);` -- If using `Startup.cs` pattern, add to `ConfigureServices` -- If using `Program.Main` method, add in the appropriate location -- Do not duplicate if already present - -### Step 6: Wire up OpenTelemetry for non-.NET services - -For non-.NET services included in the AppHost, OpenTelemetry should be configured so the Aspire dashboard can show their traces, metrics, and logs. This is the equivalent of what ServiceDefaults does for .NET. - -**Node.js/TypeScript services:** - -Suggest adding OpenTelemetry packages: - -```bash -npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-otlp-grpc -``` - -And instrumentation setup that reads the `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable (injected by Aspire automatically). - -**Python services:** suggest `opentelemetry-distro` and `opentelemetry-exporter-otlp`. - -**Important**: Ask the user before modifying any non-.NET service code. OTel setup may conflict with existing instrumentation. - -### Step 7: Configure NuGet (if needed) - -If the AppHost uses a non-stable Aspire SDK channel (preview, daily, etc.), ensure the appropriate NuGet feed is configured: - -Check `aspire.config.json` for the `channel` field. If it's not `stable`, a `NuGet.config` may need to be created or updated with the appropriate feed URL. - -For single-file mode, this is handled automatically by the `#:sdk` directive. For full project mode, ensure the NuGet.config is in scope. - -### Step 8: Trust development certificates - -```bash -aspire certs trust -``` - -This ensures HTTPS works locally for the Aspire dashboard and service-to-service communication. - -### Step 9: Validate - -```bash -aspire start -``` - -Wait for the AppHost to start. Check that: - -1. The dashboard URL is printed -2. All modeled resources appear in `aspire describe` -3. No startup errors in `aspire logs` -4. .NET services show health check endpoints (from ServiceDefaults) - -If `aspire start` fails: - -1. Read the error output carefully -2. Check `aspire logs` for resource-specific failures -3. Common issues: - - Missing project references — ensure all `dotnet add reference` commands succeeded - - Missing NuGet packages — run `dotnet restore` on the AppHost - - TFM mismatches — ensure all referenced projects target compatible frameworks - - Build errors — run `dotnet build` on the AppHost project to see compiler output - - Port conflicts — check for hardcoded ports that clash - -Iterate until `aspire start` succeeds and all resources are healthy. - -### Step 10: Update solution file (if applicable) - -If a solution file exists, verify all new projects are included: - -```bash -dotnet sln list -``` - -Ensure both the AppHost and ServiceDefaults projects appear. If not, add them: - -```bash -dotnet sln add -dotnet sln add -``` - -### Step 11: Clean up - -After successful validation: - -1. Stop the running AppHost: `aspire stop` -2. **Delete this skill** — remove the `aspire-init-csharp/` skill directory from all locations where it was installed (check `.agents/skills/`, `.github/skills/`, `.claude/skills/`) -3. Confirm the evergreen `aspire` skill is present for ongoing AppHost work - -## Key rules - -- **Use `aspire docs search` before guessing APIs** — look up the correct builder methods for unfamiliar resource types -- **Ask the user before modifying service code** (especially for ServiceDefaults injection and OTel setup) -- **Respect existing project structure** — don't reorganize the repo, work with what's there -- **This is a one-time skill** — delete it after successful init -- **If stuck, use `aspire doctor`** to diagnose environment issues -- **For C# APIs, use `dotnet-inspect` skill** if available to verify method signatures and overloads diff --git a/.agents/skills/aspire-init-typescript/SKILL.md b/.agents/skills/aspire-init-typescript/SKILL.md deleted file mode 100644 index 5d4d40ff798..00000000000 --- a/.agents/skills/aspire-init-typescript/SKILL.md +++ /dev/null @@ -1,270 +0,0 @@ ---- -name: aspire-init-typescript -description: "One-time skill for completing Aspire initialization in a TypeScript AppHost workspace. Run this after `aspire init` has dropped the skeleton apphost.ts and aspire.config.json. This skill scans the repository, wires up projects in the AppHost, configures package.json/tsconfig/eslint, sets up OpenTelemetry for non-.NET services, installs dependencies, and validates that `aspire start` works. Self-removes on success." ---- - -# Aspire Init — TypeScript AppHost - -This is a **one-time setup skill**. It completes the Aspire initialization that `aspire init` started. After this skill finishes successfully, it should be deleted — the evergreen `aspire` skill handles ongoing AppHost work. - -## Prerequisites - -Before running this skill, `aspire init` must have already: - -- Dropped a skeleton `apphost.ts` at the configured location -- Created `aspire.config.json` at the repository root - -Verify both files exist before proceeding. - -## Workflow - -Follow these steps in order. If any step fails, diagnose and fix before continuing. - -### Step 1: Scan the repository - -Analyze the repository to discover all projects and services that could be modeled in the AppHost. - -Look for: - -- **Node.js/TypeScript apps**: directories with `package.json` containing a `start` script, `dev` script, or `main`/`module` entry point -- **.NET projects**: `*.csproj` or `*.fsproj` files (check `OutputType` — `Exe`/`WinExe` are runnable services) -- **Python apps**: directories with `pyproject.toml`, `requirements.txt`, or a `main.py`/`app.py` entry point -- **Go apps**: directories with `go.mod` -- **Java apps**: directories with `pom.xml` or `build.gradle` -- **Dockerfiles**: standalone `Dockerfile` or `docker-compose.yml` entries that represent services -- **Static frontends**: directories with Vite, Next.js, Create React App, or other frontend framework configs - -Ignore: - -- The AppHost directory itself -- `node_modules/`, `.modules/`, `dist/`, `build/`, `bin/`, `obj/`, `.git/` -- Test projects (directories named `test`, `tests`, `__tests__`, or with test-only package.json scripts) - -### Step 2: Present findings and confirm with the user - -Show the user what you found. For each discovered project/service, show: - -- Name (directory name or project name) -- Type (Node.js app, .NET service, Python app, Dockerfile, etc.) -- Entry point (e.g., `src/index.ts`, `Program.cs`, `app.py`) -- Whether it exposes HTTP endpoints (check for `express`, `fastify`, `koa`, `next`, `vite`, ASP.NET, Flask, etc.) - -Ask the user which projects to include in the AppHost. Pre-select all discovered runnable services. - -### Step 3: Wire up apphost.ts - -Edit the skeleton `apphost.ts` to add resource definitions for each selected project. Use the appropriate Aspire builder methods: - -```typescript -import { createBuilder } from './.modules/aspire.js'; - -const builder = await createBuilder(); - -// Example patterns — use the appropriate one for each discovered project type: - -// Node.js/TypeScript app -const api = await builder - .addNodeApp("api", "./api", "src/index.ts") - .withHttpEndpoint({ env: "PORT" }); - -// Vite frontend -const frontend = await builder - .addViteApp("frontend", "./frontend") - .withReference(api) - .waitFor(api); - -// .NET project -const dotnetSvc = await builder - .addProject("catalog", "./src/Catalog/Catalog.csproj"); - -// Dockerfile-based service -const worker = await builder - .addDockerfile("worker", "./worker"); - -// Python app -const pyApi = await builder - .addPythonApp("py-api", "./py-api", "app.py"); - -await builder.build().run(); -``` - -**Important rules:** - -- Use `aspire docs search` and `aspire docs get` to look up the correct builder API for each resource type before writing code. Do not guess API shapes. -- Check `.modules/aspire.ts` (after Step 5) to confirm available APIs. -- Use meaningful resource names derived from the directory/project name. -- Wire up `withReference()` and `waitFor()` for services that depend on each other (ask the user if dependency relationships are unclear). -- Expose HTTP endpoints with `withHttpEndpoint()` for services that serve HTTP traffic. -- Use `withExternalHttpEndpoints()` for user-facing frontends. - -### Step 4: Configure package.json - -If a root `package.json` already exists, **augment it** — do not overwrite. Add: - -```json -{ - "type": "module", - "scripts": { - "start": "npx tsc && node --enable-source-maps apphost.js" - }, - "dependencies": { - // Added by aspire restore — do not manually add Aspire packages - } -} -``` - -If no root `package.json` exists, create one with: - -```json -{ - "name": "-apphost", - "version": "1.0.0", - "type": "module", - "scripts": { - "start": "npx tsc && node --enable-source-maps apphost.js" - } -} -``` - -**Important rules:** - -- Never overwrite existing `scripts`, `dependencies`, or `devDependencies` — merge only. -- Set `"type": "module"` if not already set (required for ESM imports in apphost.ts). -- Do not manually add Aspire SDK packages to dependencies — `aspire restore` handles this. - -### Step 5: Run aspire restore - -```bash -aspire restore -``` - -This generates the `.modules/` directory with TypeScript SDK bindings. After restore completes, inspect `.modules/aspire.ts` to confirm the available API surface matches what you used in apphost.ts. - -If restore fails, diagnose the error. Common issues: - -- Missing `aspire.config.json` — ensure it exists at repo root -- Wrong `appHost.path` in config — ensure it points to the correct `apphost.ts` -- Network issues downloading SDK packages - -### Step 6: Configure tsconfig.json - -If a root `tsconfig.json` already exists, augment it to include the AppHost compilation: - -- Ensure `".modules/**/*.ts"` is in the `include` array -- Ensure `"apphost.ts"` is in the `include` array (or covered by an existing glob) -- Ensure `"module"` is set to `"nodenext"` or `"node16"` (ESM required) -- Ensure `"moduleResolution"` matches the module setting - -If no `tsconfig.json` exists, check if `aspire restore` created one. If not, create a minimal one: - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "nodenext", - "moduleResolution": "nodenext", - "esModuleInterop": true, - "strict": true, - "outDir": "./dist", - "rootDir": "." - }, - "include": ["apphost.ts", ".modules/**/*.ts"] -} -``` - -If the repo has a separate `tsconfig.apphost.json`, ensure it's referenced properly. If using TypeScript project references, add it as a reference. - -### Step 7: Handle ESLint configuration - -If the project uses ESLint with `typescript-eslint` project service: - -1. Check if `.eslintrc.*` or `eslint.config.*` exists -2. If it uses `parserOptions.project` or `parserOptions.projectService`, ensure the AppHost tsconfig is discoverable -3. Common fix: add `tsconfig.apphost.json` to `parserOptions.project` array, or configure `projectService.allowDefaultProject` to include `apphost.ts` - -If no ESLint config exists, skip this step. - -**Do not create ESLint configuration from scratch** — only augment existing configs to recognize the AppHost files. - -### Step 8: Wire up OpenTelemetry for non-.NET services - -For each non-.NET service included in the AppHost, configure OpenTelemetry so the Aspire dashboard can show traces, metrics, and logs. This is the equivalent of what ServiceDefaults does for .NET projects. - -**Node.js/TypeScript services:** - -Check if the service already has OpenTelemetry configured. If not, suggest adding: - -```bash -npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-otlp-grpc -``` - -And an instrumentation file (e.g., `instrumentation.ts`): - -```typescript -import { NodeSDK } from '@opentelemetry/sdk-node'; -import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; -import { OTLPTraceExporter } from '@opentelemetry/exporter-otlp-grpc'; - -const sdk = new NodeSDK({ - traceExporter: new OTLPTraceExporter(), - instrumentations: [getNodeAutoInstrumentations()], -}); - -sdk.start(); -``` - -The OTLP endpoint URL is injected by Aspire via environment variables — the service just needs to read `OTEL_EXPORTER_OTLP_ENDPOINT`. - -**Python services**: suggest `opentelemetry-distro` and `opentelemetry-exporter-otlp`. - -**Other languages**: point the user to the OpenTelemetry docs for their language and note that the OTLP endpoint will be injected via environment variables by Aspire. - -**Important**: Ask the user before modifying any service code. OTel setup may conflict with existing instrumentation. Present it as a recommendation, not an automatic change. - -### Step 9: Install dependencies - -```bash -npm install -``` - -Run this from the repo root (or wherever package.json lives) to install all dependencies including any added by aspire restore. - -### Step 10: Validate - -```bash -aspire start -``` - -Wait for the AppHost to start. Check that: - -1. The dashboard URL is printed -2. All modeled resources appear in `aspire describe` -3. No startup errors in `aspire logs` - -If `aspire start` fails: - -1. Read the error output carefully -2. Check `aspire logs` for resource-specific failures -3. Common issues: - - Missing dependencies — run `npm install` again - - TypeScript compilation errors — check tsconfig and fix type issues - - Port conflicts — ensure no hardcoded ports clash - - Missing environment variables — check if services need specific env vars - -Iterate until `aspire start` succeeds and all resources are healthy. - -### Step 11: Clean up - -After successful validation: - -1. Stop the running AppHost: `aspire stop` -2. **Delete this skill** — remove the `aspire-init-typescript/` skill directory from all locations where it was installed (check `.agents/skills/`, `.github/skills/`, `.claude/skills/`) -3. Confirm the evergreen `aspire` skill is present for ongoing AppHost work - -## Key rules - -- **Never overwrite existing files** — always augment/merge -- **Use `aspire docs search` before guessing APIs** — look up the correct builder methods -- **Ask the user before modifying service code** (especially for OTel setup) -- **This is a one-time skill** — delete it after successful init -- **If stuck, use `aspire doctor`** to diagnose environment issues diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md new file mode 100644 index 00000000000..d142398b2d9 --- /dev/null +++ b/.agents/skills/aspire-init/SKILL.md @@ -0,0 +1,358 @@ +--- +name: aspire-init +description: "One-time skill for completing Aspire initialization after `aspire init` has dropped the skeleton AppHost and aspire.config.json. This skill scans the repository, discovers projects, wires up the AppHost (TypeScript or C#), configures dependencies and OpenTelemetry, validates that `aspire start` works, and self-removes on success." +--- + +# Aspire Init + +This is a **one-time setup skill**. It completes the Aspire initialization that `aspire init` started. After this skill finishes successfully, it should be deleted — the evergreen `aspire` skill handles ongoing AppHost work. + +## Prerequisites + +Before running this skill, `aspire init` must have already: + +- Dropped a skeleton AppHost file (`apphost.ts` or `apphost.cs`) at the configured location +- Created `aspire.config.json` at the repository root + +Verify both exist before proceeding. + +## Determine your context + +Read `aspire.config.json` at the repository root. Key fields: + +- **`appHost.language`**: `"typescript/nodejs"` or `"csharp"` — determines which syntax and tooling to use +- **`appHost.path`**: path to the AppHost file or project directory — this is where you'll edit code + +For C# AppHosts, there are two sub-modes: + +- **Single-file mode**: `appHost.path` points directly to an `apphost.cs` file using the `#:sdk` directive. No `.csproj` needed. +- **Full project mode**: `appHost.path` points to a directory containing a `.csproj` and `apphost.cs`. This was created because a `.sln`/`.slnx` was found. + +Check which mode you're in by looking at what exists at the `appHost.path` location. + +## Workflow + +Follow these steps in order. If any step fails, diagnose and fix before continuing. + +### Step 1: Scan the repository + +Analyze the repository to discover all projects and services that could be modeled in the AppHost. + +**What to look for:** + +- **.NET projects**: `*.csproj` and `*.fsproj` files. For each, run: + - `dotnet msbuild -getProperty:OutputType` — `Exe`/`WinExe` = runnable service, `Library` = skip + - `dotnet msbuild -getProperty:TargetFramework` — must be `net8.0` or newer + - `dotnet msbuild -getProperty:IsAspireHost` — skip if `true` +- **Node.js/TypeScript apps**: directories with `package.json` containing a `start`, `dev`, or `main`/`module` entry +- **Python apps**: directories with `pyproject.toml`, `requirements.txt`, or `main.py`/`app.py` +- **Go apps**: directories with `go.mod` +- **Java apps**: directories with `pom.xml` or `build.gradle` +- **Dockerfiles**: standalone `Dockerfile` or `docker-compose.yml` entries representing services +- **Static frontends**: Vite, Next.js, Create React App, or other frontend framework configs + +**Ignore:** + +- The AppHost directory/file itself +- `node_modules/`, `.modules/`, `dist/`, `build/`, `bin/`, `obj/`, `.git/` +- Test projects (directories named `test`/`tests`/`__tests__`, projects referencing xUnit/NUnit/MSTest, or test-only package.json scripts) + +### Step 2: Present findings and confirm with the user + +Show the user what you found. For each discovered project/service, show: + +- Name (project or directory name) +- Type (.NET service, Node.js app, Python app, Dockerfile, etc.) +- Framework/runtime info (e.g., net10.0, Node 20, Python 3.12) +- Whether it exposes HTTP endpoints + +Ask the user: + +1. Which projects to include in the AppHost (pre-select all discovered runnable services) +2. For C# AppHosts: which .NET projects should receive ServiceDefaults references (pre-select all .NET services) + +### Step 3: Create ServiceDefaults (C# only) + +> **Skip this step for TypeScript AppHosts.** OTel for non-.NET services is handled in Step 7. + +If no ServiceDefaults project exists in the repo, create one: + +```bash +dotnet new aspire-servicedefaults -n .ServiceDefaults -o +``` + +Place it alongside the AppHost (e.g., `src/` or solution root). If a `.sln` exists, add it: + +```bash +dotnet sln add +``` + +If a ServiceDefaults project already exists (look for references to `Microsoft.Extensions.ServiceDiscovery` or `Aspire.ServiceDefaults`), skip creation and use the existing one. + +### Step 4: Wire up the AppHost + +Edit the skeleton AppHost file to add resource definitions for each selected project. Use the appropriate syntax based on language. + +#### TypeScript AppHost (`apphost.ts`) + +```typescript +import { createBuilder } from './.modules/aspire.js'; + +const builder = await createBuilder(); + +// Node.js/TypeScript app +const api = await builder + .addNodeApp("api", "./api", "src/index.ts") + .withHttpEndpoint({ env: "PORT" }); + +// Vite frontend +const frontend = await builder + .addViteApp("frontend", "./frontend") + .withReference(api) + .waitFor(api); + +// .NET project +const dotnetSvc = await builder + .addProject("catalog", "./src/Catalog/Catalog.csproj"); + +// Dockerfile-based service +const worker = await builder + .addDockerfile("worker", "./worker"); + +// Python app +const pyApi = await builder + .addPythonApp("py-api", "./py-api", "app.py"); + +await builder.build().run(); +``` + +#### C# AppHost — single-file mode (`apphost.cs`) + +```csharp +#:sdk Aspire.AppHost.Sdk@ +#:property IsAspireHost=true + +// Project references +#:project ../src/Api/Api.csproj +#:project ../src/Web/Web.csproj + +var builder = DistributedApplication.CreateBuilder(args); + +var api = builder.AddProject("api"); + +var web = builder.AddProject("web") + .WithReference(api) + .WaitFor(api); + +builder.Build().Run(); +``` + +#### C# AppHost — full project mode (`apphost.cs` + `.csproj`) + +Edit `apphost.cs`: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var api = builder.AddProject("api"); + +var web = builder.AddProject("web") + .WithReference(api) + .WaitFor(api); + +builder.Build().Run(); +``` + +And add project references: + +```bash +dotnet add reference +dotnet add reference +``` + +#### Non-.NET services in a C# AppHost + +```csharp +// Node.js app (requires Aspire.Hosting.NodeJs) +var frontend = builder.AddNpmApp("frontend", "../frontend", "start"); + +// Dockerfile-based service +var worker = builder.AddDockerfile("worker", "../worker"); + +// Python app (requires Aspire.Hosting.Python) +var pyApi = builder.AddPythonApp("py-api", "../py-api", "app.py"); +``` + +Add required hosting NuGet packages: + +```bash +dotnet add package Aspire.Hosting.NodeJs +dotnet add package Aspire.Hosting.Python +``` + +**Important rules:** + +- Use `aspire docs search` and `aspire docs get` to look up the correct builder API for each resource type. Do not guess API shapes. +- Check `.modules/aspire.ts` (TypeScript) or NuGet package APIs (C#) to confirm available methods. +- Use meaningful resource names derived from the project/directory name. +- Wire up `WithReference()`/`withReference()` and `WaitFor()`/`waitFor()` for services that depend on each other (ask the user if relationships are unclear). +- Use `WithExternalHttpEndpoints()`/`withExternalHttpEndpoints()` for user-facing frontends. + +### Step 5: Configure dependencies + +#### TypeScript AppHost + +**package.json** — if one exists at the root, augment it (do not overwrite). Add/merge: + +```json +{ + "type": "module", + "scripts": { + "start": "npx tsc && node --enable-source-maps apphost.js" + } +} +``` + +If no root `package.json` exists, create a minimal one: + +```json +{ + "name": "-apphost", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "npx tsc && node --enable-source-maps apphost.js" + } +} +``` + +Never overwrite existing `scripts`, `dependencies`, or `devDependencies` — merge only. Do not manually add Aspire SDK packages — `aspire restore` handles those. + +Run `aspire restore` to generate the `.modules/` directory with TypeScript SDK bindings, then `npm install`. + +**tsconfig.json** — augment if it exists: + +- Ensure `".modules/**/*.ts"` and `"apphost.ts"` are in `include` +- Ensure `"module"` is `"nodenext"` or `"node16"` (ESM required) +- Ensure `"moduleResolution"` matches + +If no `tsconfig.json` exists and `aspire restore` didn't create one, create a minimal one: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "strict": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": ["apphost.ts", ".modules/**/*.ts"] +} +``` + +**ESLint** — only augment if config already exists. If it uses `parserOptions.project` or `parserOptions.projectService`, ensure the AppHost tsconfig is discoverable. Do not create ESLint configuration from scratch. + +#### C# AppHost + +**Full project mode**: dependencies are managed via the `.csproj` and `dotnet add package`/`dotnet add reference` (already handled in Steps 3-4). + +**Single-file mode**: dependencies are managed via `#:sdk` and `#:project` directives in the `apphost.cs` file. + +**NuGet feeds**: If `aspire.config.json` specifies a non-stable channel (preview, daily), ensure the appropriate NuGet feed is configured. For single-file mode this is automatic; for project mode, ensure a `NuGet.config` is in scope. + +### Step 6: Add ServiceDefaults to .NET projects (C# AppHost only) + +> **Skip this step for TypeScript AppHosts.** + +For each .NET project that the user selected for ServiceDefaults: + +```bash +dotnet add reference +``` + +Then check each project's `Program.cs` (or equivalent entry point) and add if not already present: + +```csharp +builder.AddServiceDefaults(); // Add early, after builder creation +``` + +And before `app.Run()`: + +```csharp +app.MapDefaultEndpoints(); +``` + +Be careful with code placement — look at existing structure (top-level statements vs `Startup.cs` vs `Program.Main`). Do not duplicate if already present. + +### Step 7: Wire up OpenTelemetry for non-.NET services + +For non-.NET services included in the AppHost, configure OpenTelemetry so the Aspire dashboard shows their traces, metrics, and logs. This is the equivalent of what ServiceDefaults does for .NET. + +**Node.js/TypeScript services:** + +```bash +npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-otlp-grpc +``` + +Add an instrumentation file that reads `OTEL_EXPORTER_OTLP_ENDPOINT` (injected by Aspire automatically). + +**Python services**: suggest `opentelemetry-distro` and `opentelemetry-exporter-otlp`. + +**Other languages**: point the user to OpenTelemetry docs for their language. The OTLP endpoint is injected via environment variables by Aspire. + +**Important**: Ask the user before modifying any service code. OTel setup may conflict with existing instrumentation. Present it as a recommendation, not an automatic change. + +### Step 8: Trust development certificates + +```bash +aspire certs trust +``` + +### Step 9: Validate + +```bash +aspire start +``` + +Check that: + +1. The dashboard URL is printed +2. All modeled resources appear in `aspire describe` +3. No startup errors in `aspire logs` + +If it fails, diagnose and iterate. Common issues: + +- **TypeScript**: missing `npm install`, TS compilation errors, port conflicts +- **C# project mode**: missing project references, NuGet restore needed, TFM mismatches, build errors +- **C# single-file**: `#:project` paths wrong, missing SDK directive +- **Both**: missing environment variables, port conflicts + +### Step 10: Update solution file (C# full project mode only) + +If a `.sln`/`.slnx` exists, verify all new projects are included: + +```bash +dotnet sln list +``` + +Ensure both the AppHost and ServiceDefaults projects appear. + +### Step 11: Clean up + +After successful validation: + +1. Stop the running AppHost: `aspire stop` +2. **Delete this skill** — remove the `aspire-init/` skill directory from all locations where it was installed (check `.agents/skills/`, `.github/skills/`, `.claude/skills/`) +3. Confirm the evergreen `aspire` skill is present for ongoing AppHost work + +## Key rules + +- **Never overwrite existing files** — always augment/merge +- **Use `aspire docs search` before guessing APIs** — look up the correct builder methods +- **Ask the user before modifying service code** (especially OTel and ServiceDefaults injection) +- **Respect existing project structure** — don't reorganize the repo +- **This is a one-time skill** — delete it after successful init +- **If stuck, use `aspire doctor`** to diagnose environment issues diff --git a/.agents/skills/aspire/SKILL.md b/.agents/skills/aspire/SKILL.md index 787cbe71186..86b2cb2c38c 100644 --- a/.agents/skills/aspire/SKILL.md +++ b/.agents/skills/aspire/SKILL.md @@ -12,7 +12,7 @@ Resources are typically defined in an AppHost such as, `AppHost.cs`, `apphost.ts ## Use this skill for - Starting, restarting, and stopping AppHosts with `aspire start` and `aspire stop` -- Initializing Aspire in an existing app with `aspire init` (drops skeleton files; use the `aspire-init-typescript` or `aspire-init-csharp` skill to complete wiring) +- Initializing Aspire in an existing app with `aspire init` (drops skeleton files; use the `aspire-init` skill to complete wiring) - Inspecting resources, logs, traces, and docs - Adding integrations with `aspire add` - Recovering missing TypeScript AppHost support files with `aspire restore` diff --git a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs index 2ee21350c90..e0432fafa00 100644 --- a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs +++ b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs @@ -21,24 +21,14 @@ internal static class CommonAgentApplicators internal const string AspireSkillResourceRoot = "skills.aspire"; /// - /// The name of the Aspire init TypeScript skill. + /// The name of the Aspire init skill. /// - internal const string AspireInitTypeScriptSkillName = "aspire-init-typescript"; + internal const string AspireInitSkillName = "aspire-init"; /// - /// The embedded resource root for the Aspire init TypeScript skill. + /// The embedded resource root for the Aspire init skill. /// - internal const string AspireInitTypeScriptSkillResourceRoot = "skills.aspire-init-typescript"; - - /// - /// The name of the Aspire init C# skill. - /// - internal const string AspireInitCSharpSkillName = "aspire-init-csharp"; - - /// - /// The embedded resource root for the Aspire init C# skill. - /// - internal const string AspireInitCSharpSkillResourceRoot = "skills.aspire-init-csharp"; + internal const string AspireInitSkillResourceRoot = "skills.aspire-init"; /// /// The name of the dotnet-inspect skill. diff --git a/src/Aspire.Cli/Agents/SkillDefinition.cs b/src/Aspire.Cli/Agents/SkillDefinition.cs index bdefefc0b64..b1cf08b01be 100644 --- a/src/Aspire.Cli/Agents/SkillDefinition.cs +++ b/src/Aspire.Cli/Agents/SkillDefinition.cs @@ -47,30 +47,16 @@ internal sealed class SkillDefinition applicableLanguages: [KnownLanguageId.CSharp]); /// - /// One-time skill for completing Aspire initialization in a TypeScript AppHost workspace. - /// Installed by aspire init when the user selects TypeScript. + /// One-time skill for completing Aspire initialization. + /// Installed by aspire init to scan the repo, wire up the AppHost, and configure dependencies. /// - public static readonly SkillDefinition AspireInitTypeScript = new( - CommonAgentApplicators.AspireInitTypeScriptSkillName, - AgentCommandStrings.SkillDescription_AspireInitTypeScript, + public static readonly SkillDefinition AspireInit = new( + CommonAgentApplicators.AspireInitSkillName, + AgentCommandStrings.SkillDescription_AspireInit, skillContent: null, - embeddedResourceRoot: CommonAgentApplicators.AspireInitTypeScriptSkillResourceRoot, + embeddedResourceRoot: CommonAgentApplicators.AspireInitSkillResourceRoot, installExcludedRelativePaths: [], - isDefault: false, - applicableLanguages: [KnownLanguageId.TypeScript]); - - /// - /// One-time skill for completing Aspire initialization in a C# AppHost workspace. - /// Installed by aspire init when the user selects C#. - /// - public static readonly SkillDefinition AspireInitCSharp = new( - CommonAgentApplicators.AspireInitCSharpSkillName, - AgentCommandStrings.SkillDescription_AspireInitCSharp, - skillContent: null, - embeddedResourceRoot: CommonAgentApplicators.AspireInitCSharpSkillResourceRoot, - installExcludedRelativePaths: [], - isDefault: false, - applicableLanguages: [KnownLanguageId.CSharp]); + isDefault: false); private SkillDefinition(string name, string description, string? skillContent, string? embeddedResourceRoot, IReadOnlyList installExcludedRelativePaths, bool isDefault, IReadOnlyList? applicableLanguages = null) { @@ -176,5 +162,5 @@ private static bool PathMatchesOrIsUnder(string relativePath, string excludedPat /// /// Gets all available skill definitions. /// - public static IReadOnlyList All { get; } = [Aspire, PlaywrightCli, DotnetInspect, AspireInitTypeScript, AspireInitCSharp]; + public static IReadOnlyList All { get; } = [Aspire, PlaywrightCli, DotnetInspect, AspireInit]; } diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index dc87a449f49..26e4d8779e7 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -221,10 +221,7 @@ false - - false - - + false diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 79846103f4e..06a1dabc4b0 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -86,7 +86,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } // Step 4: Install the appropriate init skill. - var initSkill = isCSharp ? SkillDefinition.AspireInitCSharp : SkillDefinition.AspireInitTypeScript; + var initSkill = SkillDefinition.AspireInit; var skillInstalled = await InstallInitSkillAsync(workingDirectory, initSkill, cancellationToken); if (!skillInstalled) { diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs index 85ae817ea22..c97388a8f4b 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs @@ -259,20 +259,11 @@ internal static string SkillDescription_DotnetInspect { } /// - /// Looks up a localized string similar to One-time setup: wire up TypeScript AppHost with discovered projects. + /// Looks up a localized string similar to One-time setup: wire up AppHost with discovered projects. /// - internal static string SkillDescription_AspireInitTypeScript { + internal static string SkillDescription_AspireInit { get { - return ResourceManager.GetString("SkillDescription_AspireInitTypeScript", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to One-time setup: wire up C# AppHost with discovered projects. - /// - internal static string SkillDescription_AspireInitCSharp { - get { - return ResourceManager.GetString("SkillDescription_AspireInitCSharp", resourceCulture); + return ResourceManager.GetString("SkillDescription_AspireInit", resourceCulture); } } diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.resx b/src/Aspire.Cli/Resources/AgentCommandStrings.resx index 86e844d8506..fdd956d3571 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.resx @@ -126,11 +126,8 @@ Query .NET API surfaces across NuGet packages and platform libraries - - One-time setup: wire up TypeScript AppHost with discovered projects - - - One-time setup: wire up C# AppHost with discovered projects + + One-time setup: wire up AppHost with discovered projects Standard (.agents/skills/) diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf index b36db8f6557..f5c1d7e4794 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf @@ -107,14 +107,9 @@ Aspire CLI commands and workflows for distributed apps - - One-time setup: wire up C# AppHost with discovered projects - One-time setup: wire up C# AppHost with discovered projects - - - - One-time setup: wire up TypeScript AppHost with discovered projects - One-time setup: wire up TypeScript AppHost with discovered projects + + One-time setup: wire up AppHost with discovered projects + One-time setup: wire up AppHost with discovered projects diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf index 6d350e2acd8..475e4ad0184 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf @@ -107,14 +107,9 @@ Aspire CLI commands and workflows for distributed apps - - One-time setup: wire up C# AppHost with discovered projects - One-time setup: wire up C# AppHost with discovered projects - - - - One-time setup: wire up TypeScript AppHost with discovered projects - One-time setup: wire up TypeScript AppHost with discovered projects + + One-time setup: wire up AppHost with discovered projects + One-time setup: wire up AppHost with discovered projects diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf index 0871408b1e1..93b735f6638 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf @@ -107,14 +107,9 @@ Aspire CLI commands and workflows for distributed apps - - One-time setup: wire up C# AppHost with discovered projects - One-time setup: wire up C# AppHost with discovered projects - - - - One-time setup: wire up TypeScript AppHost with discovered projects - One-time setup: wire up TypeScript AppHost with discovered projects + + One-time setup: wire up AppHost with discovered projects + One-time setup: wire up AppHost with discovered projects diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf index 419f52bae33..268f9d820d6 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf @@ -107,14 +107,9 @@ Aspire CLI commands and workflows for distributed apps - - One-time setup: wire up C# AppHost with discovered projects - One-time setup: wire up C# AppHost with discovered projects - - - - One-time setup: wire up TypeScript AppHost with discovered projects - One-time setup: wire up TypeScript AppHost with discovered projects + + One-time setup: wire up AppHost with discovered projects + One-time setup: wire up AppHost with discovered projects diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf index a84bcf61b79..5d0bcd0772f 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf @@ -107,14 +107,9 @@ Aspire CLI commands and workflows for distributed apps - - One-time setup: wire up C# AppHost with discovered projects - One-time setup: wire up C# AppHost with discovered projects - - - - One-time setup: wire up TypeScript AppHost with discovered projects - One-time setup: wire up TypeScript AppHost with discovered projects + + One-time setup: wire up AppHost with discovered projects + One-time setup: wire up AppHost with discovered projects diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf index 543a9e0eb24..be371d3ba7c 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf @@ -107,14 +107,9 @@ Aspire CLI commands and workflows for distributed apps - - One-time setup: wire up C# AppHost with discovered projects - One-time setup: wire up C# AppHost with discovered projects - - - - One-time setup: wire up TypeScript AppHost with discovered projects - One-time setup: wire up TypeScript AppHost with discovered projects + + One-time setup: wire up AppHost with discovered projects + One-time setup: wire up AppHost with discovered projects diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf index a574c211a16..91a7f94b716 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf @@ -107,14 +107,9 @@ Aspire CLI commands and workflows for distributed apps - - One-time setup: wire up C# AppHost with discovered projects - One-time setup: wire up C# AppHost with discovered projects - - - - One-time setup: wire up TypeScript AppHost with discovered projects - One-time setup: wire up TypeScript AppHost with discovered projects + + One-time setup: wire up AppHost with discovered projects + One-time setup: wire up AppHost with discovered projects diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf index 197e0e91503..3d382e56785 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf @@ -107,14 +107,9 @@ Aspire CLI commands and workflows for distributed apps - - One-time setup: wire up C# AppHost with discovered projects - One-time setup: wire up C# AppHost with discovered projects - - - - One-time setup: wire up TypeScript AppHost with discovered projects - One-time setup: wire up TypeScript AppHost with discovered projects + + One-time setup: wire up AppHost with discovered projects + One-time setup: wire up AppHost with discovered projects diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf index d055510cdb5..cfd11548bfc 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf @@ -107,14 +107,9 @@ Aspire CLI commands and workflows for distributed apps - - One-time setup: wire up C# AppHost with discovered projects - One-time setup: wire up C# AppHost with discovered projects - - - - One-time setup: wire up TypeScript AppHost with discovered projects - One-time setup: wire up TypeScript AppHost with discovered projects + + One-time setup: wire up AppHost with discovered projects + One-time setup: wire up AppHost with discovered projects diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf index ac148d1317c..9823459ebb7 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf @@ -107,14 +107,9 @@ Aspire CLI commands and workflows for distributed apps - - One-time setup: wire up C# AppHost with discovered projects - One-time setup: wire up C# AppHost with discovered projects - - - - One-time setup: wire up TypeScript AppHost with discovered projects - One-time setup: wire up TypeScript AppHost with discovered projects + + One-time setup: wire up AppHost with discovered projects + One-time setup: wire up AppHost with discovered projects diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf index c8841b37363..3fb198e7488 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf @@ -107,14 +107,9 @@ Aspire CLI commands and workflows for distributed apps - - One-time setup: wire up C# AppHost with discovered projects - One-time setup: wire up C# AppHost with discovered projects - - - - One-time setup: wire up TypeScript AppHost with discovered projects - One-time setup: wire up TypeScript AppHost with discovered projects + + One-time setup: wire up AppHost with discovered projects + One-time setup: wire up AppHost with discovered projects diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf index 100f373635a..588e843eb96 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf @@ -107,14 +107,9 @@ Aspire CLI commands and workflows for distributed apps - - One-time setup: wire up C# AppHost with discovered projects - One-time setup: wire up C# AppHost with discovered projects - - - - One-time setup: wire up TypeScript AppHost with discovered projects - One-time setup: wire up TypeScript AppHost with discovered projects + + One-time setup: wire up AppHost with discovered projects + One-time setup: wire up AppHost with discovered projects diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf index 5a54306ecef..a959f5337d3 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf @@ -107,14 +107,9 @@ Aspire CLI commands and workflows for distributed apps - - One-time setup: wire up C# AppHost with discovered projects - One-time setup: wire up C# AppHost with discovered projects - - - - One-time setup: wire up TypeScript AppHost with discovered projects - One-time setup: wire up TypeScript AppHost with discovered projects + + One-time setup: wire up AppHost with discovered projects + One-time setup: wire up AppHost with discovered projects From 0353f294c0fc3b7def41ac304ac1602f0440ea3f Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 20:03:00 -0400 Subject: [PATCH 05/48] Enhance init skill: docker-compose parsing, certs as troubleshooting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add detailed docker-compose/compose.yml scanning guidance — extract services, images, ports, env vars, volumes, depends_on, and build contexts. Map known images to typed Aspire integrations. Move cert trust from a required step to a troubleshooting bullet under validation (aspire start handles it automatically). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index d142398b2d9..3d09b135849 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -48,7 +48,16 @@ Analyze the repository to discover all projects and services that could be model - **Python apps**: directories with `pyproject.toml`, `requirements.txt`, or `main.py`/`app.py` - **Go apps**: directories with `go.mod` - **Java apps**: directories with `pom.xml` or `build.gradle` -- **Dockerfiles**: standalone `Dockerfile` or `docker-compose.yml` entries representing services +- **Dockerfiles**: standalone `Dockerfile` entries representing services +- **Docker Compose**: `docker-compose.yml` or `compose.yml` files — these are a goldmine. Parse them to extract: + - **Services**: each named service maps to a potential AppHost resource + - **Images**: container images used (e.g., `postgres:16`, `redis:7`) → these become `AddContainer()` or typed Aspire integrations (e.g., `AddPostgres()`, `AddRedis()`) + - **Ports**: published port mappings → `WithHttpEndpoint()` or `WithEndpoint()` + - **Environment variables**: env vars and `.env` file references → `WithEnvironment()` + - **Volumes**: named/bind volumes → `WithVolume()` or `WithBindMount()` + - **Dependencies**: `depends_on` → `WithReference()` and `WaitFor()` + - **Build contexts**: `build:` entries → `AddDockerfile()` pointing to the build context directory + - Prefer typed Aspire integrations over raw `AddContainer()` when the image matches a known integration (use `aspire docs search` to check). For example, `postgres:16` → `AddPostgres()`, `redis:7` → `AddRedis()`, `rabbitmq:3` → `AddRabbitMQ()`. - **Static frontends**: Vite, Next.js, Create React App, or other frontend framework configs **Ignore:** @@ -305,13 +314,7 @@ Add an instrumentation file that reads `OTEL_EXPORTER_OTLP_ENDPOINT` (injected b **Important**: Ask the user before modifying any service code. OTel setup may conflict with existing instrumentation. Present it as a recommendation, not an automatic change. -### Step 8: Trust development certificates - -```bash -aspire certs trust -``` - -### Step 9: Validate +### Step 8: Validate ```bash aspire start @@ -329,8 +332,9 @@ If it fails, diagnose and iterate. Common issues: - **C# project mode**: missing project references, NuGet restore needed, TFM mismatches, build errors - **C# single-file**: `#:project` paths wrong, missing SDK directive - **Both**: missing environment variables, port conflicts +- **Certificate errors**: if HTTPS fails, run `aspire certs trust` and retry -### Step 10: Update solution file (C# full project mode only) +### Step 9: Update solution file (C# full project mode only) If a `.sln`/`.slnx` exists, verify all new projects are included: @@ -340,7 +344,7 @@ dotnet sln list Ensure both the AppHost and ServiceDefaults projects appear. -### Step 11: Clean up +### Step 10: Clean up After successful validation: From 3177984f967add0b926a6d92176f844f0d96cbe8 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 20:07:59 -0400 Subject: [PATCH 06/48] Add comprehensive AppHost wiring reference to init skill Cover WithReference vs WithEnvironment, endpoint/port patterns (env: for non-.NET), service discovery env var naming, WithExternalHttpEndpoints, dev.localhost domains, URL labels, WaitFor/WaitForCompletion, container lifetimes (persistent), explicit start, parent relationships, volumes, and aspire docs search/get workflow for looking up APIs and integrations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 243 +++++++++++++++++++++++++++- 1 file changed, 242 insertions(+), 1 deletion(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 3d09b135849..e771e2a53d7 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -355,8 +355,249 @@ After successful validation: ## Key rules - **Never overwrite existing files** — always augment/merge -- **Use `aspire docs search` before guessing APIs** — look up the correct builder methods - **Ask the user before modifying service code** (especially OTel and ServiceDefaults injection) - **Respect existing project structure** — don't reorganize the repo - **This is a one-time skill** — delete it after successful init - **If stuck, use `aspire doctor`** to diagnose environment issues + +## Looking up APIs and integrations + +Before writing AppHost code for an unfamiliar resource type or integration, **always** look it up: + +```bash +# Search for documentation on a topic +aspire docs search "redis" +aspire docs search "node app endpoints" + +# Get a specific doc page by slug (returned from search results) +aspire docs get "redis-integration" +``` + +Use `aspire docs search` to find the right builder methods, configuration options, and patterns. Use `aspire docs get ` to read the full doc page. Do not guess API shapes — Aspire has many resource types with specific overloads. + +To add an integration package (which unlocks typed builder methods): + +```bash +aspire add Aspire.Hosting.Redis +aspire add Aspire.Hosting.NodeJs +aspire add Aspire.Hosting.Python +``` + +After adding, run `aspire restore` (TypeScript) or `dotnet restore` (C#) to update available APIs, then check what methods are now available. + +## AppHost wiring reference + +This section covers the patterns you'll need when writing Step 4 (Wire up the AppHost). Refer back to it as needed. + +### Service communication: `WithReference` vs `WithEnvironment` + +**`WithReference()`** is the primary way to connect services. It does two things: + +1. Injects the referenced resource's connection information (connection string or URL) into the consuming service +2. Enables Aspire service discovery — .NET services can resolve the referenced resource by name + +```csharp +// C#: api gets the database connection string injected automatically +var db = builder.AddPostgres("pg").AddDatabase("mydb"); +var api = builder.AddProject("api") + .WithReference(db); + +// C#: frontend gets service discovery URL for api +var frontend = builder.AddProject("web") + .WithReference(api); +``` + +```typescript +// TypeScript equivalent +const db = await builder.addPostgres("pg").addDatabase("mydb"); +const api = await builder.addProject("api", "./src/Api/Api.csproj") + .withReference(db); +``` + +**How non-.NET services consume references**: They receive environment variables. The naming convention is: +- Connection strings: `ConnectionStrings__` (e.g., `ConnectionStrings__mydb=Host=...`) +- Service URLs: `services______0` (e.g., `services__api__http__0=http://localhost:5123`) + +**`WithEnvironment()`** injects raw environment variables. Use this for custom config that isn't a service reference: + +```csharp +var api = builder.AddProject("api") + .WithEnvironment("FEATURE_FLAG_X", "true") + .WithEnvironment("API_KEY", someParameter); +``` + +**When to use which:** +- Connecting service A to service B or a database/cache/queue → `WithReference()` +- Passing configuration values, feature flags, API keys → `WithEnvironment()` +- Never manually construct connection strings with `WithEnvironment()` when `WithReference()` would work + +### Endpoints and ports + +**`WithHttpEndpoint()`** — expose an HTTP endpoint. For services that serve HTTP traffic: + +```csharp +// Let Aspire assign a random port (recommended for most cases) +var api = builder.AddProject("api") + .WithHttpEndpoint(); + +// Use a specific port +var api = builder.AddProject("api") + .WithHttpEndpoint(port: 5000); + +// For non-.NET services that read the port from an env var +var nodeApi = builder.AddNpmApp("api", "../api", "start") + .WithHttpEndpoint(env: "PORT"); // Aspire injects PORT= +``` + +**`WithHttpsEndpoint()`** — same as above but for HTTPS. + +**`WithEndpoint()`** — expose a non-HTTP endpoint (gRPC, TCP, custom protocols): + +```csharp +var grpcService = builder.AddProject("grpc") + .WithEndpoint("grpc", endpoint => + { + endpoint.Port = 5050; + endpoint.Protocol = "grpc"; + }); +``` + +**`WithExternalHttpEndpoints()`** — mark a resource's HTTP endpoints as externally visible. Use this for user-facing frontends so the URL appears prominently in the dashboard: + +```csharp +var frontend = builder.AddNpmApp("frontend", "../frontend", "dev") + .WithHttpEndpoint(env: "PORT") + .WithExternalHttpEndpoints(); +``` + +**Port injection for non-.NET services**: Many frameworks (Express, Vite, Flask) need to know which port to listen on. Use the `env:` parameter: +- `withHttpEndpoint({ env: "PORT" })` (TypeScript) +- `.WithHttpEndpoint(env: "PORT")` (C#) + +Aspire assigns a port and injects it as the specified environment variable. The service should read it and listen on that port. + +### URL labels and dashboard niceties + +Customize how endpoints appear in the Aspire dashboard: + +```csharp +// Named endpoints for clarity +var api = builder.AddProject("api") + .WithHttpEndpoint(name: "public", port: 8080) + .WithHttpEndpoint(name: "internal", port: 8081); +``` + +**Custom domains with `dev.localhost`**: For a nicer local dev experience, use `WithUrlForEndpoint()` to give services friendly URLs that resolve to localhost: + +```csharp +var frontend = builder.AddNpmApp("frontend", "../frontend", "dev") + .WithHttpEndpoint(env: "PORT") + .WithUrlForEndpoint("http", url => url.Host = "frontend.dev.localhost"); + +var api = builder.AddProject("api") + .WithUrlForEndpoint("http", url => url.Host = "api.dev.localhost"); +``` + +> Note: `*.dev.localhost` resolves to `127.0.0.1` on most systems without any `/etc/hosts` changes. + +Use `aspire docs search "url for endpoint"` to check the latest API shape if unsure. + +### Dependency ordering: `WaitFor` and `WaitForCompletion` + +**`WaitFor()`** — delay starting a resource until another resource is healthy/ready: + +```csharp +var db = builder.AddPostgres("pg").AddDatabase("mydb"); +var api = builder.AddProject("api") + .WithReference(db) + .WaitFor(db); // Don't start api until db is healthy +``` + +Always pair `WithReference()` with `WaitFor()` for infrastructure dependencies (databases, caches, queues). Services that depend on other services should generally also wait for them. + +**`WaitForCompletion()`** — wait for a resource to run to completion (exit successfully). Use for init containers, database migrations, or seed data scripts: + +```csharp +var migration = builder.AddProject("migration") + .WithReference(db) + .WaitFor(db); + +var api = builder.AddProject("api") + .WithReference(db) + .WaitFor(db) + .WaitForCompletion(migration); // Don't start until migration finishes +``` + +### Container lifetimes + +By default, containers are stopped when the AppHost stops. Use **persistent lifetime** to keep containers running across restarts (useful for databases during development): + +```csharp +var db = builder.AddPostgres("pg") + .WithLifetime(ContainerLifetime.Persistent); +``` + +This prevents data loss when restarting the AppHost — the container stays running and the AppHost reconnects. + +**TypeScript equivalent:** + +```typescript +const db = await builder.addPostgres("pg") + .withLifetime("persistent"); +``` + +Recommend persistent lifetime for databases and caches during local development. + +### Explicit start (manual start) + +Some resources shouldn't auto-start with the AppHost. Mark them for explicit start: + +```csharp +var debugTool = builder.AddContainer("profiler", "myregistry/profiler") + .WithLifetime(ContainerLifetime.Persistent) + .ExcludeFromManifest() + .WithExplicitStart(); +``` + +The resource appears in the dashboard but stays stopped until the user manually starts it. Useful for debugging tools, admin UIs, or optional services. + +### Parent resources (grouping in the dashboard) + +Group related resources under a parent for a cleaner dashboard: + +```csharp +var postgres = builder.AddPostgres("pg"); +var ordersDb = postgres.AddDatabase("orders"); +var inventoryDb = postgres.AddDatabase("inventory"); +// ordersDb and inventoryDb appear nested under pg in the dashboard +``` + +This happens automatically for databases added to a server resource. For custom grouping of arbitrary resources, use `WithParentRelationship()`: + +```csharp +var backend = builder.AddResource(new ContainerResource("backend-group")); +var api = builder.AddProject("api") + .WithParentRelationship(backend); +var worker = builder.AddProject("worker") + .WithParentRelationship(backend); +``` + +Use `aspire docs search "parent relationship"` to verify the current API shape. + +### Volumes and data persistence + +```csharp +// Named volume (managed by Docker, persists across container recreations) +var db = builder.AddPostgres("pg") + .WithDataVolume("pg-data"); + +// Bind mount (maps to a host directory) +var db = builder.AddPostgres("pg") + .WithBindMount("./data/pg", "/var/lib/postgresql/data"); +``` + +```typescript +const db = await builder.addPostgres("pg") + .withDataVolume("pg-data"); +``` + From 6099136bb1da07f364844331242c2280748df857 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 20:11:04 -0400 Subject: [PATCH 07/48] Add guiding principles: minimize user code changes, surface tradeoffs Establish the default stance: adapt the AppHost to the app, not vice versa. When a small code change unlocks better integration (WithReference vs WithEnvironment, dynamic ports, OTel endpoint), present the tradeoff with both options and let the user decide. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index e771e2a53d7..4755f82c9ab 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -7,6 +7,41 @@ description: "One-time skill for completing Aspire initialization after `aspire This is a **one-time setup skill**. It completes the Aspire initialization that `aspire init` started. After this skill finishes successfully, it should be deleted — the evergreen `aspire` skill handles ongoing AppHost work. +## Guiding principles + +### Minimize changes to the user's code + +The default stance is **adapt the AppHost to fit the app, not the other way around**. The user's services already work — the goal is to model them in Aspire without breaking anything. + +- Prefer `WithEnvironment()` to match existing env var names over asking users to rename vars in their code +- Use `WithHttpEndpoint(port: )` to match hardcoded ports rather than changing the service +- Map existing `docker-compose.yml` config 1:1 before optimizing +- Don't restructure project directories, rename files, or change build scripts + +### Surface tradeoffs, don't decide silently + +Sometimes a small code change unlocks significantly better Aspire integration. When this happens, **present the tradeoff to the user and let them decide**. Examples: + +- **Connection strings**: A service reads `DATABASE_URL` but Aspire injects `ConnectionStrings__mydb`. You can use `WithEnvironment("DATABASE_URL", db.Resource.ConnectionStringExpression)` (zero code change) or suggest the service reads from config so `WithReference(db)` just works (enables service discovery, health checks, auto-retry). + → Ask: *"Your API reads DATABASE_URL. I can map that with WithEnvironment (no code change) or you could switch to reading ConnectionStrings:mydb which unlocks WithReference and automatic service discovery. Which do you prefer?"* + +- **Port binding**: A service hardcodes `PORT=3000`. You can match it with `WithHttpEndpoint(port: 3000)` (zero change) or suggest reading from env so Aspire can assign ports dynamically and avoid conflicts. + → Ask: *"Your frontend hardcodes port 3000. I can match that, but if you read PORT from env instead, Aspire can assign ports dynamically and avoid conflicts when running multiple services. Want me to make that change?"* + +- **OTel setup**: Service has its own tracing config pointing to Jaeger. You can leave it (Aspire won't show its traces) or suggest switching the exporter to read `OTEL_EXPORTER_OTLP_ENDPOINT` (which Aspire injects). + → Ask: *"Your API exports traces to Jaeger directly. I can leave that, or switch it to use the OTEL_EXPORTER_OTLP_ENDPOINT env var so traces show up in the Aspire dashboard. The Jaeger endpoint would still work in non-Aspire environments. Want me to update it?"* + +**Format for presenting tradeoffs:** +1. Explain what the current code does +2. Show the zero-change option and what it gives you +3. Show the small-change option and the extra benefits +4. Ask which they prefer +5. If they decline the change, implement the zero-change option without complaint + +### When in doubt, ask + +If you're unsure whether something is a service, whether two services depend on each other, whether a port is significant, or whether a Docker Compose service should be modeled — ask. Don't guess at architectural intent. + ## Prerequisites Before running this skill, `aspire init` must have already: From 50680af981fcd51839c6a2d1a45959fe809cc9e4 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 20:12:21 -0400 Subject: [PATCH 08/48] Focus init skill on local dev, surface external service dependencies Clarify this skill optimizes for local dev experience, not deployment. Recommend persistent container lifetimes, data volumes, dev.localhost URLs. Add guidance for extracting hardcoded external service URLs into AppHost parameters so they're visible and swappable from the dashboard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 4755f82c9ab..064c323478b 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -42,6 +42,27 @@ Sometimes a small code change unlocks significantly better Aspire integration. W If you're unsure whether something is a service, whether two services depend on each other, whether a port is significant, or whether a Docker Compose service should be modeled — ask. Don't guess at architectural intent. +### Optimize for local dev, not deployment + +This skill is about getting a great **local development experience**. Don't worry about production deployment manifests, cloud provisioning, or publish configuration — that's a separate concern for later. + +This means: + +- Prefer `ContainerLifetime.Persistent` for databases and caches so data survives AppHost restarts +- Use `WithDataVolume()` to persist data across container recreations +- Dev-friendly URLs with `*.dev.localhost` are encouraged +- Don't add production health check probes, scaling config, or cloud resource definitions +- If services reference external third-party APIs/services (e.g., a hardcoded Stripe URL, an external database host, a SaaS webhook endpoint), consider modeling those as parameters or connection strings in the AppHost so they're visible and configurable from one place: + +```csharp +// Instead of the service hardcoding "https://api.stripe.com" +var stripeUrl = builder.AddParameter("stripe-url", secret: false); +var api = builder.AddProject("api") + .WithEnvironment("STRIPE_API_URL", stripeUrl); +``` + +This makes the external dependency visible in the dashboard and lets developers easily swap endpoints (e.g., to a Stripe test endpoint) without digging through service code. Present this as an option to the user — don't silently refactor their external service calls. + ## Prerequisites Before running this skill, `aspire init` must have already: From 58db00e99c92bbd53e9654ec98ba0f5e0cb2dfed Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 20:16:34 -0400 Subject: [PATCH 09/48] Add .env file migration guidance to init skill Scan for .env files during repo discovery. Classify each variable as secret (AddParameter with secret:true), plain config (WithEnvironment), or Aspire resource (replace with AddPostgres/AddRedis + WithReference). Goal: eliminate .env files so all config flows through the AppHost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 064c323478b..16f9766154b 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -63,6 +63,36 @@ var api = builder.AddProject("api") This makes the external dependency visible in the dashboard and lets developers easily swap endpoints (e.g., to a Stripe test endpoint) without digging through service code. Present this as an option to the user — don't silently refactor their external service calls. +### Migrate `.env` files into AppHost parameters + +Many projects use `.env` files for configuration. These should be migrated into the AppHost so that all config is centralized and visible in the dashboard. Scan for `.env`, `.env.local`, `.env.development`, etc. and propose migrating their contents: + +- **Secrets** (API keys, tokens, passwords, connection strings): use `AddParameter(name, secret: true)`. Aspire stores these securely via user secrets and prompts the developer to set them. +- **Non-secret config** (feature flags, URLs, mode settings): use `AddParameter(name, secret: false)` with a default value, or `WithEnvironment()` directly. +- **Values that map to Aspire resources** (e.g., `DATABASE_URL=postgres://...`, `REDIS_URL=redis://...`): replace with actual Aspire resources (`AddPostgres`, `AddRedis`) and `WithReference()` — the connection string is then managed by Aspire. + +```csharp +// Before: .env file with DATABASE_URL=postgres://user:pass@localhost:5432/mydb +// STRIPE_KEY=sk_test_abc123 +// DEBUG=true + +// After: modeled in AppHost +var db = builder.AddPostgres("pg").AddDatabase("mydb"); +var stripeKey = builder.AddParameter("stripe-key", secret: true); + +var api = builder.AddProject("api") + .WithReference(db) // replaces DATABASE_URL + .WithEnvironment("STRIPE_KEY", stripeKey) // secret, stored securely + .WithEnvironment("DEBUG", "true"); // plain config +``` + +**The goal is to eliminate `.env` files entirely** so all configuration flows through the AppHost. This means: +- No more "did you copy the .env.example?" onboarding friction +- Secrets are stored securely (not in plaintext files that get accidentally committed) +- All service config is visible in one place (the dashboard) + +Present this as a recommendation. Walk through the `.env` contents with the user and classify each variable together. Some values may be intentionally local-only and the user may prefer to keep them — that's fine. + ## Prerequisites Before running this skill, `aspire init` must have already: @@ -115,6 +145,7 @@ Analyze the repository to discover all projects and services that could be model - **Build contexts**: `build:` entries → `AddDockerfile()` pointing to the build context directory - Prefer typed Aspire integrations over raw `AddContainer()` when the image matches a known integration (use `aspire docs search` to check). For example, `postgres:16` → `AddPostgres()`, `redis:7` → `AddRedis()`, `rabbitmq:3` → `AddRabbitMQ()`. - **Static frontends**: Vite, Next.js, Create React App, or other frontend framework configs +- **`.env` files**: Scan for `.env`, `.env.local`, `.env.development`, `.env.example`, etc. These contain configuration that should be migrated into AppHost parameters (see Guiding Principles above) **Ignore:** From ce6116ae3a448ca8e72983da358799ab8a2afda2 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 20:25:34 -0400 Subject: [PATCH 10/48] Add aspirify-eval playground apps for init skill benchmarking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pre-aspirified apps that serve as eval targets for the aspire-init skill: dotnet-traditional/ — .NET solution with slnx, Vue/Vite frontend, ASP.NET API + Blazor admin + EF Core migration runner, Postgres + Redis, .env with secrets and config. Exercises: full project mode, ServiceDefaults, env var migration, secret parameterization. polyglot/ — Python FastAPI + Go HTTP + C# minimal API + React frontend, Redis cache, external API keys in .env. Exercises: TS apphost single-file, multi-language scanning, OTel wiring, port injection, .env migration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- playground/aspirify-eval/README.md | 39 ++++++++++++++ .../dotnet-traditional/BoardApp.slnx | 6 +++ .../dotnet-traditional/README.md | 13 +++++ .../dotnet-traditional/frontend/index.html | 12 +++++ .../dotnet-traditional/frontend/package.json | 17 ++++++ .../dotnet-traditional/frontend/src/App.vue | 32 +++++++++++ .../dotnet-traditional/frontend/src/main.ts | 4 ++ .../dotnet-traditional/frontend/tsconfig.json | 13 +++++ .../frontend/vite.config.ts | 15 ++++++ .../src/AdminDashboard/AdminDashboard.csproj | 14 +++++ .../src/AdminDashboard/Program.cs | 35 ++++++++++++ .../src/BoardApi/BoardApi.csproj | 15 ++++++ .../src/BoardApi/Program.cs | 54 +++++++++++++++++++ .../src/BoardData/BoardData.csproj | 11 ++++ .../src/BoardData/Models.cs | 26 +++++++++ .../MigrationRunner/MigrationRunner.csproj | 14 +++++ .../src/MigrationRunner/Program.cs | 34 ++++++++++++ playground/aspirify-eval/polyglot/README.md | 19 +++++++ .../polyglot/api-events/Program.cs | 19 +++++++ .../polyglot/api-events/api-events.csproj | 7 +++ .../aspirify-eval/polyglot/api-geo/go.mod | 3 ++ .../aspirify-eval/polyglot/api-geo/main.go | 47 ++++++++++++++++ .../polyglot/api-weather/main.py | 43 +++++++++++++++ .../polyglot/api-weather/requirements.txt | 4 ++ .../polyglot/frontend/index.html | 12 +++++ .../polyglot/frontend/package.json | 21 ++++++++ .../polyglot/frontend/src/App.tsx | 36 +++++++++++++ .../polyglot/frontend/src/main.tsx | 9 ++++ .../polyglot/frontend/tsconfig.json | 13 +++++ .../polyglot/frontend/vite.config.ts | 9 ++++ 30 files changed, 596 insertions(+) create mode 100644 playground/aspirify-eval/README.md create mode 100644 playground/aspirify-eval/dotnet-traditional/BoardApp.slnx create mode 100644 playground/aspirify-eval/dotnet-traditional/README.md create mode 100644 playground/aspirify-eval/dotnet-traditional/frontend/index.html create mode 100644 playground/aspirify-eval/dotnet-traditional/frontend/package.json create mode 100644 playground/aspirify-eval/dotnet-traditional/frontend/src/App.vue create mode 100644 playground/aspirify-eval/dotnet-traditional/frontend/src/main.ts create mode 100644 playground/aspirify-eval/dotnet-traditional/frontend/tsconfig.json create mode 100644 playground/aspirify-eval/dotnet-traditional/frontend/vite.config.ts create mode 100644 playground/aspirify-eval/dotnet-traditional/src/AdminDashboard/AdminDashboard.csproj create mode 100644 playground/aspirify-eval/dotnet-traditional/src/AdminDashboard/Program.cs create mode 100644 playground/aspirify-eval/dotnet-traditional/src/BoardApi/BoardApi.csproj create mode 100644 playground/aspirify-eval/dotnet-traditional/src/BoardApi/Program.cs create mode 100644 playground/aspirify-eval/dotnet-traditional/src/BoardData/BoardData.csproj create mode 100644 playground/aspirify-eval/dotnet-traditional/src/BoardData/Models.cs create mode 100644 playground/aspirify-eval/dotnet-traditional/src/MigrationRunner/MigrationRunner.csproj create mode 100644 playground/aspirify-eval/dotnet-traditional/src/MigrationRunner/Program.cs create mode 100644 playground/aspirify-eval/polyglot/README.md create mode 100644 playground/aspirify-eval/polyglot/api-events/Program.cs create mode 100644 playground/aspirify-eval/polyglot/api-events/api-events.csproj create mode 100644 playground/aspirify-eval/polyglot/api-geo/go.mod create mode 100644 playground/aspirify-eval/polyglot/api-geo/main.go create mode 100644 playground/aspirify-eval/polyglot/api-weather/main.py create mode 100644 playground/aspirify-eval/polyglot/api-weather/requirements.txt create mode 100644 playground/aspirify-eval/polyglot/frontend/index.html create mode 100644 playground/aspirify-eval/polyglot/frontend/package.json create mode 100644 playground/aspirify-eval/polyglot/frontend/src/App.tsx create mode 100644 playground/aspirify-eval/polyglot/frontend/src/main.tsx create mode 100644 playground/aspirify-eval/polyglot/frontend/tsconfig.json create mode 100644 playground/aspirify-eval/polyglot/frontend/vite.config.ts diff --git a/playground/aspirify-eval/README.md b/playground/aspirify-eval/README.md new file mode 100644 index 00000000000..b1de8928ce1 --- /dev/null +++ b/playground/aspirify-eval/README.md @@ -0,0 +1,39 @@ +# Aspirify Eval Apps + +These are **pre-aspirification** playground apps used to evaluate the `aspire-init` skill. +They are intentionally NOT wired up with Aspire — the goal is to run `aspire init` on them +and have the agent use the `aspire-init` skill to fully aspirify them. + +## Apps + +### dotnet-traditional/ + +A traditional .NET solution with a JS frontend, similar to a real-world LOB app: + +- **Vue/Vite frontend** (`frontend/`) — talks to the API via `API_URL` env var +- **ASP.NET Web API** (`src/BoardApi/`) — REST API with EF Core + Postgres, Redis caching +- **Blazor admin dashboard** (`src/AdminDashboard/`) — server-side Blazor, shares DB +- **EF Core migrations worker** (`src/MigrationRunner/`) — runs DB migrations on startup +- **Shared data library** (`src/BoardData/`) — EF Core models and DbContext +- **Solution file** (`BoardApp.slnx`) — ties it all together + +Config is via `.env` files and hardcoded connection strings. No Aspire. + +### polyglot/ + +A polyglot microservices app with multiple languages, no solution file: + +- **React/Vite frontend** (`frontend/`) — calls all backend APIs +- **Python FastAPI service** (`api-weather/`) — weather data with Redis caching +- **Go HTTP service** (`api-geo/`) — geocoding stub with external API key +- **C# minimal API** (`api-events/`) — events endpoint, single-file .cs +- **Redis** — referenced via `REDIS_URL` env var in multiple services + +Config is via `.env` file at root. No Aspire, no apphost. + +## Eval process + +1. `cd` into either app directory +2. Run `aspire init` +3. Let the agent execute the `aspire-init` skill +4. Verify with `aspire start` — all services should appear in the dashboard diff --git a/playground/aspirify-eval/dotnet-traditional/BoardApp.slnx b/playground/aspirify-eval/dotnet-traditional/BoardApp.slnx new file mode 100644 index 00000000000..48918a1d1df --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/BoardApp.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/playground/aspirify-eval/dotnet-traditional/README.md b/playground/aspirify-eval/dotnet-traditional/README.md new file mode 100644 index 00000000000..285403f8e98 --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/README.md @@ -0,0 +1,13 @@ +# BoardApp — .NET Traditional + +A traditional .NET app with a JS frontend. NOT aspirified — used as eval for `aspire init`. + +## Running manually + +1. Start Postgres on localhost:5432 +2. Start Redis on localhost:6379 +3. Copy `.env` and set values +4. `cd src/MigrationRunner && dotnet run` (run migrations) +5. `cd src/BoardApi && dotnet run` (start API on :5220) +6. `cd src/AdminDashboard && dotnet run` (start admin on :5230) +7. `cd frontend && npm install && npm run dev` (start frontend on :5173) diff --git a/playground/aspirify-eval/dotnet-traditional/frontend/index.html b/playground/aspirify-eval/dotnet-traditional/frontend/index.html new file mode 100644 index 00000000000..9bc2ff32a1e --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + BoardApp + + +
+ + + diff --git a/playground/aspirify-eval/dotnet-traditional/frontend/package.json b/playground/aspirify-eval/dotnet-traditional/frontend/package.json new file mode 100644 index 00000000000..e85d83f5b51 --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/frontend/package.json @@ -0,0 +1,17 @@ +{ + "name": "boardapp-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.0", + "vite": "^6.3.0" + } +} diff --git a/playground/aspirify-eval/dotnet-traditional/frontend/src/App.vue b/playground/aspirify-eval/dotnet-traditional/frontend/src/App.vue new file mode 100644 index 00000000000..6cb92240eee --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/frontend/src/App.vue @@ -0,0 +1,32 @@ + + + diff --git a/playground/aspirify-eval/dotnet-traditional/frontend/src/main.ts b/playground/aspirify-eval/dotnet-traditional/frontend/src/main.ts new file mode 100644 index 00000000000..684d04215d7 --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/frontend/src/main.ts @@ -0,0 +1,4 @@ +import { createApp } from 'vue'; +import App from './App.vue'; + +createApp(App).mount('#app'); diff --git a/playground/aspirify-eval/dotnet-traditional/frontend/tsconfig.json b/playground/aspirify-eval/dotnet-traditional/frontend/tsconfig.json new file mode 100644 index 00000000000..def8e002915 --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/frontend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/playground/aspirify-eval/dotnet-traditional/frontend/vite.config.ts b/playground/aspirify-eval/dotnet-traditional/frontend/vite.config.ts new file mode 100644 index 00000000000..3637713a5d2 --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': { + target: process.env.API_URL || 'http://localhost:5220', + changeOrigin: true, + }, + }, + }, +}); diff --git a/playground/aspirify-eval/dotnet-traditional/src/AdminDashboard/AdminDashboard.csproj b/playground/aspirify-eval/dotnet-traditional/src/AdminDashboard/AdminDashboard.csproj new file mode 100644 index 00000000000..8cfba4f39e1 --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/src/AdminDashboard/AdminDashboard.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + + + + + + + + + diff --git a/playground/aspirify-eval/dotnet-traditional/src/AdminDashboard/Program.cs b/playground/aspirify-eval/dotnet-traditional/src/AdminDashboard/Program.cs new file mode 100644 index 00000000000..cc4268034c4 --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/src/AdminDashboard/Program.cs @@ -0,0 +1,35 @@ +using BoardData; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +// Database — same connection string pattern as the API +var dbConnStr = Environment.GetEnvironmentVariable("DATABASE_URL") + ?? "Host=localhost;Port=5432;Database=boardapp;Username=postgres;Password=localdev123"; +builder.Services.AddDbContext(options => options.UseNpgsql(dbConnStr)); + +// Admin auth token +var adminSecret = Environment.GetEnvironmentVariable("ADMIN_SECRET") + ?? throw new InvalidOperationException("ADMIN_SECRET must be set"); + +builder.Services.AddRazorPages(); +builder.Services.AddServerSideBlazor(); + +var app = builder.Build(); + +app.UseStaticFiles(); +app.UseRouting(); + +app.MapGet("/admin/health", () => Results.Ok(new { status = "healthy", role = "admin" })); + +app.MapGet("/admin/stats", async (BoardDbContext db) => +{ + var itemCount = await db.BoardItems.CountAsync(); + var userCount = await db.UserProfiles.CountAsync(); + return Results.Ok(new { items = itemCount, users = userCount }); +}); + +app.MapBlazorHub(); +app.MapFallbackToPage("/_Host"); + +app.Run(); diff --git a/playground/aspirify-eval/dotnet-traditional/src/BoardApi/BoardApi.csproj b/playground/aspirify-eval/dotnet-traditional/src/BoardApi/BoardApi.csproj new file mode 100644 index 00000000000..1a83c7ef8ec --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/src/BoardApi/BoardApi.csproj @@ -0,0 +1,15 @@ + + + net10.0 + enable + enable + + + + + + + + + + diff --git a/playground/aspirify-eval/dotnet-traditional/src/BoardApi/Program.cs b/playground/aspirify-eval/dotnet-traditional/src/BoardApi/Program.cs new file mode 100644 index 00000000000..d43c4691e2b --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/src/BoardApi/Program.cs @@ -0,0 +1,54 @@ +using BoardData; +using Microsoft.EntityFrameworkCore; +using StackExchange.Redis; + +var builder = WebApplication.CreateBuilder(args); + +// Database — reads connection string from DATABASE_URL env var +var dbConnStr = Environment.GetEnvironmentVariable("DATABASE_URL") + ?? "Host=localhost;Port=5432;Database=boardapp;Username=postgres;Password=localdev123"; +builder.Services.AddDbContext(options => options.UseNpgsql(dbConnStr)); + +// Redis — reads from REDIS_URL env var +var redisUrl = Environment.GetEnvironmentVariable("REDIS_URL") ?? "localhost:6379"; +builder.Services.AddSingleton(ConnectionMultiplexer.Connect(redisUrl)); + +// External API key — used for third-party notification service +var externalApiKey = Environment.GetEnvironmentVariable("EXTERNAL_API_KEY") + ?? throw new InvalidOperationException("EXTERNAL_API_KEY must be set"); + +builder.Services.AddCors(options => + options.AddDefaultPolicy(policy => policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); + +var app = builder.Build(); +app.UseCors(); + +app.MapGet("/api/health", () => Results.Ok(new { status = "healthy" })); + +app.MapGet("/api/items", async (BoardDbContext db) => + await db.BoardItems.OrderByDescending(i => i.CreatedAt).ToListAsync()); + +app.MapPost("/api/items", async (BoardDbContext db, BoardItem item) => +{ + db.BoardItems.Add(item); + await db.SaveChangesAsync(); + return Results.Created($"/api/items/{item.Id}", item); +}); + +app.MapGet("/api/items/{id}", async (BoardDbContext db, int id) => + await db.BoardItems.FindAsync(id) is { } item ? Results.Ok(item) : Results.NotFound()); + +app.MapGet("/api/cached-count", async (IConnectionMultiplexer redis) => +{ + var db = redis.GetDatabase(); + var cached = await db.StringGetAsync("item-count"); + return Results.Ok(new { count = (string?)cached ?? "not cached" }); +}); + +app.MapPost("/api/notify", (HttpContext ctx) => +{ + // Stub: would call external notification service using EXTERNAL_API_KEY + return Results.Ok(new { sent = true, provider = "external", keyPrefix = externalApiKey[..8] + "..." }); +}); + +app.Run(); diff --git a/playground/aspirify-eval/dotnet-traditional/src/BoardData/BoardData.csproj b/playground/aspirify-eval/dotnet-traditional/src/BoardData/BoardData.csproj new file mode 100644 index 00000000000..b149ea205c4 --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/src/BoardData/BoardData.csproj @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + + + + + + diff --git a/playground/aspirify-eval/dotnet-traditional/src/BoardData/Models.cs b/playground/aspirify-eval/dotnet-traditional/src/BoardData/Models.cs new file mode 100644 index 00000000000..e7162f9c9d3 --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/src/BoardData/Models.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; + +namespace BoardData; + +public class BoardDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet BoardItems => Set(); + public DbSet UserProfiles => Set(); +} + +public class BoardItem +{ + public int Id { get; set; } + public required string Title { get; set; } + public string? Description { get; set; } + public bool IsComplete { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +public class UserProfile +{ + public int Id { get; set; } + public required string DisplayName { get; set; } + public required string Email { get; set; } + public string Role { get; set; } = "user"; +} diff --git a/playground/aspirify-eval/dotnet-traditional/src/MigrationRunner/MigrationRunner.csproj b/playground/aspirify-eval/dotnet-traditional/src/MigrationRunner/MigrationRunner.csproj new file mode 100644 index 00000000000..d42851dff8e --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/src/MigrationRunner/MigrationRunner.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + + + + + + + + + diff --git a/playground/aspirify-eval/dotnet-traditional/src/MigrationRunner/Program.cs b/playground/aspirify-eval/dotnet-traditional/src/MigrationRunner/Program.cs new file mode 100644 index 00000000000..8444348cf57 --- /dev/null +++ b/playground/aspirify-eval/dotnet-traditional/src/MigrationRunner/Program.cs @@ -0,0 +1,34 @@ +using BoardData; +using Microsoft.EntityFrameworkCore; + +var builder = Host.CreateApplicationBuilder(args); + +var dbConnStr = Environment.GetEnvironmentVariable("DATABASE_URL") + ?? "Host=localhost;Port=5432;Database=boardapp;Username=postgres;Password=localdev123"; +builder.Services.AddDbContext(options => options.UseNpgsql(dbConnStr)); + +var host = builder.Build(); + +// Run migrations and seed data, then exit +using var scope = host.Services.CreateScope(); +var db = scope.ServiceProvider.GetRequiredService(); + +Console.WriteLine("Running database migrations..."); +await db.Database.EnsureCreatedAsync(); + +// Seed some initial data if empty +if (!await db.BoardItems.AnyAsync()) +{ + db.BoardItems.AddRange( + new BoardItem { Title = "Set up project", Description = "Initial project scaffolding", IsComplete = true }, + new BoardItem { Title = "Add authentication", Description = "Implement user login flow" }, + new BoardItem { Title = "Deploy to production", Description = "Set up CI/CD pipeline" } + ); + + db.UserProfiles.Add(new UserProfile { DisplayName = "Admin", Email = "admin@boardapp.local", Role = "admin" }); + + await db.SaveChangesAsync(); + Console.WriteLine("Seeded initial data."); +} + +Console.WriteLine("Migrations complete."); diff --git a/playground/aspirify-eval/polyglot/README.md b/playground/aspirify-eval/polyglot/README.md new file mode 100644 index 00000000000..85c07730d1c --- /dev/null +++ b/playground/aspirify-eval/polyglot/README.md @@ -0,0 +1,19 @@ +# CityServices — Polyglot + +A polyglot microservices app. NOT aspirified — used as eval for `aspire init`. + +## Services + +- **api-weather** (Python/FastAPI) — weather data, caches in Redis +- **api-geo** (Go) — geocoding stub, uses external API key +- **api-events** (C# minimal API) — city events endpoint +- **frontend** (React/Vite) — dashboard calling all APIs + +## Running manually + +1. Start Redis on localhost:6379 +2. Copy `.env` and set values +3. `cd api-weather && pip install -r requirements.txt && uvicorn main:app --port 8001` +4. `cd api-geo && go run .` (listens on PORT env var, default 8002) +5. `cd api-events && dotnet run` (listens on :8003) +6. `cd frontend && npm install && npm run dev` (listens on :5173) diff --git a/playground/aspirify-eval/polyglot/api-events/Program.cs b/playground/aspirify-eval/polyglot/api-events/Program.cs new file mode 100644 index 00000000000..acfa8026c7a --- /dev/null +++ b/playground/aspirify-eval/polyglot/api-events/Program.cs @@ -0,0 +1,19 @@ +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +var events = new[] +{ + new { Id = 1, City = "seattle", Name = "Aspire Community Standup", Date = "2026-04-15" }, + new { Id = 2, City = "new-york", Name = ".NET Conf Local", Date = "2026-05-01" }, + new { Id = 3, City = "san-francisco", Name = "DevOps Days SF", Date = "2026-04-22" }, + new { Id = 4, City = "chicago", Name = "Cloud Native Chicago", Date = "2026-05-10" }, +}; + +app.MapGet("/health", () => Results.Ok(new { status = "healthy", service = "api-events" })); + +app.MapGet("/events", () => events); + +app.MapGet("/events/{city}", (string city) => + events.Where(e => e.City.Equals(city, StringComparison.OrdinalIgnoreCase)).ToArray()); + +app.Run(); diff --git a/playground/aspirify-eval/polyglot/api-events/api-events.csproj b/playground/aspirify-eval/polyglot/api-events/api-events.csproj new file mode 100644 index 00000000000..4c69387cc90 --- /dev/null +++ b/playground/aspirify-eval/polyglot/api-events/api-events.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/playground/aspirify-eval/polyglot/api-geo/go.mod b/playground/aspirify-eval/polyglot/api-geo/go.mod new file mode 100644 index 00000000000..e005845ba5f --- /dev/null +++ b/playground/aspirify-eval/polyglot/api-geo/go.mod @@ -0,0 +1,3 @@ +module cityservices/api-geo + +go 1.23 diff --git a/playground/aspirify-eval/polyglot/api-geo/main.go b/playground/aspirify-eval/polyglot/api-geo/main.go new file mode 100644 index 00000000000..c96f2583470 --- /dev/null +++ b/playground/aspirify-eval/polyglot/api-geo/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" +) + +type GeoResult struct { + City string `json:"city"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Source string `json:"source"` + APIKeySet bool `json:"api_key_set"` +} + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8002" + } + + apiKey := os.Getenv("GEOCODING_API_KEY") + + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "healthy", "service": "api-geo"}) + }) + + http.HandleFunc("/geocode/", func(w http.ResponseWriter, r *http.Request) { + city := r.URL.Path[len("/geocode/"):] + // Stub geocoding (would call external API with apiKey) + result := GeoResult{ + City: city, + Lat: 47.6062, + Lng: -122.3321, + Source: "stub", + APIKeySet: apiKey != "", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) + }) + + fmt.Printf("api-geo listening on :%s\n", port) + http.ListenAndServe(":"+port, nil) +} diff --git a/playground/aspirify-eval/polyglot/api-weather/main.py b/playground/aspirify-eval/polyglot/api-weather/main.py new file mode 100644 index 00000000000..d1ec019a2f2 --- /dev/null +++ b/playground/aspirify-eval/polyglot/api-weather/main.py @@ -0,0 +1,43 @@ +import os +import json +from fastapi import FastAPI +import redis +import httpx + +app = FastAPI(title="Weather API") + +redis_url = os.environ.get("REDIS_URL", "localhost:6379") +weather_api_key = os.environ.get("WEATHER_API_KEY", "") +redis_host, redis_port = redis_url.split(":") +cache = redis.Redis(host=redis_host, port=int(redis_port), decode_responses=True) + + +@app.get("/health") +def health(): + return {"status": "healthy", "service": "api-weather"} + + +@app.get("/weather/{city}") +async def get_weather(city: str): + # Check cache first + cached = cache.get(f"weather:{city}") + if cached: + return json.loads(cached) + + # Stub weather data (would call external API with weather_api_key) + data = { + "city": city, + "temp_f": 72, + "condition": "Partly Cloudy", + "humidity": 45, + "source": "stub", + "api_key_configured": bool(weather_api_key), + } + + cache.setex(f"weather:{city}", 300, json.dumps(data)) + return data + + +@app.get("/cities") +def list_cities(): + return ["seattle", "new-york", "san-francisco", "chicago", "austin"] diff --git a/playground/aspirify-eval/polyglot/api-weather/requirements.txt b/playground/aspirify-eval/polyglot/api-weather/requirements.txt new file mode 100644 index 00000000000..b37bddb3783 --- /dev/null +++ b/playground/aspirify-eval/polyglot/api-weather/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115.0 +uvicorn>=0.34.0 +redis>=5.0.0 +httpx>=0.28.0 diff --git a/playground/aspirify-eval/polyglot/frontend/index.html b/playground/aspirify-eval/polyglot/frontend/index.html new file mode 100644 index 00000000000..69735b59746 --- /dev/null +++ b/playground/aspirify-eval/polyglot/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + CityServices + + +
+ + + diff --git a/playground/aspirify-eval/polyglot/frontend/package.json b/playground/aspirify-eval/polyglot/frontend/package.json new file mode 100644 index 00000000000..85791bc549e --- /dev/null +++ b/playground/aspirify-eval/polyglot/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "cityservices-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.4.0", + "typescript": "^5.7.0", + "vite": "^6.3.0" + } +} diff --git a/playground/aspirify-eval/polyglot/frontend/src/App.tsx b/playground/aspirify-eval/polyglot/frontend/src/App.tsx new file mode 100644 index 00000000000..3bfeedb94c1 --- /dev/null +++ b/playground/aspirify-eval/polyglot/frontend/src/App.tsx @@ -0,0 +1,36 @@ +import { useState, useEffect } from 'react'; + +// These would be injected by Aspire after aspirification +const WEATHER_API = import.meta.env.VITE_WEATHER_API_URL || 'http://localhost:8001'; +const GEO_API = import.meta.env.VITE_GEO_API_URL || 'http://localhost:8002'; +const EVENTS_API = import.meta.env.VITE_EVENTS_API_URL || 'http://localhost:8003'; + +export default function App() { + const [city, setCity] = useState('seattle'); + const [weather, setWeather] = useState(null); + const [geo, setGeo] = useState(null); + const [events, setEvents] = useState([]); + + useEffect(() => { + fetch(`${WEATHER_API}/weather/${city}`).then(r => r.json()).then(setWeather).catch(() => {}); + fetch(`${GEO_API}/geocode/${city}`).then(r => r.json()).then(setGeo).catch(() => {}); + fetch(`${EVENTS_API}/events/${city}`).then(r => r.json()).then(setEvents).catch(() => {}); + }, [city]); + + return ( +
+

CityServices — {city}

+ +

Weather

+
{JSON.stringify(weather, null, 2)}
+

Location

+
{JSON.stringify(geo, null, 2)}
+

Events

+
{JSON.stringify(events, null, 2)}
+
+ ); +} diff --git a/playground/aspirify-eval/polyglot/frontend/src/main.tsx b/playground/aspirify-eval/polyglot/frontend/src/main.tsx new file mode 100644 index 00000000000..9707d8270f8 --- /dev/null +++ b/playground/aspirify-eval/polyglot/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/playground/aspirify-eval/polyglot/frontend/tsconfig.json b/playground/aspirify-eval/polyglot/frontend/tsconfig.json new file mode 100644 index 00000000000..9e17805fbaf --- /dev/null +++ b/playground/aspirify-eval/polyglot/frontend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "react-jsx", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/playground/aspirify-eval/polyglot/frontend/vite.config.ts b/playground/aspirify-eval/polyglot/frontend/vite.config.ts new file mode 100644 index 00000000000..5c594471f82 --- /dev/null +++ b/playground/aspirify-eval/polyglot/frontend/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + }, +}); From ab440b159030668fd32fd914a9121c8a11c0e418 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 20:32:15 -0400 Subject: [PATCH 11/48] Flesh out eval app READMEs with full before-state docs Both READMEs now cover: architecture diagram, dependencies, config table (.env vars with secret classification), step-by-step multi-terminal setup, verification endpoints, pain points that Aspire should fix, and expected Aspire outcome after aspirification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnet-traditional/README.md | 139 +++++++++++++++-- playground/aspirify-eval/polyglot/README.md | 147 ++++++++++++++++-- 2 files changed, 262 insertions(+), 24 deletions(-) diff --git a/playground/aspirify-eval/dotnet-traditional/README.md b/playground/aspirify-eval/dotnet-traditional/README.md index 285403f8e98..04ec2f6e29c 100644 --- a/playground/aspirify-eval/dotnet-traditional/README.md +++ b/playground/aspirify-eval/dotnet-traditional/README.md @@ -1,13 +1,132 @@ -# BoardApp — .NET Traditional +# BoardApp — .NET Traditional (Pre-Aspirification) -A traditional .NET app with a JS frontend. NOT aspirified — used as eval for `aspire init`. +A traditional .NET LOB app with a Vue frontend. This app is **intentionally not aspirified** — it represents the "before" state for evaluating the `aspire-init` skill. -## Running manually +## Architecture + +``` +frontend/ → Vue 3 + Vite (port 5173), proxies /api/* to BoardApi +src/BoardApi/ → ASP.NET minimal API (port 5220), EF Core + Postgres, Redis caching +src/AdminDashboard/→ Blazor Server (port 5230), shares DB with BoardApi +src/MigrationRunner/→ Worker service, runs EF Core migrations then exits +src/BoardData/ → Class library, shared EF Core DbContext + models +BoardApp.slnx → Solution file tying it all together +.env → All config: DB connection, Redis, API keys, secrets +``` + +## Dependencies + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) +- [Node.js 20+](https://nodejs.org/) +- PostgreSQL (localhost:5432) +- Redis (localhost:6379) + +## Configuration + +All config lives in `.env` at the repo root: + +| Variable | Purpose | Secret? | +|----------|---------|---------| +| `DATABASE_URL` | Postgres connection string | Yes (contains password) | +| `REDIS_URL` | Redis host:port | No | +| `EXTERNAL_API_KEY` | Third-party notification service key | Yes | +| `ADMIN_SECRET` | Admin dashboard auth token | Yes | +| `FEATURE_ENABLE_NOTIFICATIONS` | Feature flag | No | + +Each .NET service reads these via `Environment.GetEnvironmentVariable()`. The frontend reads `API_URL` in `vite.config.ts` for the dev proxy target. + +## Running locally (without Aspire) + +You need 4 terminal windows and 2 infrastructure services running. + +### 1. Start infrastructure + +```bash +# Terminal 1: Start Postgres (or use an existing instance) +docker run -d --name boardapp-pg \ + -e POSTGRES_PASSWORD=localdev123 \ + -e POSTGRES_DB=boardapp \ + -p 5432:5432 \ + postgres:16 + +# Terminal 1: Start Redis +docker run -d --name boardapp-redis \ + -p 6379:6379 \ + redis:7 +``` + +### 2. Load environment variables + +```bash +# In each terminal where you run a .NET service: +export $(cat .env | xargs) +``` + +### 3. Run database migrations + +```bash +# Terminal 2: +cd src/MigrationRunner +dotnet run +# Wait for "Migrations complete." then this process exits +``` + +### 4. Start the API + +```bash +# Terminal 2 (reuse after migrations): +cd src/BoardApi +dotnet run --urls http://localhost:5220 +``` + +### 5. Start the admin dashboard + +```bash +# Terminal 3: +cd src/AdminDashboard +dotnet run --urls http://localhost:5230 +``` + +### 6. Start the frontend + +```bash +# Terminal 4: +cd frontend +npm install +npm run dev +# Opens on http://localhost:5173 +``` + +## Verifying it works + +- **Frontend**: http://localhost:5173 — should show "BoardApp" with a list of seeded items +- **API health**: http://localhost:5220/api/health — should return `{"status":"healthy"}` +- **API items**: http://localhost:5220/api/items — should return seeded board items +- **Admin stats**: http://localhost:5230/admin/stats — should return item/user counts +- **Cached count**: http://localhost:5220/api/cached-count — tests Redis connectivity + +## Pain points (what Aspire should fix) + +This is the developer experience the `aspire-init` skill should improve: + +1. **4 terminals** to run everything — no single command to start +2. **Manual infrastructure** — have to remember to start Postgres and Redis +3. **`.env` file** with secrets in plaintext — easy to commit accidentally +4. **Hardcoded ports** — if 5220 is busy, everything breaks +5. **No service discovery** — frontend proxy target is hardcoded +6. **No observability** — no traces, no metrics, no centralized logs +7. **Migration ordering** — have to manually run migrations before starting API +8. **No health visibility** — no dashboard showing what's running/healthy + +## Expected Aspire outcome + +After running `aspire init` and the init skill, `aspire start` should: + +- Start Postgres and Redis as containers (persistent lifetime) +- Run MigrationRunner, wait for completion, then start API and Admin +- Start the Vue frontend with proper service discovery to the API +- Show all services in the Aspire dashboard with health status +- Store `EXTERNAL_API_KEY` and `ADMIN_SECRET` as secure parameters +- Wire up OpenTelemetry for traces/metrics/logs +- Replace the `.env` file entirely -1. Start Postgres on localhost:5432 -2. Start Redis on localhost:6379 -3. Copy `.env` and set values -4. `cd src/MigrationRunner && dotnet run` (run migrations) -5. `cd src/BoardApi && dotnet run` (start API on :5220) -6. `cd src/AdminDashboard && dotnet run` (start admin on :5230) -7. `cd frontend && npm install && npm run dev` (start frontend on :5173) diff --git a/playground/aspirify-eval/polyglot/README.md b/playground/aspirify-eval/polyglot/README.md index 85c07730d1c..4093675d3e5 100644 --- a/playground/aspirify-eval/polyglot/README.md +++ b/playground/aspirify-eval/polyglot/README.md @@ -1,19 +1,138 @@ -# CityServices — Polyglot +# CityServices — Polyglot (Pre-Aspirification) -A polyglot microservices app. NOT aspirified — used as eval for `aspire init`. +A polyglot microservices app with Python, Go, C#, and React. This app is **intentionally not aspirified** — it represents the "before" state for evaluating the `aspire-init` skill. -## Services +## Architecture -- **api-weather** (Python/FastAPI) — weather data, caches in Redis -- **api-geo** (Go) — geocoding stub, uses external API key -- **api-events** (C# minimal API) — city events endpoint -- **frontend** (React/Vite) — dashboard calling all APIs +``` +api-weather/ → Python FastAPI (port 8001), weather data with Redis caching +api-geo/ → Go stdlib HTTP (port 8002), geocoding stub with external API key +api-events/ → C# minimal API (port 8003), city events endpoint +frontend/ → React + Vite (port 5173), calls all three APIs directly +.env → All config: Redis URL, API keys +``` -## Running manually +No solution file, no AppHost — just four independent services that talk to each other via hardcoded URLs. + +## Dependencies + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) +- [Node.js 20+](https://nodejs.org/) +- [Python 3.12+](https://python.org/) with pip +- [Go 1.23+](https://go.dev/) +- Redis (localhost:6379) + +## Configuration + +All config lives in `.env` at the repo root: + +| Variable | Purpose | Secret? | +|----------|---------|---------| +| `REDIS_URL` | Redis host:port for weather cache | No | +| `GEOCODING_API_KEY` | External geocoding API key | Yes | +| `WEATHER_API_KEY` | External weather API key | Yes | +| `OPENAI_API_KEY` | OpenAI API key (reserved for future advisor feature) | Yes | + +Services read these via `os.environ` (Python), `os.Getenv` (Go), or aren't wired yet (C#, React). + +The React frontend has **hardcoded backend URLs** in `App.tsx`: +```typescript +const WEATHER_API = import.meta.env.VITE_WEATHER_API_URL || 'http://localhost:8001'; +const GEO_API = import.meta.env.VITE_GEO_API_URL || 'http://localhost:8002'; +const EVENTS_API = import.meta.env.VITE_EVENTS_API_URL || 'http://localhost:8003'; +``` + +## Running locally (without Aspire) + +You need 5 terminal windows and Redis running. + +### 1. Start infrastructure + +```bash +# Terminal 1: Start Redis +docker run -d --name cityservices-redis \ + -p 6379:6379 \ + redis:7 +``` + +### 2. Load environment variables + +```bash +# In each terminal: +export $(cat .env | xargs) +``` + +### 3. Start the weather API (Python) + +```bash +# Terminal 2: +cd api-weather +pip install -r requirements.txt +uvicorn main:app --host 0.0.0.0 --port 8001 +``` + +### 4. Start the geo API (Go) + +```bash +# Terminal 3: +cd api-geo +PORT=8002 go run . +``` + +### 5. Start the events API (C#) + +```bash +# Terminal 4: +cd api-events +dotnet run --urls http://localhost:8003 +``` + +### 6. Start the frontend (React) + +```bash +# Terminal 5: +cd frontend +npm install +npm run dev +# Opens on http://localhost:5173 +``` + +## Verifying it works + +- **Frontend**: http://localhost:5173 — should show "CityServices" with weather, geo, and events data for Seattle +- **Weather API**: http://localhost:8001/weather/seattle — should return weather stub data +- **Weather cities**: http://localhost:8001/cities — should return list of cities +- **Geo API**: http://localhost:8002/geocode/seattle — should return lat/lng stub +- **Events API**: http://localhost:8003/events — should return all events +- **Events by city**: http://localhost:8003/events/seattle — should return Seattle events +- **Health checks**: each service has `/health` returning its name and status + +## Pain points (what Aspire should fix) + +This is the developer experience the `aspire-init` skill should improve: + +1. **5 terminals** across 4 different language runtimes — no single command +2. **Manual Redis** — have to remember to start it +3. **`.env` file** with API keys in plaintext — easy to commit +4. **Hardcoded URLs** in frontend — `localhost:8001`, `localhost:8002`, `localhost:8003` +5. **Hardcoded ports** — if any port is busy, services fail silently +6. **No observability** — no traces across services, no centralized logs +7. **No dependency ordering** — services start in whatever order you type the commands +8. **No health visibility** — no way to see at a glance if everything is running +9. **Mixed toolchains** — pip, go, dotnet, npm all need separate setup +10. **No service discovery** — every service needs to know every other service's URL + +## Expected Aspire outcome + +After running `aspire init` (choosing TypeScript AppHost) and the init skill, `aspire start` should: + +- Start Redis as a persistent container +- Start all four backend services with dynamic port assignment +- Start the React frontend with service discovery URLs injected +- Show all services in the Aspire dashboard with health status +- Store `GEOCODING_API_KEY`, `WEATHER_API_KEY`, and `OPENAI_API_KEY` as secure parameters +- Wire up OpenTelemetry for Python and Go services (C# gets ServiceDefaults if applicable) +- Replace the `.env` file entirely +- Go service reads PORT from env (Aspire injects it) +- Frontend gets backend URLs via `VITE_*` env vars from Aspire -1. Start Redis on localhost:6379 -2. Copy `.env` and set values -3. `cd api-weather && pip install -r requirements.txt && uvicorn main:app --port 8001` -4. `cd api-geo && go run .` (listens on PORT env var, default 8002) -5. `cd api-events && dotnet run` (listens on :8003) -6. `cd frontend && npm install && npm run dev` (listens on :5173) From 7d7da274f23196cfa631389221eae6d981c542d2 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 20:47:26 -0400 Subject: [PATCH 12/48] Move eval rubric out of app dirs so agents can't cheat Strip 'Pain points' and 'Expected Aspire outcome' from the app READMEs (agents would use them as answer keys). App READMEs now only describe the before-state. Eval rubric lives in EVAL-RUBRIC.md in the parent directory with detailed pass/fail checklists for both apps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- playground/aspirify-eval/EVAL-RUBRIC.md | 120 ++++++++++++++++++ playground/aspirify-eval/README.md | 29 ++--- .../dotnet-traditional/README.md | 25 ---- playground/aspirify-eval/polyglot/README.md | 29 ----- 4 files changed, 129 insertions(+), 74 deletions(-) create mode 100644 playground/aspirify-eval/EVAL-RUBRIC.md diff --git a/playground/aspirify-eval/EVAL-RUBRIC.md b/playground/aspirify-eval/EVAL-RUBRIC.md new file mode 100644 index 00000000000..bb23c8a6f42 --- /dev/null +++ b/playground/aspirify-eval/EVAL-RUBRIC.md @@ -0,0 +1,120 @@ +# Aspirify Eval Rubric + +**Do NOT place this file inside the eval app directories** — it contains expected outcomes +that the agent should not be able to read during the eval. + +## Scoring + +Each criterion is pass/fail. A good aspirification should hit most of these. + +--- + +## dotnet-traditional/ (C# AppHost, full project mode) + +### Infrastructure (must-have) +- [ ] `aspire start` launches the app successfully +- [ ] Postgres runs as an Aspire-managed container +- [ ] Redis runs as an Aspire-managed container +- [ ] All services appear in the Aspire dashboard + +### AppHost wiring +- [ ] Full project mode used (`.slnx` detected → creates `*.AppHost/` directory with `.csproj`) +- [ ] BoardApi modeled as `AddProject` with references to Postgres DB and Redis +- [ ] AdminDashboard modeled as `AddProject` with reference to Postgres DB +- [ ] MigrationRunner modeled as `AddProject` with reference to Postgres DB +- [ ] Frontend modeled (AddViteApp, AddNpmApp, or similar) with reference to BoardApi +- [ ] BoardData NOT modeled (it's a class library, not a runnable service) + +### Dependency ordering +- [ ] MigrationRunner waits for Postgres (`WaitFor`) +- [ ] BoardApi waits for Postgres and Redis (`WaitFor`) +- [ ] BoardApi waits for MigrationRunner to complete (`WaitForCompletion`) +- [ ] AdminDashboard waits for Postgres (`WaitFor`) +- [ ] Frontend waits for BoardApi (`WaitFor`) + +### ServiceDefaults +- [ ] ServiceDefaults project created (via `dotnet new aspire-servicedefaults`) +- [ ] ServiceDefaults added to solution +- [ ] BoardApi, AdminDashboard, and MigrationRunner reference ServiceDefaults +- [ ] `builder.AddServiceDefaults()` added to each .NET service's Program.cs +- [ ] `app.MapDefaultEndpoints()` added where applicable + +### Secret & config migration +- [ ] `DATABASE_URL` replaced — Postgres connection managed by Aspire via `WithReference` +- [ ] `REDIS_URL` replaced — Redis connection managed by Aspire via `WithReference` +- [ ] `EXTERNAL_API_KEY` migrated to `AddParameter("external-api-key", secret: true)` +- [ ] `ADMIN_SECRET` migrated to `AddParameter("admin-secret", secret: true)` +- [ ] `FEATURE_ENABLE_NOTIFICATIONS` handled (either `WithEnvironment` or parameter) +- [ ] `.env` file no longer needed for running via Aspire + +### Dev experience niceties (nice-to-have) +- [ ] Postgres container uses persistent lifetime +- [ ] Redis container uses persistent lifetime +- [ ] Data volumes configured for Postgres +- [ ] Frontend has `WithExternalHttpEndpoints()` +- [ ] User was asked about tradeoffs (e.g., renaming DATABASE_URL → ConnectionStrings:db) + +### Observability +- [ ] OpenTelemetry wired for Vue frontend (or noted as not applicable for static frontend) +- [ ] .NET services get OTel via ServiceDefaults automatically + +### Cleanup +- [ ] Init skill self-removed after completion +- [ ] Evergreen `aspire` skill confirmed present + +--- + +## polyglot/ (TypeScript AppHost, single-file mode) + +### Infrastructure (must-have) +- [ ] `aspire start` launches the app successfully +- [ ] Redis runs as an Aspire-managed container +- [ ] All services appear in the Aspire dashboard + +### AppHost wiring +- [ ] Single-file `apphost.ts` used (no `.sln` → no project mode) +- [ ] `aspire.config.json` created at repo root +- [ ] api-weather modeled (AddPythonApp or similar) with Redis reference +- [ ] api-geo modeled (likely AddDockerfile or custom) with HTTP endpoint using `env: "PORT"` +- [ ] api-events modeled (AddProject for .csproj) with HTTP endpoint +- [ ] Frontend modeled (AddViteApp or AddNpmApp) with references to backend services +- [ ] Redis modeled as `addRedis` (typed integration, not raw container) + +### Dependency ordering +- [ ] Backend services wait for Redis (`waitFor`) +- [ ] Frontend waits for backend services (`waitFor`) + +### Secret & config migration +- [ ] `REDIS_URL` replaced — Redis managed by Aspire via `withReference` +- [ ] `GEOCODING_API_KEY` migrated to a secret parameter +- [ ] `WEATHER_API_KEY` migrated to a secret parameter +- [ ] `OPENAI_API_KEY` migrated to a secret parameter +- [ ] `.env` file no longer needed for running via Aspire + +### Service communication +- [ ] Frontend gets backend URLs injected (not hardcoded localhost) +- [ ] Go service gets PORT injected via `withHttpEndpoint({ env: "PORT" })` +- [ ] Python service gets Redis connection via Aspire (not hardcoded host:port) +- [ ] User was asked about tradeoffs (e.g., frontend URL injection approach) + +### Dev experience niceties (nice-to-have) +- [ ] Redis container uses persistent lifetime +- [ ] Data volume configured for Redis +- [ ] Frontend has `withExternalHttpEndpoints()` +- [ ] `*.dev.localhost` domains suggested or configured + +### Observability +- [ ] OpenTelemetry suggested for Python service (fastapi + OTLP exporter) +- [ ] OpenTelemetry suggested for Go service (OTLP exporter) +- [ ] User was asked before modifying service code for OTel +- [ ] C# service gets OTel consideration (single project, may not need full ServiceDefaults) + +### Package & config setup +- [ ] `package.json` at root configured for apphost (type: module, start script) +- [ ] `tsconfig.json` configured for apphost compilation +- [ ] `aspire restore` run successfully +- [ ] `npm install` run successfully + +### Cleanup +- [ ] Init skill self-removed after completion +- [ ] Evergreen `aspire` skill confirmed present diff --git a/playground/aspirify-eval/README.md b/playground/aspirify-eval/README.md index b1de8928ce1..4c9d6ff494a 100644 --- a/playground/aspirify-eval/README.md +++ b/playground/aspirify-eval/README.md @@ -8,32 +8,21 @@ and have the agent use the `aspire-init` skill to fully aspirify them. ### dotnet-traditional/ -A traditional .NET solution with a JS frontend, similar to a real-world LOB app: - -- **Vue/Vite frontend** (`frontend/`) — talks to the API via `API_URL` env var -- **ASP.NET Web API** (`src/BoardApi/`) — REST API with EF Core + Postgres, Redis caching -- **Blazor admin dashboard** (`src/AdminDashboard/`) — server-side Blazor, shares DB -- **EF Core migrations worker** (`src/MigrationRunner/`) — runs DB migrations on startup -- **Shared data library** (`src/BoardData/`) — EF Core models and DbContext -- **Solution file** (`BoardApp.slnx`) — ties it all together - -Config is via `.env` files and hardcoded connection strings. No Aspire. +A traditional .NET solution with a JS frontend, similar to a real-world LOB app. +See its README for architecture and manual setup instructions. ### polyglot/ -A polyglot microservices app with multiple languages, no solution file: - -- **React/Vite frontend** (`frontend/`) — calls all backend APIs -- **Python FastAPI service** (`api-weather/`) — weather data with Redis caching -- **Go HTTP service** (`api-geo/`) — geocoding stub with external API key -- **C# minimal API** (`api-events/`) — events endpoint, single-file .cs -- **Redis** — referenced via `REDIS_URL` env var in multiple services - -Config is via `.env` file at root. No Aspire, no apphost. +A polyglot microservices app with multiple languages, no solution file. +See its README for architecture and manual setup instructions. ## Eval process 1. `cd` into either app directory 2. Run `aspire init` 3. Let the agent execute the `aspire-init` skill -4. Verify with `aspire start` — all services should appear in the dashboard +4. Score against the rubric in `EVAL-RUBRIC.md` + +**Important**: The app READMEs only describe the "before" state (how to run manually). +The eval rubric is in this directory, NOT inside the app directories, so the agent +can't peek at the expected outcomes. diff --git a/playground/aspirify-eval/dotnet-traditional/README.md b/playground/aspirify-eval/dotnet-traditional/README.md index 04ec2f6e29c..48e466ae986 100644 --- a/playground/aspirify-eval/dotnet-traditional/README.md +++ b/playground/aspirify-eval/dotnet-traditional/README.md @@ -105,28 +105,3 @@ npm run dev - **Admin stats**: http://localhost:5230/admin/stats — should return item/user counts - **Cached count**: http://localhost:5220/api/cached-count — tests Redis connectivity -## Pain points (what Aspire should fix) - -This is the developer experience the `aspire-init` skill should improve: - -1. **4 terminals** to run everything — no single command to start -2. **Manual infrastructure** — have to remember to start Postgres and Redis -3. **`.env` file** with secrets in plaintext — easy to commit accidentally -4. **Hardcoded ports** — if 5220 is busy, everything breaks -5. **No service discovery** — frontend proxy target is hardcoded -6. **No observability** — no traces, no metrics, no centralized logs -7. **Migration ordering** — have to manually run migrations before starting API -8. **No health visibility** — no dashboard showing what's running/healthy - -## Expected Aspire outcome - -After running `aspire init` and the init skill, `aspire start` should: - -- Start Postgres and Redis as containers (persistent lifetime) -- Run MigrationRunner, wait for completion, then start API and Admin -- Start the Vue frontend with proper service discovery to the API -- Show all services in the Aspire dashboard with health status -- Store `EXTERNAL_API_KEY` and `ADMIN_SECRET` as secure parameters -- Wire up OpenTelemetry for traces/metrics/logs -- Replace the `.env` file entirely - diff --git a/playground/aspirify-eval/polyglot/README.md b/playground/aspirify-eval/polyglot/README.md index 4093675d3e5..3df696bb415 100644 --- a/playground/aspirify-eval/polyglot/README.md +++ b/playground/aspirify-eval/polyglot/README.md @@ -107,32 +107,3 @@ npm run dev - **Events by city**: http://localhost:8003/events/seattle — should return Seattle events - **Health checks**: each service has `/health` returning its name and status -## Pain points (what Aspire should fix) - -This is the developer experience the `aspire-init` skill should improve: - -1. **5 terminals** across 4 different language runtimes — no single command -2. **Manual Redis** — have to remember to start it -3. **`.env` file** with API keys in plaintext — easy to commit -4. **Hardcoded URLs** in frontend — `localhost:8001`, `localhost:8002`, `localhost:8003` -5. **Hardcoded ports** — if any port is busy, services fail silently -6. **No observability** — no traces across services, no centralized logs -7. **No dependency ordering** — services start in whatever order you type the commands -8. **No health visibility** — no way to see at a glance if everything is running -9. **Mixed toolchains** — pip, go, dotnet, npm all need separate setup -10. **No service discovery** — every service needs to know every other service's URL - -## Expected Aspire outcome - -After running `aspire init` (choosing TypeScript AppHost) and the init skill, `aspire start` should: - -- Start Redis as a persistent container -- Start all four backend services with dynamic port assignment -- Start the React frontend with service discovery URLs injected -- Show all services in the Aspire dashboard with health status -- Store `GEOCODING_API_KEY`, `WEATHER_API_KEY`, and `OPENAI_API_KEY` as secure parameters -- Wire up OpenTelemetry for Python and Go services (C# gets ServiceDefaults if applicable) -- Replace the `.env` file entirely -- Go service reads PORT from env (Aspire injects it) -- Frontend gets backend URLs via `VITE_*` env vars from Aspire - From 2cee73db2837ef501216f10c43414c160b041151 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 21:42:56 -0400 Subject: [PATCH 13/48] Update aspire-init skill based on eval findings - Add 'Always use latest Aspire APIs' principle: enforce aspire docs search before writing any builder call, never invent APIs - Massively expand OTel section: per-language setup for Node.js, Python, Go, and Java with concrete code samples; surface as user option - Add Step 8 'Dev experience enhancements': surface dev.localhost friendly URLs, dashboard URL labels, and OTel as opt-in suggestions - Renumber steps 9-11 to accommodate new step Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 163 ++++++++++++++++++++++++++-- 1 file changed, 154 insertions(+), 9 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 16f9766154b..05a68ab5e9c 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -42,6 +42,20 @@ Sometimes a small code change unlocks significantly better Aspire integration. W If you're unsure whether something is a service, whether two services depend on each other, whether a port is significant, or whether a Docker Compose service should be modeled — ask. Don't guess at architectural intent. +### Always use latest Aspire APIs — verify before you write + +**Do not assume APIs exist.** Aspire evolves fast and community toolkit packages may or may not be available. Before writing any AppHost code: + +1. Run `aspire docs search ""` to find the correct builder method +2. Run `aspire docs get ""` to read the full API surface +3. If an API doesn't exist (e.g., there's no `AddGolangApp`), fall back to `AddDockerfile()` or `AddContainer()` and note it to the user +4. For TypeScript, check `.modules/aspire.ts` after `aspire restore` to see what's actually available + +Common pitfalls: +- **Don't invent APIs** — if `aspire docs search` doesn't return it, it probably doesn't exist +- **CommunityToolkit packages** may not be installed by default — verify with `aspire docs search` before referencing them +- **API shapes differ between C# and TypeScript** — always check the correct language docs + ### Optimize for local dev, not deployment This skill is about getting a great **local development experience**. Don't worry about production deployment manifests, cloud provisioning, or publish configuration — that's a separate concern for later. @@ -385,23 +399,154 @@ Be careful with code placement — look at existing structure (top-level stateme ### Step 7: Wire up OpenTelemetry for non-.NET services -For non-.NET services included in the AppHost, configure OpenTelemetry so the Aspire dashboard shows their traces, metrics, and logs. This is the equivalent of what ServiceDefaults does for .NET. +For non-.NET services included in the AppHost, OpenTelemetry makes their traces, metrics, and logs visible in the Aspire dashboard. This is the equivalent of what ServiceDefaults does for .NET. Aspire automatically injects `OTEL_EXPORTER_OTLP_ENDPOINT` into all managed resources — the services just need to read it. + +**Present this to the user as an option, not a mandatory step.** Some users may want to add OTel later, and that's fine — their services will still run, they just won't appear in the dashboard's trace/metrics views. + +**For each non-.NET service, ask:** +> "Would you like me to add OpenTelemetry instrumentation to ``? This lets the Aspire dashboard show its traces, metrics, and logs. I'll need to add a few packages and an instrumentation setup file." -**Node.js/TypeScript services:** +If they say yes, follow the per-language guide below. + +#### Node.js/TypeScript services ```bash npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-otlp-grpc ``` -Add an instrumentation file that reads `OTEL_EXPORTER_OTLP_ENDPOINT` (injected by Aspire automatically). +Create an instrumentation file (e.g., `instrumentation.ts` or `instrumentation.js`): + +```typescript +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-otlp-grpc'; +import { OTLPMetricExporter } from '@opentelemetry/exporter-otlp-grpc'; +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; + +const sdk = new NodeSDK({ + traceExporter: new OTLPTraceExporter(), + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + }), + instrumentations: [getNodeAutoInstrumentations()], + serviceName: process.env.OTEL_SERVICE_NAME, +}); + +sdk.start(); +``` + +Then ensure the service loads it early — either via `--require`/`--import` in the start script or by importing it as the first line of the entry point. + +#### Python services + +```bash +pip install opentelemetry-distro opentelemetry-exporter-otlp +opentelemetry-bootstrap -a install # auto-detect and install framework instrumentations +``` + +Add to the service's startup (e.g., top of `main.py` or as a separate `instrumentation.py`): + +```python +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter +from opentelemetry import trace, metrics +import os + +resource = Resource.create({"service.name": os.environ.get("OTEL_SERVICE_NAME", "unknown")}) + +# Traces +trace.set_tracer_provider(TracerProvider(resource=resource)) +trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + +# Metrics +metrics.set_meter_provider(MeterProvider( + resource=resource, + metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())], +)) +``` + +Or more simply, run with the auto-instrumentation wrapper: + +```bash +opentelemetry-instrument uvicorn main:app --host 0.0.0.0 +``` + +#### Go services + +```bash +go get go.opentelemetry.io/otel +go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc +go get go.opentelemetry.io/otel/sdk/trace +go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp +``` + +Add initialization in `main()`: + +```go +import ( + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +func initTracer() func() { + exporter, _ := otlptracegrpc.New(context.Background()) + tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter)) + otel.SetTracerProvider(tp) + return func() { tp.Shutdown(context.Background()) } +} +``` + +Wrap HTTP handlers with `otelhttp.NewHandler()` for automatic HTTP span creation. + +#### Java services + +Point the user to the [OpenTelemetry Java Agent](https://opentelemetry.io/docs/zero-code/java/agent/) — it's the easiest approach: + +```bash +java -javaagent:opentelemetry-javaagent.jar -jar myapp.jar +``` + +The agent auto-instruments common frameworks. Aspire injects `OTEL_EXPORTER_OTLP_ENDPOINT` automatically. + +### Step 8: Offer dev experience enhancements + +Before validating, present the user with optional quality-of-life improvements. These aren't required for `aspire start` to work, but they make the local dev experience significantly nicer. + +**Suggest each of these individually — don't apply without asking:** + +1. **Friendly URLs with `dev.localhost`**: Give services memorable URLs instead of random ports: + > "Would you like friendly local URLs like `frontend.dev.localhost` and `api.dev.localhost` instead of `localhost:`? These resolve to 127.0.0.1 automatically on most systems — no `/etc/hosts` changes needed." + + ```csharp + // C# + var frontend = builder.AddNpmApp("frontend", "../frontend") + .WithHttpEndpoint(env: "PORT") + .WithUrlForEndpoint("http", url => url.Host = "frontend.dev.localhost"); + ``` + + ```typescript + // TypeScript + const frontend = builder.addNpmApp("frontend", "../frontend") + .withHttpEndpoint({ env: "PORT" }) + .withUrlForEndpoint("http", url => { url.host = "frontend.dev.localhost"; }); + ``` -**Python services**: suggest `opentelemetry-distro` and `opentelemetry-exporter-otlp`. +2. **Custom URL labels in the dashboard**: Rename endpoint URLs in the Aspire dashboard for clarity: + ```csharp + .WithUrlForEndpoint("http", url => url.DisplayText = "Web UI") + ``` -**Other languages**: point the user to OpenTelemetry docs for their language. The OTLP endpoint is injected via environment variables by Aspire. +3. **OpenTelemetry** (if not done in Step 7): "Would you like to add observability to your non-.NET services so they appear in the Aspire dashboard's traces and metrics views?" -**Important**: Ask the user before modifying any service code. OTel setup may conflict with existing instrumentation. Present it as a recommendation, not an automatic change. +Present these as a batch: "I have a few optional dev experience improvements I can make. Want to hear about them?" -### Step 8: Validate +### Step 9: Validate ```bash aspire start @@ -421,7 +566,7 @@ If it fails, diagnose and iterate. Common issues: - **Both**: missing environment variables, port conflicts - **Certificate errors**: if HTTPS fails, run `aspire certs trust` and retry -### Step 9: Update solution file (C# full project mode only) +### Step 10: Update solution file (C# full project mode only) If a `.sln`/`.slnx` exists, verify all new projects are included: @@ -431,7 +576,7 @@ dotnet sln list Ensure both the AppHost and ServiceDefaults projects appear. -### Step 10: Clean up +### Step 11: Clean up After successful validation: From 64eff445e5bd808263f90dbb88b6c70d81183667 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 22:12:31 -0400 Subject: [PATCH 14/48] Add tiered integration preference to aspire-init skill Introduce a 3-tier hierarchy for choosing how to model resources: 1. First-party Aspire.Hosting.* packages (always prefer) 2. CommunityToolkit.Aspire.Hosting.* packages (Go, Rust, etc.) 3. Raw AddExecutable/AddDockerfile/AddContainer (last resort) Update the 'verify before you write' principle with concrete package tables, discovery workflow (aspire list integrations, aspire docs search, aspire add), and examples for both first-party and toolkit. Update Step 4 non-.NET examples to show all 3 tiers with Go as the community toolkit example. Update 'Looking up APIs' reference section with aspire list integrations and toolkit add commands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 101 +++++++++++++++++++++------- 1 file changed, 78 insertions(+), 23 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 05a68ab5e9c..742552dc417 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -44,17 +44,49 @@ If you're unsure whether something is a service, whether two services depend on ### Always use latest Aspire APIs — verify before you write -**Do not assume APIs exist.** Aspire evolves fast and community toolkit packages may or may not be available. Before writing any AppHost code: +**Do not assume APIs exist.** Aspire evolves fast. Before writing any AppHost code, look up the correct API. Follow this **tiered preference** when choosing how to model a resource: -1. Run `aspire docs search ""` to find the correct builder method -2. Run `aspire docs get ""` to read the full API surface -3. If an API doesn't exist (e.g., there's no `AddGolangApp`), fall back to `AddDockerfile()` or `AddContainer()` and note it to the user -4. For TypeScript, check `.modules/aspire.ts` after `aspire restore` to see what's actually available +#### Tier 1: First-party Aspire hosting packages (always prefer) -Common pitfalls: -- **Don't invent APIs** — if `aspire docs search` doesn't return it, it probably doesn't exist -- **CommunityToolkit packages** may not be installed by default — verify with `aspire docs search` before referencing them -- **API shapes differ between C# and TypeScript** — always check the correct language docs +Packages named `Aspire.Hosting.*` — these are maintained by the Aspire team and ship with every release. Examples: + +| Package | Unlocks | +|---------|---------| +| `Aspire.Hosting.Python` | `AddPythonApp()`, `AddUvicornApp()` | +| `Aspire.Hosting.NodeJs` | `AddNodeApp()`, `AddNpmApp()`, `AddViteApp()` | +| `Aspire.Hosting.JavaScript` | Additional JavaScript hosting support | +| `Aspire.Hosting.PostgreSQL` | `AddPostgres()`, `AddDatabase()` | +| `Aspire.Hosting.Redis` | `AddRedis()` | + +#### Tier 2: Community Toolkit packages (use when no first-party exists) + +Packages named `CommunityToolkit.Aspire.Hosting.*` — maintained by the community, documented on aspire.dev, and installable via `aspire add`. Examples: + +| Package | Unlocks | +|---------|---------| +| `CommunityToolkit.Aspire.Hosting.Golang` | `AddGolangApp()` — handles `go run .`, working dir, PORT env | +| `CommunityToolkit.Aspire.Hosting.Rust` | `AddRustApp()` | +| `CommunityToolkit.Aspire.Hosting.Java` | Java hosting support | + +These provide typed APIs with proper endpoint handling, health checks, and dashboard integration — significantly better than raw executables. + +#### Tier 3: Raw fallbacks (last resort) + +`AddExecutable()`, `AddDockerfile()`, `AddContainer()` — use only when no Tier 1 or Tier 2 package exists for the technology, or when the user's setup is too custom for a typed integration. + +#### How to discover available packages + +Before writing any builder call: + +1. Run `aspire docs search ""` (e.g., `aspire docs search "golang"`, `aspire docs search "python"`) +2. Run `aspire docs get ""` to read the full API surface and installation instructions +3. Run `aspire list integrations` to see all available packages (first-party and community toolkit) +4. Install with `aspire add ` (e.g., `aspire add communitytoolkit-golang`) +5. For TypeScript, run `aspire restore` then check `.modules/aspire.ts` to see what's available + +**Don't invent APIs** — if the docs search and integration list don't return it, it doesn't exist. Fall back to Tier 3 and note the limitation to the user. + +**API shapes differ between C# and TypeScript** — always check the correct language docs. ### Optimize for local dev, not deployment @@ -283,23 +315,34 @@ dotnet add reference #### Non-.NET services in a C# AppHost ```csharp -// Node.js app (requires Aspire.Hosting.NodeJs) +// Node.js app (Tier 1: Aspire.Hosting.NodeJs) var frontend = builder.AddNpmApp("frontend", "../frontend", "start"); -// Dockerfile-based service -var worker = builder.AddDockerfile("worker", "../worker"); - -// Python app (requires Aspire.Hosting.Python) +// Python app (Tier 1: Aspire.Hosting.Python) var pyApi = builder.AddPythonApp("py-api", "../py-api", "app.py"); + +// Go app (Tier 2: CommunityToolkit.Aspire.Hosting.Golang) +var goApi = builder.AddGolangApp("go-api", "../go-api") + .WithHttpEndpoint(env: "PORT"); + +// Dockerfile-based service (Tier 3: fallback for unsupported languages) +var worker = builder.AddDockerfile("worker", "../worker"); ``` -Add required hosting NuGet packages: +Add required hosting packages — use `aspire add` or `dotnet add package`: ```bash -dotnet add package Aspire.Hosting.NodeJs -dotnet add package Aspire.Hosting.Python +# Tier 1: first-party +aspire add nodejs # or: dotnet add package Aspire.Hosting.NodeJs +aspire add python # or: dotnet add package Aspire.Hosting.Python + +# Tier 2: community toolkit +aspire add communitytoolkit-golang +# or: dotnet add package CommunityToolkit.Aspire.Hosting.Golang ``` +Always check `aspire list integrations` and `aspire docs search ""` to find the best available integration before falling back to `AddExecutable`/`AddDockerfile`. + **Important rules:** - Use `aspire docs search` and `aspire docs get` to look up the correct builder API for each resource type. Do not guess API shapes. @@ -594,29 +637,41 @@ After successful validation: ## Looking up APIs and integrations -Before writing AppHost code for an unfamiliar resource type or integration, **always** look it up: +Before writing AppHost code for an unfamiliar resource type or integration, **always** look it up. Follow the tiered preference from the principles section (first-party → community toolkit → raw fallbacks). ```bash # Search for documentation on a topic aspire docs search "redis" -aspire docs search "node app endpoints" +aspire docs search "golang" +aspire docs search "python uvicorn" # Get a specific doc page by slug (returned from search results) aspire docs get "redis-integration" +aspire docs get "go-integration" + +# List ALL available integrations (first-party and community toolkit) +aspire list integrations ``` -Use `aspire docs search` to find the right builder methods, configuration options, and patterns. Use `aspire docs get ` to read the full doc page. Do not guess API shapes — Aspire has many resource types with specific overloads. +Use `aspire docs search` to find the right builder methods, configuration options, and patterns. Use `aspire docs get ` to read the full doc page. Use `aspire list integrations` to discover packages you might not have known about. Do not guess API shapes — Aspire has many resource types with specific overloads. To add an integration package (which unlocks typed builder methods): ```bash -aspire add Aspire.Hosting.Redis -aspire add Aspire.Hosting.NodeJs -aspire add Aspire.Hosting.Python +# First-party +aspire add redis +aspire add python +aspire add nodejs + +# Community Toolkit +aspire add communitytoolkit-golang +aspire add communitytoolkit-rust ``` After adding, run `aspire restore` (TypeScript) or `dotnet restore` (C#) to update available APIs, then check what methods are now available. +**Always prefer a typed integration over raw `AddExecutable`/`AddContainer`.** Typed integrations handle working directories, port injection, health checks, and dashboard integration automatically. + ## AppHost wiring reference This section covers the patterns you'll need when writing Step 4 (Wire up the AppHost). Refer back to it as needed. From 5ef224accd00d575421692ee0146d1c5224ebe9d Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 22:28:26 -0400 Subject: [PATCH 15/48] Replace AddProject with AddCsharpApp, preserve .env files, strengthen sln detection - Replace all AddProject/addProject references with AddCsharpApp/ addCsharpApp throughout skill examples (AddProject may be deprecated) - Remove fsproj scanning (not needed) - Make .env file deletion an explicit decision point: never auto-delete, always ask the user since teams may need them for non-Aspire workflows - Strengthen sln/slnx detection: explicitly note it forces full project mode for Visual Studio compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 58 +++++++++++++++-------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 742552dc417..06c442ca001 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -103,7 +103,7 @@ This means: ```csharp // Instead of the service hardcoding "https://api.stripe.com" var stripeUrl = builder.AddParameter("stripe-url", secret: false); -var api = builder.AddProject("api") +var api = builder.AddCsharpApp("api", "../src/Api") .WithEnvironment("STRIPE_API_URL", stripeUrl); ``` @@ -126,17 +126,22 @@ Many projects use `.env` files for configuration. These should be migrated into var db = builder.AddPostgres("pg").AddDatabase("mydb"); var stripeKey = builder.AddParameter("stripe-key", secret: true); -var api = builder.AddProject("api") +var api = builder.AddCsharpApp("api", "../src/Api") .WithReference(db) // replaces DATABASE_URL .WithEnvironment("STRIPE_KEY", stripeKey) // secret, stored securely .WithEnvironment("DEBUG", "true"); // plain config ``` -**The goal is to eliminate `.env` files entirely** so all configuration flows through the AppHost. This means: +**The goal is to make `.env` files unnecessary** so all configuration flows through the AppHost. This means: - No more "did you copy the .env.example?" onboarding friction - Secrets are stored securely (not in plaintext files that get accidentally committed) - All service config is visible in one place (the dashboard) +**Important: Never delete `.env` files automatically.** After migrating all values into the AppHost, explicitly ask the user: +> "I've migrated all the values from your `.env` file into the AppHost. The `.env` file is no longer needed for running via Aspire, but it still works for non-Aspire workflows. Would you like me to remove it, or keep it around?" + +Some teams still need `.env` files for CI, Docker Compose, or developers who haven't switched to Aspire yet. Respect that. + Present this as a recommendation. Walk through the `.env` contents with the user and classify each variable together. Some values may be intentionally local-only and the user may prefer to keep them — that's fine. ## Prerequisites @@ -158,7 +163,7 @@ Read `aspire.config.json` at the repository root. Key fields: For C# AppHosts, there are two sub-modes: - **Single-file mode**: `appHost.path` points directly to an `apphost.cs` file using the `#:sdk` directive. No `.csproj` needed. -- **Full project mode**: `appHost.path` points to a directory containing a `.csproj` and `apphost.cs`. This was created because a `.sln`/`.slnx` was found. +- **Full project mode**: `appHost.path` points to a directory containing a `.csproj` and `apphost.cs`. This was created because a `.sln`/`.slnx` was found — full project mode is required so the AppHost can be opened in Visual Studio alongside the rest of the solution. Check which mode you're in by looking at what exists at the `appHost.path` location. @@ -172,10 +177,11 @@ Analyze the repository to discover all projects and services that could be model **What to look for:** -- **.NET projects**: `*.csproj` and `*.fsproj` files. For each, run: +- **.NET projects**: `*.csproj` files. For each, run: - `dotnet msbuild -getProperty:OutputType` — `Exe`/`WinExe` = runnable service, `Library` = skip - `dotnet msbuild -getProperty:TargetFramework` — must be `net8.0` or newer - `dotnet msbuild -getProperty:IsAspireHost` — skip if `true` +- **Solution files**: `*.sln` or `*.slnx` — if found, the C# AppHost **must** use full project mode (with `.csproj`) so it can be opened in Visual Studio alongside the rest of the solution. This is a hard requirement. - **Node.js/TypeScript apps**: directories with `package.json` containing a `start`, `dev`, or `main`/`module` entry - **Python apps**: directories with `pyproject.toml`, `requirements.txt`, or `main.py`/`app.py` - **Go apps**: directories with `go.mod` @@ -255,7 +261,7 @@ const frontend = await builder // .NET project const dotnetSvc = await builder - .addProject("catalog", "./src/Catalog/Catalog.csproj"); + .addCsharpApp("catalog", "./src/Catalog"); // Dockerfile-based service const worker = await builder @@ -274,15 +280,11 @@ await builder.build().run(); #:sdk Aspire.AppHost.Sdk@ #:property IsAspireHost=true -// Project references -#:project ../src/Api/Api.csproj -#:project ../src/Web/Web.csproj - var builder = DistributedApplication.CreateBuilder(args); -var api = builder.AddProject("api"); +var api = builder.AddCsharpApp("api", "../src/Api"); -var web = builder.AddProject("web") +var web = builder.AddCsharpApp("web", "../src/Web") .WithReference(api) .WaitFor(api); @@ -296,9 +298,9 @@ Edit `apphost.cs`: ```csharp var builder = DistributedApplication.CreateBuilder(args); -var api = builder.AddProject("api"); +var api = builder.AddCsharpApp("api", "../src/Api"); -var web = builder.AddProject("web") +var web = builder.AddCsharpApp("web", "../src/Web") .WithReference(api) .WaitFor(api); @@ -686,18 +688,18 @@ This section covers the patterns you'll need when writing Step 4 (Wire up the Ap ```csharp // C#: api gets the database connection string injected automatically var db = builder.AddPostgres("pg").AddDatabase("mydb"); -var api = builder.AddProject("api") +var api = builder.AddCsharpApp("api", "../src/Api") .WithReference(db); // C#: frontend gets service discovery URL for api -var frontend = builder.AddProject("web") +var frontend = builder.AddCsharpApp("web", "../src/Web") .WithReference(api); ``` ```typescript // TypeScript equivalent const db = await builder.addPostgres("pg").addDatabase("mydb"); -const api = await builder.addProject("api", "./src/Api/Api.csproj") +const api = await builder.addCsharpApp("api", "./src/Api") .withReference(db); ``` @@ -708,7 +710,7 @@ const api = await builder.addProject("api", "./src/Api/Api.csproj") **`WithEnvironment()`** injects raw environment variables. Use this for custom config that isn't a service reference: ```csharp -var api = builder.AddProject("api") +var api = builder.AddCsharpApp("api", "../src/Api") .WithEnvironment("FEATURE_FLAG_X", "true") .WithEnvironment("API_KEY", someParameter); ``` @@ -724,11 +726,11 @@ var api = builder.AddProject("api") ```csharp // Let Aspire assign a random port (recommended for most cases) -var api = builder.AddProject("api") +var api = builder.AddCsharpApp("api", "../src/Api") .WithHttpEndpoint(); // Use a specific port -var api = builder.AddProject("api") +var api = builder.AddCsharpApp("api", "../src/Api") .WithHttpEndpoint(port: 5000); // For non-.NET services that read the port from an env var @@ -741,7 +743,7 @@ var nodeApi = builder.AddNpmApp("api", "../api", "start") **`WithEndpoint()`** — expose a non-HTTP endpoint (gRPC, TCP, custom protocols): ```csharp -var grpcService = builder.AddProject("grpc") +var grpcService = builder.AddCsharpApp("grpc", "../src/GrpcService") .WithEndpoint("grpc", endpoint => { endpoint.Port = 5050; @@ -769,7 +771,7 @@ Customize how endpoints appear in the Aspire dashboard: ```csharp // Named endpoints for clarity -var api = builder.AddProject("api") +var api = builder.AddCsharpApp("api", "../src/Api") .WithHttpEndpoint(name: "public", port: 8080) .WithHttpEndpoint(name: "internal", port: 8081); ``` @@ -781,7 +783,7 @@ var frontend = builder.AddNpmApp("frontend", "../frontend", "dev") .WithHttpEndpoint(env: "PORT") .WithUrlForEndpoint("http", url => url.Host = "frontend.dev.localhost"); -var api = builder.AddProject("api") +var api = builder.AddCsharpApp("api", "../src/Api") .WithUrlForEndpoint("http", url => url.Host = "api.dev.localhost"); ``` @@ -795,7 +797,7 @@ Use `aspire docs search "url for endpoint"` to check the latest API shape if uns ```csharp var db = builder.AddPostgres("pg").AddDatabase("mydb"); -var api = builder.AddProject("api") +var api = builder.AddCsharpApp("api", "../src/Api") .WithReference(db) .WaitFor(db); // Don't start api until db is healthy ``` @@ -805,11 +807,11 @@ Always pair `WithReference()` with `WaitFor()` for infrastructure dependencies ( **`WaitForCompletion()`** — wait for a resource to run to completion (exit successfully). Use for init containers, database migrations, or seed data scripts: ```csharp -var migration = builder.AddProject("migration") +var migration = builder.AddCsharpApp("migration", "../src/MigrationRunner") .WithReference(db) .WaitFor(db); -var api = builder.AddProject("api") +var api = builder.AddCsharpApp("api", "../src/Api") .WithReference(db) .WaitFor(db) .WaitForCompletion(migration); // Don't start until migration finishes @@ -863,9 +865,9 @@ This happens automatically for databases added to a server resource. For custom ```csharp var backend = builder.AddResource(new ContainerResource("backend-group")); -var api = builder.AddProject("api") +var api = builder.AddCsharpApp("api", "../src/Api") .WithParentRelationship(backend); -var worker = builder.AddProject("worker") +var worker = builder.AddCsharpApp("worker", "../src/Worker") .WithParentRelationship(backend); ``` From 9c71ba6f65f25cce5d111037f22e4b0811417f24 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 22:31:16 -0400 Subject: [PATCH 16/48] Use aspire run for TS scripts, be package-manager agnostic - Replace npx tsc && node pattern with 'aspire run' in package.json scripts, matching the canonical Aspire template pattern - Add package manager detection step: scan for pnpm-lock.yaml, yarn.lock, or package-lock.json and use the matching tool throughout - Replace hardcoded 'npm install' references with generic guidance that respects the repo's package manager Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 06c442ca001..ab374d17039 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -198,6 +198,7 @@ Analyze the repository to discover all projects and services that could be model - Prefer typed Aspire integrations over raw `AddContainer()` when the image matches a known integration (use `aspire docs search` to check). For example, `postgres:16` → `AddPostgres()`, `redis:7` → `AddRedis()`, `rabbitmq:3` → `AddRabbitMQ()`. - **Static frontends**: Vite, Next.js, Create React App, or other frontend framework configs - **`.env` files**: Scan for `.env`, `.env.local`, `.env.development`, `.env.example`, etc. These contain configuration that should be migrated into AppHost parameters (see Guiding Principles above) +- **Package manager**: Detect which Node.js package manager the repo uses by looking for lock files: `pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `package-lock.json` or none → npm. Use the detected package manager for all install/run commands throughout this skill. **Ignore:** @@ -357,33 +358,42 @@ Always check `aspire list integrations` and `aspire docs search ""` to #### TypeScript AppHost -**package.json** — if one exists at the root, augment it (do not overwrite). Add/merge: +**package.json** — if one exists at the root, augment it (do not overwrite). Add/merge these scripts that delegate to the Aspire CLI: ```json { "type": "module", "scripts": { - "start": "npx tsc && node --enable-source-maps apphost.js" + "dev": "aspire run", + "build": "tsc", + "watch": "tsc --watch" } } ``` -If no root `package.json` exists, create a minimal one: +If no root `package.json` exists, create a minimal one matching the canonical Aspire template: ```json { - "name": "-apphost", - "version": "1.0.0", + "name": "", + "private": true, "type": "module", "scripts": { - "start": "npx tsc && node --enable-source-maps apphost.js" + "dev": "aspire run", + "build": "tsc", + "watch": "tsc --watch" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } } ``` +**Important**: Scripts should point to `aspire run`/`aspire start` — the Aspire CLI handles TypeScript compilation internally. Do not use `npx tsc && node apphost.js` patterns. + Never overwrite existing `scripts`, `dependencies`, or `devDependencies` — merge only. Do not manually add Aspire SDK packages — `aspire restore` handles those. -Run `aspire restore` to generate the `.modules/` directory with TypeScript SDK bindings, then `npm install`. +Run `aspire restore` to generate the `.modules/` directory with TypeScript SDK bindings, then install dependencies with the repo's package manager (`npm install`, `pnpm install`, or `yarn`). **tsconfig.json** — augment if it exists: @@ -456,7 +466,10 @@ If they say yes, follow the per-language guide below. #### Node.js/TypeScript services ```bash +# Use the repo's package manager (npm/pnpm/yarn) npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-otlp-grpc +# or: pnpm add ... +# or: yarn add ... ``` Create an instrumentation file (e.g., `instrumentation.ts` or `instrumentation.js`): @@ -605,7 +618,7 @@ Check that: If it fails, diagnose and iterate. Common issues: -- **TypeScript**: missing `npm install`, TS compilation errors, port conflicts +- **TypeScript**: missing dependency install, TS compilation errors, port conflicts - **C# project mode**: missing project references, NuGet restore needed, TFM mismatches, build errors - **C# single-file**: `#:project` paths wrong, missing SDK directive - **Both**: missing environment variables, port conflicts From efcedf67d34b6beada9b6cb8209958fc03f63a8d Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 22:39:09 -0400 Subject: [PATCH 17/48] Fix dev.localhost value prop, replace AddNpmApp, add CLI validation, clean up language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite dev.localhost to focus on cookie/session isolation (not friendly URLs) - Replace all AddNpmApp with AddViteApp/AddJavaScriptApp (API removed) - Update Aspire.Hosting.NodeJs → Aspire.Hosting.JavaScript - Add aspire describe/otel/logs to validation step for verifying wiring - Remove 'non-.NET services' phrasing throughout - Simplify OTel prompt language Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 56 +++++++++++++++-------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index ab374d17039..7bcefd944d6 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -53,7 +53,7 @@ Packages named `Aspire.Hosting.*` — these are maintained by the Aspire team an | Package | Unlocks | |---------|---------| | `Aspire.Hosting.Python` | `AddPythonApp()`, `AddUvicornApp()` | -| `Aspire.Hosting.NodeJs` | `AddNodeApp()`, `AddNpmApp()`, `AddViteApp()` | +| `Aspire.Hosting.JavaScript` | `AddJavaScriptApp()`, `AddNodeApp()`, `AddViteApp()` | | `Aspire.Hosting.JavaScript` | Additional JavaScript hosting support | | `Aspire.Hosting.PostgreSQL` | `AddPostgres()`, `AddDatabase()` | | `Aspire.Hosting.Redis` | `AddRedis()` | @@ -96,7 +96,7 @@ This means: - Prefer `ContainerLifetime.Persistent` for databases and caches so data survives AppHost restarts - Use `WithDataVolume()` to persist data across container recreations -- Dev-friendly URLs with `*.dev.localhost` are encouraged +- Cookie and session isolation with `*.dev.localhost` subdomains is encouraged - Don't add production health check probes, scaling config, or cloud resource definitions - If services reference external third-party APIs/services (e.g., a hardcoded Stripe URL, an external database host, a SaaS webhook endpoint), consider modeling those as parameters or connection strings in the AppHost so they're visible and configurable from one place: @@ -222,7 +222,7 @@ Ask the user: ### Step 3: Create ServiceDefaults (C# only) -> **Skip this step for TypeScript AppHosts.** OTel for non-.NET services is handled in Step 7. +> **Skip this step for TypeScript AppHosts.** OTel is handled in Step 7. If no ServiceDefaults project exists in the repo, create one: @@ -318,8 +318,8 @@ dotnet add reference #### Non-.NET services in a C# AppHost ```csharp -// Node.js app (Tier 1: Aspire.Hosting.NodeJs) -var frontend = builder.AddNpmApp("frontend", "../frontend", "start"); +// Node.js app (Tier 1: Aspire.Hosting.JavaScript) +var frontend = builder.AddViteApp("frontend", "../frontend"); // Python app (Tier 1: Aspire.Hosting.Python) var pyApi = builder.AddPythonApp("py-api", "../py-api", "app.py"); @@ -336,8 +336,8 @@ Add required hosting packages — use `aspire add` or `dotnet add package`: ```bash # Tier 1: first-party -aspire add nodejs # or: dotnet add package Aspire.Hosting.NodeJs -aspire add python # or: dotnet add package Aspire.Hosting.Python +aspire add javascript # or: dotnet add package Aspire.Hosting.JavaScript +aspire add python # or: dotnet add package Aspire.Hosting.Python # Tier 2: community toolkit aspire add communitytoolkit-golang @@ -452,13 +452,13 @@ app.MapDefaultEndpoints(); Be careful with code placement — look at existing structure (top-level statements vs `Startup.cs` vs `Program.Main`). Do not duplicate if already present. -### Step 7: Wire up OpenTelemetry for non-.NET services +### Step 7: Wire up OpenTelemetry -For non-.NET services included in the AppHost, OpenTelemetry makes their traces, metrics, and logs visible in the Aspire dashboard. This is the equivalent of what ServiceDefaults does for .NET. Aspire automatically injects `OTEL_EXPORTER_OTLP_ENDPOINT` into all managed resources — the services just need to read it. +OpenTelemetry makes your services' traces, metrics, and logs visible in the Aspire dashboard. For .NET services, ServiceDefaults handles this automatically. For everything else, the services need a small setup to export telemetry. Aspire automatically injects `OTEL_EXPORTER_OTLP_ENDPOINT` into all managed resources — the services just need to read it. **Present this to the user as an option, not a mandatory step.** Some users may want to add OTel later, and that's fine — their services will still run, they just won't appear in the dashboard's trace/metrics views. -**For each non-.NET service, ask:** +**For each service that doesn't already have OTel, ask:** > "Would you like me to add OpenTelemetry instrumentation to ``? This lets the Aspire dashboard show its traces, metrics, and logs. I'll need to add a few packages and an instrumentation setup file." If they say yes, follow the per-language guide below. @@ -578,19 +578,19 @@ Before validating, present the user with optional quality-of-life improvements. **Suggest each of these individually — don't apply without asking:** -1. **Friendly URLs with `dev.localhost`**: Give services memorable URLs instead of random ports: - > "Would you like friendly local URLs like `frontend.dev.localhost` and `api.dev.localhost` instead of `localhost:`? These resolve to 127.0.0.1 automatically on most systems — no `/etc/hosts` changes needed." +1. **Cookie and session isolation with `dev.localhost`**: When multiple services run on `localhost`, they share cookies and session storage — which can cause hard-to-debug auth problems. Using `*.dev.localhost` subdomains isolates each service's cookies and storage. Note: URLs still include ports (e.g., `frontend.dev.localhost:5173`), but the subdomain isolation prevents cross-service cookie collisions. + > "Would you like me to set up `dev.localhost` subdomains for your services? This gives each service its own cookie/session scope so they don't interfere with each other. URLs will look like `frontend.dev.localhost:5173` — the `*.dev.localhost` domain resolves to 127.0.0.1 automatically on most systems, no `/etc/hosts` changes needed." ```csharp // C# - var frontend = builder.AddNpmApp("frontend", "../frontend") + var frontend = builder.AddViteApp("frontend", "../frontend") .WithHttpEndpoint(env: "PORT") .WithUrlForEndpoint("http", url => url.Host = "frontend.dev.localhost"); ``` ```typescript // TypeScript - const frontend = builder.addNpmApp("frontend", "../frontend") + const frontend = builder.addViteApp("frontend", "../frontend") .withHttpEndpoint({ env: "PORT" }) .withUrlForEndpoint("http", url => { url.host = "frontend.dev.localhost"; }); ``` @@ -600,7 +600,7 @@ Before validating, present the user with optional quality-of-life improvements. .WithUrlForEndpoint("http", url => url.DisplayText = "Web UI") ``` -3. **OpenTelemetry** (if not done in Step 7): "Would you like to add observability to your non-.NET services so they appear in the Aspire dashboard's traces and metrics views?" +3. **OpenTelemetry** (if not done in Step 7): "Would you like me to add observability to your services so they appear in the Aspire dashboard's traces and metrics views?" Present these as a batch: "I have a few optional dev experience improvements I can make. Want to hear about them?" @@ -610,13 +610,15 @@ Present these as a batch: "I have a few optional dev experience improvements I c aspire start ``` -Check that: +Once the app is running, use the Aspire CLI to verify everything is wired up correctly: -1. The dashboard URL is printed -2. All modeled resources appear in `aspire describe` -3. No startup errors in `aspire logs` +1. **Resources are modeled**: `aspire describe` — confirm all expected resources appear with correct types, endpoints, and states. +2. **Environment flows correctly**: `aspire describe` — check that environment variables (connection strings, ports, secrets from parameters) are injected into each resource as expected. Verify `.env` values that were migrated to parameters are present. +3. **OTel is flowing** (if configured in Step 7): `aspire otel` — verify that services instrumented with OpenTelemetry are exporting traces and metrics to the Aspire dashboard collector. +4. **No startup errors**: `aspire logs ` — check logs for each resource to ensure clean startup with no crashes, missing config, or connection failures. +5. **Dashboard is accessible**: Confirm the dashboard URL is printed and can be opened. -If it fails, diagnose and iterate. Common issues: +If anything fails, diagnose and iterate. Common issues: - **TypeScript**: missing dependency install, TS compilation errors, port conflicts - **C# project mode**: missing project references, NuGet restore needed, TFM mismatches, build errors @@ -716,7 +718,7 @@ const api = await builder.addCsharpApp("api", "./src/Api") .withReference(db); ``` -**How non-.NET services consume references**: They receive environment variables. The naming convention is: +**How services consume references**: Services receive connection info as environment variables. The naming convention is: - Connection strings: `ConnectionStrings__` (e.g., `ConnectionStrings__mydb=Host=...`) - Service URLs: `services______0` (e.g., `services__api__http__0=http://localhost:5123`) @@ -746,8 +748,8 @@ var api = builder.AddCsharpApp("api", "../src/Api") var api = builder.AddCsharpApp("api", "../src/Api") .WithHttpEndpoint(port: 5000); -// For non-.NET services that read the port from an env var -var nodeApi = builder.AddNpmApp("api", "../api", "start") +// For services that read the port from an env var +var nodeApi = builder.AddJavaScriptApp("api", "../api", "start") .WithHttpEndpoint(env: "PORT"); // Aspire injects PORT= ``` @@ -767,12 +769,12 @@ var grpcService = builder.AddCsharpApp("grpc", "../src/GrpcService") **`WithExternalHttpEndpoints()`** — mark a resource's HTTP endpoints as externally visible. Use this for user-facing frontends so the URL appears prominently in the dashboard: ```csharp -var frontend = builder.AddNpmApp("frontend", "../frontend", "dev") +var frontend = builder.AddViteApp("frontend", "../frontend") .WithHttpEndpoint(env: "PORT") .WithExternalHttpEndpoints(); ``` -**Port injection for non-.NET services**: Many frameworks (Express, Vite, Flask) need to know which port to listen on. Use the `env:` parameter: +**Port injection**: Many frameworks (Express, Vite, Flask) need to know which port to listen on. Use the `env:` parameter: - `withHttpEndpoint({ env: "PORT" })` (TypeScript) - `.WithHttpEndpoint(env: "PORT")` (C#) @@ -789,10 +791,10 @@ var api = builder.AddCsharpApp("api", "../src/Api") .WithHttpEndpoint(name: "internal", port: 8081); ``` -**Custom domains with `dev.localhost`**: For a nicer local dev experience, use `WithUrlForEndpoint()` to give services friendly URLs that resolve to localhost: +**Cookie/session isolation with `dev.localhost`**: When multiple services share `localhost`, cookies and session storage can leak between them. Using `*.dev.localhost` subdomains gives each service its own cookie scope. URLs still have ports (e.g., `frontend.dev.localhost:5173`), but the subdomain isolation prevents cross-service collisions: ```csharp -var frontend = builder.AddNpmApp("frontend", "../frontend", "dev") +var frontend = builder.AddViteApp("frontend", "../frontend") .WithHttpEndpoint(env: "PORT") .WithUrlForEndpoint("http", url => url.Host = "frontend.dev.localhost"); From 6e8b0e220a407f28a3967b36ffe943499a108f9a Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 22:43:27 -0400 Subject: [PATCH 18/48] Prefer HTTPS over HTTP, add WithHttpsDeveloperCertificate guidance - Add 'Prefer HTTPS over HTTP' principle with full guidance - Update all code samples to use WithHttpsEndpoint by default - Add WithHttpsDeveloperCertificate for JS/Python apps throughout - Keep WithHttpEndpoint as documented fallback when HTTPS causes issues - Update dev.localhost examples to use 'https' endpoint names - Note experimental status (ASPIRECERTIFICATES001) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 116 ++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 32 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 7bcefd944d6..35c23735d73 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -14,7 +14,7 @@ This is a **one-time setup skill**. It completes the Aspire initialization that The default stance is **adapt the AppHost to fit the app, not the other way around**. The user's services already work — the goal is to model them in Aspire without breaking anything. - Prefer `WithEnvironment()` to match existing env var names over asking users to rename vars in their code -- Use `WithHttpEndpoint(port: )` to match hardcoded ports rather than changing the service +- Use `WithHttpsEndpoint(port: )` to match hardcoded ports rather than changing the service - Map existing `docker-compose.yml` config 1:1 before optimizing - Don't restructure project directories, rename files, or change build scripts @@ -25,7 +25,7 @@ Sometimes a small code change unlocks significantly better Aspire integration. W - **Connection strings**: A service reads `DATABASE_URL` but Aspire injects `ConnectionStrings__mydb`. You can use `WithEnvironment("DATABASE_URL", db.Resource.ConnectionStringExpression)` (zero code change) or suggest the service reads from config so `WithReference(db)` just works (enables service discovery, health checks, auto-retry). → Ask: *"Your API reads DATABASE_URL. I can map that with WithEnvironment (no code change) or you could switch to reading ConnectionStrings:mydb which unlocks WithReference and automatic service discovery. Which do you prefer?"* -- **Port binding**: A service hardcodes `PORT=3000`. You can match it with `WithHttpEndpoint(port: 3000)` (zero change) or suggest reading from env so Aspire can assign ports dynamically and avoid conflicts. +- **Port binding**: A service hardcodes `PORT=3000`. You can match it with `WithHttpsEndpoint(port: 3000)` (zero change) or suggest reading from env so Aspire can assign ports dynamically and avoid conflicts. → Ask: *"Your frontend hardcodes port 3000. I can match that, but if you read PORT from env instead, Aspire can assign ports dynamically and avoid conflicts when running multiple services. Want me to make that change?"* - **OTel setup**: Service has its own tracing config pointing to Jaeger. You can leave it (Aspire won't show its traces) or suggest switching the exporter to read `OTEL_EXPORTER_OTLP_ENDPOINT` (which Aspire injects). @@ -88,6 +88,28 @@ Before writing any builder call: **API shapes differ between C# and TypeScript** — always check the correct language docs. +### Prefer HTTPS over HTTP + +Always set up HTTPS endpoints by default. Use `WithHttpsEndpoint()` instead of `WithHttpEndpoint()` unless HTTPS doesn't work for a specific integration. + +For JavaScript and Python apps, call `WithHttpsDeveloperCertificate()` to configure the ASP.NET Core dev cert for serving HTTPS. Some apps may also need `WithDeveloperCertificateTrust(true)` so they trust the dev cert for outbound calls (e.g., to the dashboard OTLP collector). If HTTPS causes issues for a specific resource, fall back to HTTP and leave a comment explaining why. + +```csharp +// JavaScript/Vite — HTTPS with dev cert +var frontend = builder.AddViteApp("frontend", "../frontend") + .WithHttpsDeveloperCertificate() + .WithHttpsEndpoint(env: "PORT"); + +// Python — HTTPS with dev cert +var pyApi = builder.AddUvicornApp("py-api", "../py-api", "app:main") + .WithHttpsDeveloperCertificate(); + +// .NET — HTTPS works out of the box, no extra config needed +var api = builder.AddCsharpApp("api", "../src/Api"); +``` + +> **Note**: These certificate APIs are experimental (`ASPIRECERTIFICATES001`). Use `aspire docs search "certificate configuration"` to check the latest API shape. If `WithHttpsDeveloperCertificate` causes errors for a resource type, fall back to `WithHttpEndpoint()`. + ### Optimize for local dev, not deployment This skill is about getting a great **local development experience**. Don't worry about production deployment manifests, cloud provisioning, or publish configuration — that's a separate concern for later. @@ -190,7 +212,7 @@ Analyze the repository to discover all projects and services that could be model - **Docker Compose**: `docker-compose.yml` or `compose.yml` files — these are a goldmine. Parse them to extract: - **Services**: each named service maps to a potential AppHost resource - **Images**: container images used (e.g., `postgres:16`, `redis:7`) → these become `AddContainer()` or typed Aspire integrations (e.g., `AddPostgres()`, `AddRedis()`) - - **Ports**: published port mappings → `WithHttpEndpoint()` or `WithEndpoint()` + - **Ports**: published port mappings → `WithHttpsEndpoint()` or `WithEndpoint()` - **Environment variables**: env vars and `.env` file references → `WithEnvironment()` - **Volumes**: named/bind volumes → `WithVolume()` or `WithBindMount()` - **Dependencies**: `depends_on` → `WithReference()` and `WaitFor()` @@ -249,18 +271,20 @@ import { createBuilder } from './.modules/aspire.js'; const builder = await createBuilder(); -// Node.js/TypeScript app +// Node.js/TypeScript app — HTTPS with dev cert const api = await builder .addNodeApp("api", "./api", "src/index.ts") - .withHttpEndpoint({ env: "PORT" }); + .withHttpsDeveloperCertificate() + .withHttpsEndpoint({ env: "PORT" }); -// Vite frontend +// Vite frontend — HTTPS with dev cert const frontend = await builder .addViteApp("frontend", "./frontend") + .withHttpsDeveloperCertificate() .withReference(api) .waitFor(api); -// .NET project +// .NET project — HTTPS works out of the box const dotnetSvc = await builder .addCsharpApp("catalog", "./src/Catalog"); @@ -268,9 +292,10 @@ const dotnetSvc = await builder const worker = await builder .addDockerfile("worker", "./worker"); -// Python app +// Python app — HTTPS with dev cert const pyApi = await builder - .addPythonApp("py-api", "./py-api", "app.py"); + .addPythonApp("py-api", "./py-api", "app.py") + .withHttpsDeveloperCertificate(); await builder.build().run(); ``` @@ -318,15 +343,17 @@ dotnet add reference #### Non-.NET services in a C# AppHost ```csharp -// Node.js app (Tier 1: Aspire.Hosting.JavaScript) -var frontend = builder.AddViteApp("frontend", "../frontend"); +// Node.js app (Tier 1: Aspire.Hosting.JavaScript) — HTTPS with dev cert +var frontend = builder.AddViteApp("frontend", "../frontend") + .WithHttpsDeveloperCertificate(); -// Python app (Tier 1: Aspire.Hosting.Python) -var pyApi = builder.AddPythonApp("py-api", "../py-api", "app.py"); +// Python app (Tier 1: Aspire.Hosting.Python) — HTTPS with dev cert +var pyApi = builder.AddPythonApp("py-api", "../py-api", "app.py") + .WithHttpsDeveloperCertificate(); // Go app (Tier 2: CommunityToolkit.Aspire.Hosting.Golang) var goApi = builder.AddGolangApp("go-api", "../go-api") - .WithHttpEndpoint(env: "PORT"); + .WithHttpsEndpoint(env: "PORT"); // Dockerfile-based service (Tier 3: fallback for unsupported languages) var worker = builder.AddDockerfile("worker", "../worker"); @@ -584,20 +611,22 @@ Before validating, present the user with optional quality-of-life improvements. ```csharp // C# var frontend = builder.AddViteApp("frontend", "../frontend") - .WithHttpEndpoint(env: "PORT") - .WithUrlForEndpoint("http", url => url.Host = "frontend.dev.localhost"); + .WithHttpsDeveloperCertificate() + .WithHttpsEndpoint(env: "PORT") + .WithUrlForEndpoint("https", url => url.Host = "frontend.dev.localhost"); ``` ```typescript // TypeScript const frontend = builder.addViteApp("frontend", "../frontend") - .withHttpEndpoint({ env: "PORT" }) - .withUrlForEndpoint("http", url => { url.host = "frontend.dev.localhost"; }); + .withHttpsDeveloperCertificate() + .withHttpsEndpoint({ env: "PORT" }) + .withUrlForEndpoint("https", url => { url.host = "frontend.dev.localhost"; }); ``` 2. **Custom URL labels in the dashboard**: Rename endpoint URLs in the Aspire dashboard for clarity: ```csharp - .WithUrlForEndpoint("http", url => url.DisplayText = "Web UI") + .WithUrlForEndpoint("https", url => url.DisplayText = "Web UI") ``` 3. **OpenTelemetry** (if not done in Step 7): "Would you like me to add observability to your services so they appear in the Aspire dashboard's traces and metrics views?" @@ -737,23 +766,44 @@ var api = builder.AddCsharpApp("api", "../src/Api") ### Endpoints and ports -**`WithHttpEndpoint()`** — expose an HTTP endpoint. For services that serve HTTP traffic: +**Prefer HTTPS by default.** Use `WithHttpsEndpoint()` for all services and fall back to `WithHttpEndpoint()` only if HTTPS doesn't work for that resource. + +**`WithHttpsEndpoint()`** — expose an HTTPS endpoint. For services that serve traffic: ```csharp // Let Aspire assign a random port (recommended for most cases) var api = builder.AddCsharpApp("api", "../src/Api") - .WithHttpEndpoint(); + .WithHttpsEndpoint(); // Use a specific port var api = builder.AddCsharpApp("api", "../src/Api") - .WithHttpEndpoint(port: 5000); + .WithHttpsEndpoint(port: 5001); // For services that read the port from an env var var nodeApi = builder.AddJavaScriptApp("api", "../api", "start") - .WithHttpEndpoint(env: "PORT"); // Aspire injects PORT= + .WithHttpsDeveloperCertificate() + .WithHttpsEndpoint(env: "PORT"); // Aspire injects PORT= ``` -**`WithHttpsEndpoint()`** — same as above but for HTTPS. +**`WithHttpsDeveloperCertificate()`** — required for JavaScript and Python apps to serve HTTPS. Configures the ASP.NET Core dev cert. .NET apps handle this automatically. + +```csharp +var frontend = builder.AddViteApp("frontend", "../frontend") + .WithHttpsDeveloperCertificate(); + +var pyApi = builder.AddUvicornApp("api", "../api", "app:main") + .WithHttpsDeveloperCertificate(); +``` + +> If `WithHttpsDeveloperCertificate()` causes issues for a resource, fall back to `WithHttpEndpoint()` and leave a comment explaining why. + +**`WithHttpEndpoint()`** — fallback for HTTP when HTTPS doesn't work: + +```csharp +// Use when HTTPS causes issues with a specific integration +var legacy = builder.AddJavaScriptApp("legacy", "../legacy", "start") + .WithHttpEndpoint(env: "PORT"); // HTTP fallback +``` **`WithEndpoint()`** — expose a non-HTTP endpoint (gRPC, TCP, custom protocols): @@ -770,13 +820,14 @@ var grpcService = builder.AddCsharpApp("grpc", "../src/GrpcService") ```csharp var frontend = builder.AddViteApp("frontend", "../frontend") - .WithHttpEndpoint(env: "PORT") + .WithHttpsDeveloperCertificate() + .WithHttpsEndpoint(env: "PORT") .WithExternalHttpEndpoints(); ``` **Port injection**: Many frameworks (Express, Vite, Flask) need to know which port to listen on. Use the `env:` parameter: -- `withHttpEndpoint({ env: "PORT" })` (TypeScript) -- `.WithHttpEndpoint(env: "PORT")` (C#) +- `withHttpsEndpoint({ env: "PORT" })` (TypeScript) +- `.WithHttpsEndpoint(env: "PORT")` (C#) Aspire assigns a port and injects it as the specified environment variable. The service should read it and listen on that port. @@ -787,19 +838,20 @@ Customize how endpoints appear in the Aspire dashboard: ```csharp // Named endpoints for clarity var api = builder.AddCsharpApp("api", "../src/Api") - .WithHttpEndpoint(name: "public", port: 8080) - .WithHttpEndpoint(name: "internal", port: 8081); + .WithHttpsEndpoint(name: "public", port: 8443) + .WithHttpsEndpoint(name: "internal", port: 8444); ``` **Cookie/session isolation with `dev.localhost`**: When multiple services share `localhost`, cookies and session storage can leak between them. Using `*.dev.localhost` subdomains gives each service its own cookie scope. URLs still have ports (e.g., `frontend.dev.localhost:5173`), but the subdomain isolation prevents cross-service collisions: ```csharp var frontend = builder.AddViteApp("frontend", "../frontend") - .WithHttpEndpoint(env: "PORT") - .WithUrlForEndpoint("http", url => url.Host = "frontend.dev.localhost"); + .WithHttpsDeveloperCertificate() + .WithHttpsEndpoint(env: "PORT") + .WithUrlForEndpoint("https", url => url.Host = "frontend.dev.localhost"); var api = builder.AddCsharpApp("api", "../src/Api") - .WithUrlForEndpoint("http", url => url.Host = "api.dev.localhost"); + .WithUrlForEndpoint("https", url => url.Host = "api.dev.localhost"); ``` > Note: `*.dev.localhost` resolves to `127.0.0.1` on most systems without any `/etc/hosts` changes. From 1462ebbfc4d8964ae5c20fae37746a097e765c64 Mon Sep 17 00:00:00 2001 From: "Maddy Montaquila (Leger)" Date: Mon, 6 Apr 2026 22:47:04 -0400 Subject: [PATCH 19/48] Update src/Aspire.Cli/Agents/SkillDefinition.cs Co-authored-by: David Pine --- src/Aspire.Cli/Agents/SkillDefinition.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Agents/SkillDefinition.cs b/src/Aspire.Cli/Agents/SkillDefinition.cs index b1cf08b01be..97eff10d891 100644 --- a/src/Aspire.Cli/Agents/SkillDefinition.cs +++ b/src/Aspire.Cli/Agents/SkillDefinition.cs @@ -162,5 +162,5 @@ private static bool PathMatchesOrIsUnder(string relativePath, string excludedPat /// /// Gets all available skill definitions. /// - public static IReadOnlyList All { get; } = [Aspire, PlaywrightCli, DotnetInspect, AspireInit]; + public static IReadOnlyList All { get; } = [Aspire, AspireInit, PlaywrightCli, DotnetInspect]; } From c584bb1e0676990473c02c5e24effc5745dfa480 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 23:00:52 -0400 Subject: [PATCH 20/48] Fix AddCSharpApp casing (capital S, capital H) GPT-5.4 review caught that the actual C# API is AddCSharpApp (not AddCsharpApp) and the TS equivalent is addCSharpApp. Fixed all 22 occurrences. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 44 ++++++++++++++--------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 35c23735d73..fc0b2463d43 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -105,7 +105,7 @@ var pyApi = builder.AddUvicornApp("py-api", "../py-api", "app:main") .WithHttpsDeveloperCertificate(); // .NET — HTTPS works out of the box, no extra config needed -var api = builder.AddCsharpApp("api", "../src/Api"); +var api = builder.AddCSharpApp("api", "../src/Api"); ``` > **Note**: These certificate APIs are experimental (`ASPIRECERTIFICATES001`). Use `aspire docs search "certificate configuration"` to check the latest API shape. If `WithHttpsDeveloperCertificate` causes errors for a resource type, fall back to `WithHttpEndpoint()`. @@ -125,7 +125,7 @@ This means: ```csharp // Instead of the service hardcoding "https://api.stripe.com" var stripeUrl = builder.AddParameter("stripe-url", secret: false); -var api = builder.AddCsharpApp("api", "../src/Api") +var api = builder.AddCSharpApp("api", "../src/Api") .WithEnvironment("STRIPE_API_URL", stripeUrl); ``` @@ -148,7 +148,7 @@ Many projects use `.env` files for configuration. These should be migrated into var db = builder.AddPostgres("pg").AddDatabase("mydb"); var stripeKey = builder.AddParameter("stripe-key", secret: true); -var api = builder.AddCsharpApp("api", "../src/Api") +var api = builder.AddCSharpApp("api", "../src/Api") .WithReference(db) // replaces DATABASE_URL .WithEnvironment("STRIPE_KEY", stripeKey) // secret, stored securely .WithEnvironment("DEBUG", "true"); // plain config @@ -286,7 +286,7 @@ const frontend = await builder // .NET project — HTTPS works out of the box const dotnetSvc = await builder - .addCsharpApp("catalog", "./src/Catalog"); + .addCSharpApp("catalog", "./src/Catalog"); // Dockerfile-based service const worker = await builder @@ -308,9 +308,9 @@ await builder.build().run(); var builder = DistributedApplication.CreateBuilder(args); -var api = builder.AddCsharpApp("api", "../src/Api"); +var api = builder.AddCSharpApp("api", "../src/Api"); -var web = builder.AddCsharpApp("web", "../src/Web") +var web = builder.AddCSharpApp("web", "../src/Web") .WithReference(api) .WaitFor(api); @@ -324,9 +324,9 @@ Edit `apphost.cs`: ```csharp var builder = DistributedApplication.CreateBuilder(args); -var api = builder.AddCsharpApp("api", "../src/Api"); +var api = builder.AddCSharpApp("api", "../src/Api"); -var web = builder.AddCsharpApp("web", "../src/Web") +var web = builder.AddCSharpApp("web", "../src/Web") .WithReference(api) .WaitFor(api); @@ -732,18 +732,18 @@ This section covers the patterns you'll need when writing Step 4 (Wire up the Ap ```csharp // C#: api gets the database connection string injected automatically var db = builder.AddPostgres("pg").AddDatabase("mydb"); -var api = builder.AddCsharpApp("api", "../src/Api") +var api = builder.AddCSharpApp("api", "../src/Api") .WithReference(db); // C#: frontend gets service discovery URL for api -var frontend = builder.AddCsharpApp("web", "../src/Web") +var frontend = builder.AddCSharpApp("web", "../src/Web") .WithReference(api); ``` ```typescript // TypeScript equivalent const db = await builder.addPostgres("pg").addDatabase("mydb"); -const api = await builder.addCsharpApp("api", "./src/Api") +const api = await builder.addCSharpApp("api", "./src/Api") .withReference(db); ``` @@ -754,7 +754,7 @@ const api = await builder.addCsharpApp("api", "./src/Api") **`WithEnvironment()`** injects raw environment variables. Use this for custom config that isn't a service reference: ```csharp -var api = builder.AddCsharpApp("api", "../src/Api") +var api = builder.AddCSharpApp("api", "../src/Api") .WithEnvironment("FEATURE_FLAG_X", "true") .WithEnvironment("API_KEY", someParameter); ``` @@ -772,11 +772,11 @@ var api = builder.AddCsharpApp("api", "../src/Api") ```csharp // Let Aspire assign a random port (recommended for most cases) -var api = builder.AddCsharpApp("api", "../src/Api") +var api = builder.AddCSharpApp("api", "../src/Api") .WithHttpsEndpoint(); // Use a specific port -var api = builder.AddCsharpApp("api", "../src/Api") +var api = builder.AddCSharpApp("api", "../src/Api") .WithHttpsEndpoint(port: 5001); // For services that read the port from an env var @@ -808,7 +808,7 @@ var legacy = builder.AddJavaScriptApp("legacy", "../legacy", "start") **`WithEndpoint()`** — expose a non-HTTP endpoint (gRPC, TCP, custom protocols): ```csharp -var grpcService = builder.AddCsharpApp("grpc", "../src/GrpcService") +var grpcService = builder.AddCSharpApp("grpc", "../src/GrpcService") .WithEndpoint("grpc", endpoint => { endpoint.Port = 5050; @@ -837,7 +837,7 @@ Customize how endpoints appear in the Aspire dashboard: ```csharp // Named endpoints for clarity -var api = builder.AddCsharpApp("api", "../src/Api") +var api = builder.AddCSharpApp("api", "../src/Api") .WithHttpsEndpoint(name: "public", port: 8443) .WithHttpsEndpoint(name: "internal", port: 8444); ``` @@ -850,7 +850,7 @@ var frontend = builder.AddViteApp("frontend", "../frontend") .WithHttpsEndpoint(env: "PORT") .WithUrlForEndpoint("https", url => url.Host = "frontend.dev.localhost"); -var api = builder.AddCsharpApp("api", "../src/Api") +var api = builder.AddCSharpApp("api", "../src/Api") .WithUrlForEndpoint("https", url => url.Host = "api.dev.localhost"); ``` @@ -864,7 +864,7 @@ Use `aspire docs search "url for endpoint"` to check the latest API shape if uns ```csharp var db = builder.AddPostgres("pg").AddDatabase("mydb"); -var api = builder.AddCsharpApp("api", "../src/Api") +var api = builder.AddCSharpApp("api", "../src/Api") .WithReference(db) .WaitFor(db); // Don't start api until db is healthy ``` @@ -874,11 +874,11 @@ Always pair `WithReference()` with `WaitFor()` for infrastructure dependencies ( **`WaitForCompletion()`** — wait for a resource to run to completion (exit successfully). Use for init containers, database migrations, or seed data scripts: ```csharp -var migration = builder.AddCsharpApp("migration", "../src/MigrationRunner") +var migration = builder.AddCSharpApp("migration", "../src/MigrationRunner") .WithReference(db) .WaitFor(db); -var api = builder.AddCsharpApp("api", "../src/Api") +var api = builder.AddCSharpApp("api", "../src/Api") .WithReference(db) .WaitFor(db) .WaitForCompletion(migration); // Don't start until migration finishes @@ -932,9 +932,9 @@ This happens automatically for databases added to a server resource. For custom ```csharp var backend = builder.AddResource(new ContainerResource("backend-group")); -var api = builder.AddCsharpApp("api", "../src/Api") +var api = builder.AddCSharpApp("api", "../src/Api") .WithParentRelationship(backend); -var worker = builder.AddCsharpApp("worker", "../src/Worker") +var worker = builder.AddCSharpApp("worker", "../src/Worker") .WithParentRelationship(backend); ``` From 93cecec5632e6473cf364c50f05d3a5e983e92ee Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 23:02:52 -0400 Subject: [PATCH 21/48] Note aspire list integrations requires MCP server Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index fc0b2463d43..cb53aeb67c4 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -80,7 +80,7 @@ Before writing any builder call: 1. Run `aspire docs search ""` (e.g., `aspire docs search "golang"`, `aspire docs search "python"`) 2. Run `aspire docs get ""` to read the full API surface and installation instructions -3. Run `aspire list integrations` to see all available packages (first-party and community toolkit) +3. Run `aspire list integrations` to see all available packages (requires Aspire MCP — if unavailable, rely on docs search) 4. Install with `aspire add ` (e.g., `aspire add communitytoolkit-golang`) 5. For TypeScript, run `aspire restore` then check `.modules/aspire.ts` to see what's available @@ -696,6 +696,7 @@ aspire docs get "redis-integration" aspire docs get "go-integration" # List ALL available integrations (first-party and community toolkit) +# Note: requires the Aspire MCP server to be connected. If this fails, use aspire docs search instead. aspire list integrations ``` From 0d6331da3523d91d28e394e715c842e3ce964caa Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Mon, 6 Apr 2026 23:20:21 -0400 Subject: [PATCH 22/48] Emphasize iterate until aspire start works without errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index cb53aeb67c4..01e71956450 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -191,7 +191,7 @@ Check which mode you're in by looking at what exists at the `appHost.path` locat ## Workflow -Follow these steps in order. If any step fails, diagnose and fix before continuing. +Follow these steps in order. If any step fails, diagnose and fix before continuing. **The goal is a working `aspire start` — keep going until every resource starts cleanly and the dashboard is accessible. Do not stop at partial success.** ### Step 1: Scan the repository @@ -647,7 +647,9 @@ Once the app is running, use the Aspire CLI to verify everything is wired up cor 4. **No startup errors**: `aspire logs ` — check logs for each resource to ensure clean startup with no crashes, missing config, or connection failures. 5. **Dashboard is accessible**: Confirm the dashboard URL is printed and can be opened. -If anything fails, diagnose and iterate. Common issues: +**This skill is not done until `aspire start` runs without errors and all resources are healthy.** If anything fails, diagnose, fix, and run `aspire start` again. Keep iterating until it works — do not move on to Step 10 with a broken app. + +Common issues: - **TypeScript**: missing dependency install, TS compilation errors, port conflicts - **C# project mode**: missing project references, NuGet restore needed, TFM mismatches, build errors From 3928b19fa70976f0d19a5b5375cd7bc1b815216a Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 14:32:16 -0400 Subject: [PATCH 23/48] Fix aspire init writing 'already exists' on fresh repos The language selection step (GetOrPromptForProjectAsync with saveSelection: true) writes aspire.config.json to disk before DropAspireConfig runs. This causes DropAspireConfig to find the file already present and skip writing the appHost.path field. Fix: merge into existing file instead of skipping, so the path field is always written regardless of prior file state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/InitCommand.cs | 52 +++++++++++++++++--------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 06a1dabc4b0..c5091efb490 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -3,6 +3,8 @@ using System.CommandLine; using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; using Aspire.Cli.Agents; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; @@ -239,29 +241,45 @@ private Task DropPolyglotSkeletonAsync(string languageId, DirectoryInfo wor private void DropAspireConfig(DirectoryInfo directory, string appHostPath, string? language) { var configPath = Path.Combine(directory.FullName, AspireConfigFile.FileName); + + JsonObject settings; + var isUpdate = false; + if (File.Exists(configPath)) { - InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"{AspireConfigFile.FileName} already exists — skipping."); - return; + // Merge into existing file (e.g. language selection already wrote it) + var existingContent = File.ReadAllText(configPath); + settings = string.IsNullOrWhiteSpace(existingContent) + ? new JsonObject() + : JsonNode.Parse(existingContent)?.AsObject() ?? new JsonObject(); + isUpdate = true; + } + else + { + settings = new JsonObject(); } - var languageLine = language is not null - ? $""" - , - "language": "{language}" - """ - : string.Empty; + // Ensure appHost section exists + if (settings["appHost"] is not JsonObject appHost) + { + appHost = new JsonObject(); + settings["appHost"] = appHost; + } - var configContent = $$""" - { - "appHost": { - "path": "{{appHostPath}}"{{languageLine}} - } - } - """; + // Set path (always — this is the primary purpose of DropAspireConfig) + appHost["path"] = appHostPath; + + // Set language if provided and not already present + if (language is not null && appHost["language"] is null) + { + appHost["language"] = language; + } + + var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + File.WriteAllText(configPath, settings.ToJsonString(jsonOptions)); - File.WriteAllText(configPath, configContent); - InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"Created {AspireConfigFile.FileName}"); + var verb = isUpdate ? "Updated" : "Created"; + InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"{verb} {AspireConfigFile.FileName}"); } private async Task InstallInitSkillAsync(DirectoryInfo workspaceRoot, SkillDefinition skill, CancellationToken cancellationToken) From 2b622f5cac7bf9a0aa97ba838f3f52903dc281a0 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 14:41:46 -0400 Subject: [PATCH 24/48] Improve skill based on excalidraw real-world test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key additions based on analyzing agent vs Damian's working excalidraw apphost: - JS resource type decision matrix (addNodeApp vs addJavaScriptApp vs addViteApp) with clear signals for when to use each - withRunScript/withBuildScript documentation with examples — critical for aspire publish to work with TypeScript servers - Monorepo/workspace detection guidance in Step 1 (path resolution, root scripts that delegate, workspace-aware installs) - Framework-specific port binding table (Express, Vite, Next.js, CRA) - BROWSER=none pattern to suppress auto-browser-open - Cross-service env var wiring examples (withEnvironment + endpoint ref) - Never call it '.NET Aspire' — just 'Aspire' - Dashboard URL must include auth token - Updated main TS AppHost example with all new patterns Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 111 ++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 7 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 01e71956450..c2422d7897d 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -53,8 +53,7 @@ Packages named `Aspire.Hosting.*` — these are maintained by the Aspire team an | Package | Unlocks | |---------|---------| | `Aspire.Hosting.Python` | `AddPythonApp()`, `AddUvicornApp()` | -| `Aspire.Hosting.JavaScript` | `AddJavaScriptApp()`, `AddNodeApp()`, `AddViteApp()` | -| `Aspire.Hosting.JavaScript` | Additional JavaScript hosting support | +| `Aspire.Hosting.JavaScript` | `AddJavaScriptApp()`, `AddNodeApp()`, `AddViteApp()`, `.WithYarn()`, `.WithPnpm()` | | `Aspire.Hosting.PostgreSQL` | `AddPostgres()`, `AddDatabase()` | | `Aspire.Hosting.Redis` | `AddRedis()` | @@ -88,6 +87,63 @@ Before writing any builder call: **API shapes differ between C# and TypeScript** — always check the correct language docs. +### Choosing the right JavaScript resource type + +The `Aspire.Hosting.JavaScript` package provides three resource types. Pick the right one: + +| Signal | Use | Example | +|--------|-----|---------| +| Vite app (has `vite.config.*`) | `AddViteApp(name, dir)` | Frontend SPA, Vite + React/Vue/Svelte | +| App runs via package.json script only | `AddJavaScriptApp(name, dir, { runScriptName })` | CRA app, Next.js, monorepo root scripts | +| App has a specific Node entry file (`.js`/`.ts`) | `AddNodeApp(name, dir, "path/to/entry.js")` | Express/Fastify API, Socket.IO server | +| App needs different scripts for dev vs publish | `AddNodeApp(...)` + `.WithRunScript("dev")` + `.WithBuildScript("build")` | TypeScript server compiled to `dist/` | + +**Key distinctions:** +- `AddNodeApp` separates the **production entry point** (`dist/index.js`) from the **dev script** (`WithRunScript("start:dev")`), which is critical for `aspire publish` to work correctly. +- `AddJavaScriptApp` runs the same npm/yarn script in all modes — simpler but no dev/prod distinction. +- `AddViteApp` is `AddJavaScriptApp` with Vite-specific defaults (auto-HTTPS config augmentation, `dev` as default script). + +**Always add `.WithBuildScript("build")` when the app has a TypeScript compilation or bundling step** — without it, `aspire publish` will fail because there's nothing to produce production assets. + +### Dev vs publish scripts for JavaScript apps + +```typescript +// Express API with TypeScript: runs ts-node-dev in dev, compiled JS in prod +const api = await builder + .addNodeApp("api", "./api", "dist/index.js") // production entry point + .withRunScript("start:dev") // dev: runs "npm run start:dev" + .withBuildScript("build") // publish: runs "npm run build" first + .withYarn() + .withHttpEndpoint({ env: "PORT" }); + +// Vite frontend: vite dev server in dev, built assets in prod +const web = await builder + .addViteApp("web", "./frontend") + .withBuildScript("build") // publish: runs "npm run build" + .withYarn(); +``` + +### Framework-specific port binding + +Not all frameworks read ports from env vars the same way: + +| Framework | Port mechanism | AppHost pattern | +|-----------|---------------|-----------------| +| Express/Fastify | `process.env.PORT` | `.withHttpEndpoint({ env: "PORT" })` | +| Vite | `--port` CLI arg or `server.port` in config | `.withHttpEndpoint({ env: "PORT" })` — Aspire's Vite integration handles this automatically | +| Next.js | `PORT` env or `--port` | `.withHttpEndpoint({ env: "PORT" })` | +| CRA | `PORT` env | `.withHttpEndpoint({ env: "PORT" })` | + +**Suppress auto-browser-open:** Many dev servers (Vite, CRA, Next.js) auto-open a browser on start. Add `.withEnvironment("BROWSER", "none")` to prevent this in Aspire-managed apps. Vite also respects `server.open: false` in its config. + +### Never call it ".NET Aspire" + +Always refer to the product as just **Aspire**, never ".NET Aspire". This applies to all comments in generated AppHost code, messages to the user, and any documentation you produce. + +### Dashboard URL must include auth token + +When printing or displaying the Aspire dashboard URL to the user, always include the full login token query parameter. The dashboard requires authentication — a bare URL like `http://localhost:18888` won't work. Use the full URL as printed by `aspire start` (e.g., `http://localhost:18888/login?t=`). + ### Prefer HTTPS over HTTP Always set up HTTPS endpoints by default. Use `WithHttpsEndpoint()` instead of `WithHttpEndpoint()` unless HTTPS doesn't work for a specific integration. @@ -204,7 +260,15 @@ Analyze the repository to discover all projects and services that could be model - `dotnet msbuild -getProperty:TargetFramework` — must be `net8.0` or newer - `dotnet msbuild -getProperty:IsAspireHost` — skip if `true` - **Solution files**: `*.sln` or `*.slnx` — if found, the C# AppHost **must** use full project mode (with `.csproj`) so it can be opened in Visual Studio alongside the rest of the solution. This is a hard requirement. -- **Node.js/TypeScript apps**: directories with `package.json` containing a `start`, `dev`, or `main`/`module` entry +- **Node.js/TypeScript apps**: directories with `package.json` containing a `start`, `dev`, or `main`/`module` entry. For each, also check: + - Does it have a `vite.config.*` file? → use `AddViteApp` + - Does it have a specific entry file (e.g., `src/index.ts`, `server.js`) and a `build` script that compiles TypeScript? → use `AddNodeApp` with `.WithRunScript()` and `.WithBuildScript()` + - Otherwise → use `AddJavaScriptApp` +- **Monorepo/workspace detection**: Check root `package.json` for `"workspaces"` field (Yarn/npm) or `pnpm-workspace.yaml` (pnpm). If this is a monorepo: + - **Map workspace packages** — each workspace with a runnable script (`start`, `dev`) is a potential Aspire resource + - **Root scripts that delegate** — some monorepos have root-level scripts like `"start": "yarn --cwd ./subdir start"`. Model the *actual app directory* as the resource, not the root + - **Path resolution** — `appDirectory` is relative to the AppHost location. In monorepos you often need `../`, `../../`, or similar paths. Double-check these + - **Shared dependencies** — `.WithYarn()` / `.WithPnpm()` on each resource handles workspace-aware installs automatically - **Python apps**: directories with `pyproject.toml`, `requirements.txt`, or `main.py`/`app.py` - **Go apps**: directories with `go.mod` - **Java apps**: directories with `pom.xml` or `build.gradle` @@ -271,16 +335,22 @@ import { createBuilder } from './.modules/aspire.js'; const builder = await createBuilder(); -// Node.js/TypeScript app — HTTPS with dev cert +// Express/Node.js API with TypeScript — needs build for publish const api = await builder - .addNodeApp("api", "./api", "src/index.ts") + .addNodeApp("api", "./api", "dist/index.js") // production entry point + .withRunScript("start:dev") // dev: runs ts-node-dev or similar + .withBuildScript("build") // publish: compiles TS first + .withYarn() // or .withPnpm() — match the repo .withHttpsDeveloperCertificate() .withHttpsEndpoint({ env: "PORT" }); -// Vite frontend — HTTPS with dev cert +// Vite frontend — HTTPS with dev cert, suppress auto-browser const frontend = await builder .addViteApp("frontend", "./frontend") + .withBuildScript("build") + .withYarn() .withHttpsDeveloperCertificate() + .withEnvironment("BROWSER", "none") // prevent auto-opening browser .withReference(api) .waitFor(api); @@ -645,7 +715,7 @@ Once the app is running, use the Aspire CLI to verify everything is wired up cor 2. **Environment flows correctly**: `aspire describe` — check that environment variables (connection strings, ports, secrets from parameters) are injected into each resource as expected. Verify `.env` values that were migrated to parameters are present. 3. **OTel is flowing** (if configured in Step 7): `aspire otel` — verify that services instrumented with OpenTelemetry are exporting traces and metrics to the Aspire dashboard collector. 4. **No startup errors**: `aspire logs ` — check logs for each resource to ensure clean startup with no crashes, missing config, or connection failures. -5. **Dashboard is accessible**: Confirm the dashboard URL is printed and can be opened. +5. **Dashboard is accessible**: Confirm the dashboard URL (including the login token) is printed and can be opened. The full URL looks like `http://localhost:18888/login?t=` — always include the token. **This skill is not done until `aspire start` runs without errors and all resources are healthy.** If anything fails, diagnose, fix, and run `aspire start` again. Keep iterating until it works — do not move on to Step 10 with a broken app. @@ -834,6 +904,33 @@ var frontend = builder.AddViteApp("frontend", "../frontend") Aspire assigns a port and injects it as the specified environment variable. The service should read it and listen on that port. +### Cross-service environment variable wiring + +When a service expects a **specific env var name** for a dependency's URL (not the standard `services__` format from `WithReference`), use `WithEnvironment` with an endpoint reference: + +```typescript +// Get the endpoint from the dependency +const roomEndpoint = await room.getEndpoint("http"); + +// Pass it as the specific env var the consuming app expects +const frontend = await builder + .addViteApp("frontend", "./frontend") + .withEnvironment("VITE_APP_WS_SERVER_URL", roomEndpoint) // EndpointReference accepted + .withReference(room) // also sets up standard service discovery + .waitFor(room); +``` + +```csharp +// C# equivalent +var roomEndpoint = room.GetEndpoint("http"); +var frontend = builder.AddViteApp("frontend", "../frontend") + .WithEnvironment("VITE_APP_WS_SERVER_URL", roomEndpoint) + .WithReference(room) + .WaitFor(room); +``` + +Use `WithEnvironment(name, endpointRef)` when the consuming service reads a **specific env var name**. Use `WithReference()` when the service uses Aspire service discovery or standard connection string patterns. You can use both together. + ### URL labels and dashboard niceties Customize how endpoints appear in the Aspire dashboard: From b5d173bfaa35e219eaa02128a83d215c21ce013f Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 15:25:28 -0400 Subject: [PATCH 25/48] Fix init flow, profiles, ASPNETCORE_URLS, and CI issues - Fix init skill flow: remove early skill install, let agent init prompt handle it naturally; print closing message to invoke agent - Write profiles section to aspire.config.json with random ports matching aspire new templates (both https and http profiles) - Add fallback defaults for ASPNETCORE_URLS, OTLP, and resource service in GuestAppHostProject when no profile provides them - Add all polyglot skeleton templates (Python, Go, Java, Rust) - Fix markdown linter errors (MD040, MD012) in eval READMEs - Isolate eval playground from repo CPM with Directory.Build.props/targets and Directory.Packages.props Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 21 +-- .../aspirify-eval/Directory.Build.props | 11 ++ .../aspirify-eval/Directory.Build.targets | 3 + .../aspirify-eval/Directory.Packages.props | 6 + .../dotnet-traditional/README.md | 3 +- .../src/AdminDashboard/AdminDashboard.csproj | 4 +- .../src/BoardApi/BoardApi.csproj | 6 +- .../src/BoardData/BoardData.csproj | 4 +- .../MigrationRunner/MigrationRunner.csproj | 4 +- playground/aspirify-eval/polyglot/README.md | 3 +- src/Aspire.Cli/Commands/InitCommand.cs | 159 +++++++++++------- .../Projects/GuestAppHostProject.cs | 18 ++ 12 files changed, 160 insertions(+), 82 deletions(-) create mode 100644 playground/aspirify-eval/Directory.Build.props create mode 100644 playground/aspirify-eval/Directory.Build.targets create mode 100644 playground/aspirify-eval/Directory.Packages.props diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index c2422d7897d..86a0a916c6c 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -95,31 +95,28 @@ The `Aspire.Hosting.JavaScript` package provides three resource types. Pick the |--------|-----|---------| | Vite app (has `vite.config.*`) | `AddViteApp(name, dir)` | Frontend SPA, Vite + React/Vue/Svelte | | App runs via package.json script only | `AddJavaScriptApp(name, dir, { runScriptName })` | CRA app, Next.js, monorepo root scripts | -| App has a specific Node entry file (`.js`/`.ts`) | `AddNodeApp(name, dir, "path/to/entry.js")` | Express/Fastify API, Socket.IO server | -| App needs different scripts for dev vs publish | `AddNodeApp(...)` + `.WithRunScript("dev")` + `.WithBuildScript("build")` | TypeScript server compiled to `dist/` | +| App has a specific Node entry file (`.js`/`.ts`) and uses a dev script like `ts-node-dev` | `AddNodeApp(name, dir, "entry.js")` + `.WithRunScript("start:dev")` | Express/Fastify API, Socket.IO server | **Key distinctions:** -- `AddNodeApp` separates the **production entry point** (`dist/index.js`) from the **dev script** (`WithRunScript("start:dev")`), which is critical for `aspire publish` to work correctly. -- `AddJavaScriptApp` runs the same npm/yarn script in all modes — simpler but no dev/prod distinction. +- `AddNodeApp` is for apps that run a **specific file** with Node (e.g., an Express server at `src/index.ts`). Use `.WithRunScript("start:dev")` to override the dev-time command (e.g., `ts-node-dev`). +- `AddJavaScriptApp` runs a **package.json script** — simpler, good when the script handles everything. - `AddViteApp` is `AddJavaScriptApp` with Vite-specific defaults (auto-HTTPS config augmentation, `dev` as default script). -**Always add `.WithBuildScript("build")` when the app has a TypeScript compilation or bundling step** — without it, `aspire publish` will fail because there's nothing to produce production assets. +### JavaScript dev scripts -### Dev vs publish scripts for JavaScript apps +Use `.WithRunScript()` to control which package.json script runs during development: ```typescript -// Express API with TypeScript: runs ts-node-dev in dev, compiled JS in prod +// Express API with TypeScript: uses ts-node-dev for hot reload in dev const api = await builder - .addNodeApp("api", "./api", "dist/index.js") // production entry point - .withRunScript("start:dev") // dev: runs "npm run start:dev" - .withBuildScript("build") // publish: runs "npm run build" first + .addNodeApp("api", "./api", "src/index.ts") + .withRunScript("start:dev") // runs "yarn start:dev" (ts-node-dev) .withYarn() .withHttpEndpoint({ env: "PORT" }); -// Vite frontend: vite dev server in dev, built assets in prod +// Vite frontend: default "dev" script is fine, just add yarn const web = await builder .addViteApp("web", "./frontend") - .withBuildScript("build") // publish: runs "npm run build" .withYarn(); ``` diff --git a/playground/aspirify-eval/Directory.Build.props b/playground/aspirify-eval/Directory.Build.props new file mode 100644 index 00000000000..5296510ee57 --- /dev/null +++ b/playground/aspirify-eval/Directory.Build.props @@ -0,0 +1,11 @@ + + + + + false + false + false + + diff --git a/playground/aspirify-eval/Directory.Build.targets b/playground/aspirify-eval/Directory.Build.targets new file mode 100644 index 00000000000..587bed9d226 --- /dev/null +++ b/playground/aspirify-eval/Directory.Build.targets @@ -0,0 +1,3 @@ + + + diff --git a/playground/aspirify-eval/Directory.Packages.props b/playground/aspirify-eval/Directory.Packages.props new file mode 100644 index 00000000000..bbae0f2e46d --- /dev/null +++ b/playground/aspirify-eval/Directory.Packages.props @@ -0,0 +1,6 @@ + + + + false + + diff --git a/playground/aspirify-eval/dotnet-traditional/README.md b/playground/aspirify-eval/dotnet-traditional/README.md index 48e466ae986..7e0e917d1b7 100644 --- a/playground/aspirify-eval/dotnet-traditional/README.md +++ b/playground/aspirify-eval/dotnet-traditional/README.md @@ -4,7 +4,7 @@ A traditional .NET LOB app with a Vue frontend. This app is **intentionally not ## Architecture -``` +```text frontend/ → Vue 3 + Vite (port 5173), proxies /api/* to BoardApi src/BoardApi/ → ASP.NET minimal API (port 5220), EF Core + Postgres, Redis caching src/AdminDashboard/→ Blazor Server (port 5230), shares DB with BoardApi @@ -104,4 +104,3 @@ npm run dev - **API items**: http://localhost:5220/api/items — should return seeded board items - **Admin stats**: http://localhost:5230/admin/stats — should return item/user counts - **Cached count**: http://localhost:5220/api/cached-count — tests Redis connectivity - diff --git a/playground/aspirify-eval/dotnet-traditional/src/AdminDashboard/AdminDashboard.csproj b/playground/aspirify-eval/dotnet-traditional/src/AdminDashboard/AdminDashboard.csproj index 8cfba4f39e1..abf6978b85e 100644 --- a/playground/aspirify-eval/dotnet-traditional/src/AdminDashboard/AdminDashboard.csproj +++ b/playground/aspirify-eval/dotnet-traditional/src/AdminDashboard/AdminDashboard.csproj @@ -8,7 +8,7 @@ - - + + diff --git a/playground/aspirify-eval/dotnet-traditional/src/BoardApi/BoardApi.csproj b/playground/aspirify-eval/dotnet-traditional/src/BoardApi/BoardApi.csproj index 1a83c7ef8ec..ee8a8c81d02 100644 --- a/playground/aspirify-eval/dotnet-traditional/src/BoardApi/BoardApi.csproj +++ b/playground/aspirify-eval/dotnet-traditional/src/BoardApi/BoardApi.csproj @@ -8,8 +8,8 @@ - - - + + + diff --git a/playground/aspirify-eval/dotnet-traditional/src/BoardData/BoardData.csproj b/playground/aspirify-eval/dotnet-traditional/src/BoardData/BoardData.csproj index b149ea205c4..f31509aaafb 100644 --- a/playground/aspirify-eval/dotnet-traditional/src/BoardData/BoardData.csproj +++ b/playground/aspirify-eval/dotnet-traditional/src/BoardData/BoardData.csproj @@ -5,7 +5,7 @@ enable - - + + diff --git a/playground/aspirify-eval/dotnet-traditional/src/MigrationRunner/MigrationRunner.csproj b/playground/aspirify-eval/dotnet-traditional/src/MigrationRunner/MigrationRunner.csproj index d42851dff8e..dfff98bf113 100644 --- a/playground/aspirify-eval/dotnet-traditional/src/MigrationRunner/MigrationRunner.csproj +++ b/playground/aspirify-eval/dotnet-traditional/src/MigrationRunner/MigrationRunner.csproj @@ -8,7 +8,7 @@ - - + + diff --git a/playground/aspirify-eval/polyglot/README.md b/playground/aspirify-eval/polyglot/README.md index 3df696bb415..e1eac72c587 100644 --- a/playground/aspirify-eval/polyglot/README.md +++ b/playground/aspirify-eval/polyglot/README.md @@ -4,7 +4,7 @@ A polyglot microservices app with Python, Go, C#, and React. This app is **inten ## Architecture -``` +```text api-weather/ → Python FastAPI (port 8001), weather data with Redis caching api-geo/ → Go stdlib HTTP (port 8002), geocoding stub with external API key api-events/ → C# minimal API (port 8003), city events endpoint @@ -106,4 +106,3 @@ npm run dev - **Events API**: http://localhost:8003/events — should return all events - **Events by city**: http://localhost:8003/events/seattle — should return Seattle events - **Health checks**: each service has `/health` returning its name and status - diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index c5091efb490..629c511f0e6 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -5,7 +5,6 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; -using Aspire.Cli.Agents; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.Projects; @@ -87,22 +86,17 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return dropResult; } - // Step 4: Install the appropriate init skill. - var initSkill = SkillDefinition.AspireInit; - var skillInstalled = await InstallInitSkillAsync(workingDirectory, initSkill, cancellationToken); - if (!skillInstalled) - { - InteractionService.DisplayError("Failed to install init skill."); - return ExitCodeConstants.FailedToCreateNewProject; - } + // Step 4: Chain to aspire agent init for MCP server + skill configuration. + // This prompt lets users choose which skills to install — including aspire-init. + var workspaceRoot = solutionFile?.Directory ?? workingDirectory; + var agentInitResult = await _agentInitCommand.PromptAndChainAsync(_hostEnvironment, InteractionService, ExitCodeConstants.Success, workspaceRoot, cancellationToken); + // Step 5: Print closing message. InteractionService.DisplayEmptyLine(); - InteractionService.DisplayMessage(KnownEmojis.Sparkles, $"Init skill '{initSkill.Name}' installed. Ask your agent to run it to complete setup."); - InteractionService.DisplayEmptyLine(); + InteractionService.DisplayMessage(KnownEmojis.Sparkles, "Aspire skeleton created! Open your agent and ask it to complete setup."); + InteractionService.DisplaySubtleMessage("The aspire-init skill will guide your agent through wiring up projects, dependencies, and validation."); - // Step 5: Chain to aspire agent init for MCP server + evergreen skill configuration. - var workspaceRoot = solutionFile?.Directory ?? workingDirectory; - return await _agentInitCommand.PromptAndChainAsync(_hostEnvironment, InteractionService, ExitCodeConstants.Success, workspaceRoot, cancellationToken); + return agentInitResult; } private async Task DropCSharpSkeletonAsync(DirectoryInfo workingDirectory, FileInfo? solutionFile, CancellationToken cancellationToken) @@ -205,10 +199,64 @@ private Task DropPolyglotSkeletonAsync(string languageId, DirectoryInfo wor { _ = cancellationToken; - // Determine the apphost filename based on language - var (appHostFileName, languageConfigValue) = languageId switch + // Determine the apphost filename and skeleton content based on language + var (appHostFileName, languageConfigValue, appHostContent) = languageId switch { - KnownLanguageId.TypeScript => ("apphost.ts", "typescript/nodejs"), + KnownLanguageId.TypeScript => ("apphost.ts", "typescript/nodejs", """ + import { createBuilder } from './.modules/aspire.js'; + + const builder = await createBuilder(); + + // The aspire-init skill will wire up your projects here. + + await builder.build().run(); + """), + KnownLanguageId.Python => ("apphost.py", "python", """ + import aspire + + builder = aspire.create_builder() + + # The aspire-init skill will wire up your projects here. + + builder.build().run() + """), + KnownLanguageId.Go => ("apphost.go", "go", """ + package main + + import "aspire" + + func main() { + builder := aspire.CreateBuilder() + + // The aspire-init skill will wire up your projects here. + + builder.Build().Run() + } + """), + KnownLanguageId.Java => ("AppHost.java", "java", """ + import com.microsoft.aspire.*; + + public class AppHost { + public static void main(String[] args) { + var builder = Aspire.createBuilder(args); + + // The aspire-init skill will wire up your projects here. + + builder.build().run(); + } + } + """), + KnownLanguageId.Rust => ("apphost.rs", "rust", """ + use aspire::*; + + fn main() { + let builder = create_builder(); + + // The aspire-init skill will wire up your projects here. + + builder.build().run(); + } + """), _ => throw new NotSupportedException($"Polyglot skeleton not yet supported for language: {languageId}") }; @@ -219,16 +267,6 @@ private Task DropPolyglotSkeletonAsync(string languageId, DirectoryInfo wor return Task.FromResult(ExitCodeConstants.Success); } - // Drop bare apphost.ts - var appHostContent = """ - import { createBuilder } from './.modules/aspire.js'; - - const builder = await createBuilder(); - - // The aspire-init-typescript skill will wire up your projects here. - - await builder.build().run(); - """; File.WriteAllText(appHostPath, appHostContent); InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"Created {appHostFileName}"); @@ -275,41 +313,48 @@ private void DropAspireConfig(DirectoryInfo directory, string appHostPath, strin appHost["language"] = language; } - var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; - File.WriteAllText(configPath, settings.ToJsonString(jsonOptions)); - - var verb = isUpdate ? "Updated" : "Created"; - InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"{verb} {AspireConfigFile.FileName}"); - } - - private async Task InstallInitSkillAsync(DirectoryInfo workspaceRoot, SkillDefinition skill, CancellationToken cancellationToken) - { - // Install the init skill to the standard .agents/skills/ location (workspace level only). - var relativeSkillPath = Path.Combine(SkillLocation.Standard.RelativeSkillDirectory, skill.Name); - var fullSkillDir = Path.Combine(workspaceRoot.FullName, relativeSkillPath); - - try + // Write default profiles with random ports for dashboard/OTLP/resource service. + // Matches the profile structure used by `aspire new` templates (see Templates/*/aspire.config.json). + // Normally scaffolding + codegen creates these, but our thin init skips scaffolding. + if (settings["profiles"] is null) { - var skillFiles = await EmbeddedSkillResourceLoader.LoadTextFilesAsync(skill.EmbeddedResourceRoot!, cancellationToken); - - foreach (var skillFile in skillFiles) + // Port ranges match CliTemplateFactory.GenerateRandomPorts() + var httpPort = Random.Shared.Next(15000, 15300); + var httpsPort = Random.Shared.Next(17000, 17300); + var otlpHttpPort = Random.Shared.Next(19000, 19300); + var otlpHttpsPort = Random.Shared.Next(21000, 21300); + var resourceHttpPort = Random.Shared.Next(20000, 20300); + var resourceHttpsPort = Random.Shared.Next(22000, 22300); + + settings["profiles"] = new JsonObject { - var fullPath = Path.Combine(fullSkillDir, skillFile.RelativePath); - var fileDir = Path.GetDirectoryName(fullPath); - if (!string.IsNullOrEmpty(fileDir) && !Directory.Exists(fileDir)) + ["https"] = new JsonObject + { + ["applicationUrl"] = $"https://localhost:{httpsPort};http://localhost:{httpPort}", + ["environmentVariables"] = new JsonObject + { + ["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = $"https://localhost:{otlpHttpsPort}", + ["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = $"https://localhost:{resourceHttpsPort}" + } + }, + ["http"] = new JsonObject { - Directory.CreateDirectory(fileDir); + ["applicationUrl"] = $"http://localhost:{httpPort}", + ["environmentVariables"] = new JsonObject + { + ["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = $"http://localhost:{otlpHttpPort}", + ["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = $"http://localhost:{resourceHttpPort}", + ["ASPIRE_ALLOW_UNSECURED_TRANSPORT"] = "true" + } } + }; + } - await File.WriteAllTextAsync(fullPath, skillFile.Content, cancellationToken); - } + var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + File.WriteAllText(configPath, settings.ToJsonString(jsonOptions)); - return true; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) - { - InteractionService.DisplayError($"Failed to install skill '{skill.Name}': {ex.Message}"); - return false; - } + var verb = isUpdate ? "Updated" : "Created"; + InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"{verb} {AspireConfigFile.FileName}"); } + } diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 2fc509f3743..72a18795d15 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -600,6 +600,24 @@ internal Dictionary GetServerEnvironmentVariables(DirectoryInfo envVars["DOTNET_ENVIRONMENT"] = environment; envVars["ASPNETCORE_ENVIRONMENT"] = environment; + // Provide default dashboard/server URLs when no profile specifies them. + // The .NET apphost path (DotNetAppHostProject) hardcodes these defaults; + // guest apphosts need the same fallback so the dashboard can start. + if (!envVars.ContainsKey("ASPNETCORE_URLS")) + { + envVars["ASPNETCORE_URLS"] = "https://localhost:17193;http://localhost:15069"; + } + + if (!envVars.ContainsKey("ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL")) + { + envVars["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:21293"; + } + + if (!envVars.ContainsKey("ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL")) + { + envVars["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = "https://localhost:22086"; + } + return envVars; } From 89fb461169111b6aca46118a7acb9c78f7a13e8f Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 15:27:53 -0400 Subject: [PATCH 26/48] Add Step 2 smoke test: verify skeleton boots before wiring Run 'aspire start' right after scanning to catch config/profile issues early before investing time in project wiring. Renumber all subsequent steps accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 48 ++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 86a0a916c6c..6bb1e6dc45b 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -289,7 +289,25 @@ Analyze the repository to discover all projects and services that could be model - `node_modules/`, `.modules/`, `dist/`, `build/`, `bin/`, `obj/`, `.git/` - Test projects (directories named `test`/`tests`/`__tests__`, projects referencing xUnit/NUnit/MSTest, or test-only package.json scripts) -### Step 2: Present findings and confirm with the user +### Step 2: Smoke-test the skeleton + +Before investing time in wiring, verify that the Aspire skeleton boots correctly: + +```bash +aspire start +``` + +The empty AppHost should start successfully — the dashboard should come up and the process should run without errors. You won't see any resources yet (that's expected), but if `aspire start` fails here, the problem is in the generated `aspire.config.json` or the skeleton AppHost file. Fix the issue before proceeding. + +Common failures at this stage: + +- **Missing profiles in `aspire.config.json`**: The file must have a `profiles` section with `applicationUrl`. Re-run `aspire init` to regenerate it. +- **Missing dependencies**: For TypeScript, ensure `@aspect/aspire-hosting` or the `.modules/aspire.js` SDK is available. Run `aspire restore` if needed. +- **Port conflicts**: If another Aspire app is running, the randomly assigned ports may conflict. Stop other instances first. + +Once it boots, stop it (Ctrl+C) and continue. + +### Step 3: Present findings and confirm with the user Show the user what you found. For each discovered project/service, show: @@ -303,9 +321,9 @@ Ask the user: 1. Which projects to include in the AppHost (pre-select all discovered runnable services) 2. For C# AppHosts: which .NET projects should receive ServiceDefaults references (pre-select all .NET services) -### Step 3: Create ServiceDefaults (C# only) +### Step 4: Create ServiceDefaults (C# only) -> **Skip this step for TypeScript AppHosts.** OTel is handled in Step 7. +> **Skip this step for TypeScript AppHosts.** OTel is handled in Step 8. If no ServiceDefaults project exists in the repo, create one: @@ -321,7 +339,7 @@ dotnet sln add If a ServiceDefaults project already exists (look for references to `Microsoft.Extensions.ServiceDiscovery` or `Aspire.ServiceDefaults`), skip creation and use the existing one. -### Step 4: Wire up the AppHost +### Step 5: Wire up the AppHost Edit the skeleton AppHost file to add resource definitions for each selected project. Use the appropriate syntax based on language. @@ -448,7 +466,7 @@ Always check `aspire list integrations` and `aspire docs search ""` to - Wire up `WithReference()`/`withReference()` and `WaitFor()`/`waitFor()` for services that depend on each other (ask the user if relationships are unclear). - Use `WithExternalHttpEndpoints()`/`withExternalHttpEndpoints()` for user-facing frontends. -### Step 5: Configure dependencies +### Step 6: Configure dependencies #### TypeScript AppHost @@ -522,7 +540,7 @@ If no `tsconfig.json` exists and `aspire restore` didn't create one, create a mi **NuGet feeds**: If `aspire.config.json` specifies a non-stable channel (preview, daily), ensure the appropriate NuGet feed is configured. For single-file mode this is automatic; for project mode, ensure a `NuGet.config` is in scope. -### Step 6: Add ServiceDefaults to .NET projects (C# AppHost only) +### Step 7: Add ServiceDefaults to .NET projects (C# AppHost only) > **Skip this step for TypeScript AppHosts.** @@ -546,7 +564,7 @@ app.MapDefaultEndpoints(); Be careful with code placement — look at existing structure (top-level statements vs `Startup.cs` vs `Program.Main`). Do not duplicate if already present. -### Step 7: Wire up OpenTelemetry +### Step 8: Wire up OpenTelemetry OpenTelemetry makes your services' traces, metrics, and logs visible in the Aspire dashboard. For .NET services, ServiceDefaults handles this automatically. For everything else, the services need a small setup to export telemetry. Aspire automatically injects `OTEL_EXPORTER_OTLP_ENDPOINT` into all managed resources — the services just need to read it. @@ -666,7 +684,7 @@ java -javaagent:opentelemetry-javaagent.jar -jar myapp.jar The agent auto-instruments common frameworks. Aspire injects `OTEL_EXPORTER_OTLP_ENDPOINT` automatically. -### Step 8: Offer dev experience enhancements +### Step 9: Offer dev experience enhancements Before validating, present the user with optional quality-of-life improvements. These aren't required for `aspire start` to work, but they make the local dev experience significantly nicer. @@ -696,11 +714,11 @@ Before validating, present the user with optional quality-of-life improvements. .WithUrlForEndpoint("https", url => url.DisplayText = "Web UI") ``` -3. **OpenTelemetry** (if not done in Step 7): "Would you like me to add observability to your services so they appear in the Aspire dashboard's traces and metrics views?" +3. **OpenTelemetry** (if not done in Step 8): "Would you like me to add observability to your services so they appear in the Aspire dashboard's traces and metrics views?" Present these as a batch: "I have a few optional dev experience improvements I can make. Want to hear about them?" -### Step 9: Validate +### Step 10: Validate ```bash aspire start @@ -710,11 +728,11 @@ Once the app is running, use the Aspire CLI to verify everything is wired up cor 1. **Resources are modeled**: `aspire describe` — confirm all expected resources appear with correct types, endpoints, and states. 2. **Environment flows correctly**: `aspire describe` — check that environment variables (connection strings, ports, secrets from parameters) are injected into each resource as expected. Verify `.env` values that were migrated to parameters are present. -3. **OTel is flowing** (if configured in Step 7): `aspire otel` — verify that services instrumented with OpenTelemetry are exporting traces and metrics to the Aspire dashboard collector. +3. **OTel is flowing** (if configured in Step 8): `aspire otel` — verify that services instrumented with OpenTelemetry are exporting traces and metrics to the Aspire dashboard collector. 4. **No startup errors**: `aspire logs ` — check logs for each resource to ensure clean startup with no crashes, missing config, or connection failures. 5. **Dashboard is accessible**: Confirm the dashboard URL (including the login token) is printed and can be opened. The full URL looks like `http://localhost:18888/login?t=` — always include the token. -**This skill is not done until `aspire start` runs without errors and all resources are healthy.** If anything fails, diagnose, fix, and run `aspire start` again. Keep iterating until it works — do not move on to Step 10 with a broken app. +**This skill is not done until `aspire start` runs without errors and all resources are healthy.** If anything fails, diagnose, fix, and run `aspire start` again. Keep iterating until it works — do not move on to Step 11 with a broken app. Common issues: @@ -724,7 +742,7 @@ Common issues: - **Both**: missing environment variables, port conflicts - **Certificate errors**: if HTTPS fails, run `aspire certs trust` and retry -### Step 10: Update solution file (C# full project mode only) +### Step 11: Update solution file (C# full project mode only) If a `.sln`/`.slnx` exists, verify all new projects are included: @@ -734,7 +752,7 @@ dotnet sln list Ensure both the AppHost and ServiceDefaults projects appear. -### Step 11: Clean up +### Step 12: Clean up After successful validation: @@ -790,7 +808,7 @@ After adding, run `aspire restore` (TypeScript) or `dotnet restore` (C#) to upda ## AppHost wiring reference -This section covers the patterns you'll need when writing Step 4 (Wire up the AppHost). Refer back to it as needed. +This section covers the patterns you'll need when writing Step 5 (Wire up the AppHost). Refer back to it as needed. ### Service communication: `WithReference` vs `WithEnvironment` From 3165e261252451a6ae1fc8e2dd860897e89eae05 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 15:36:02 -0400 Subject: [PATCH 27/48] Fix dev.localhost guidance: use aspire.config.json profiles, not withUrlForEndpoint The correct way to enable dev.localhost subdomains is to update the applicationUrl in aspire.config.json profiles, not by calling withUrlForEndpoint in AppHost code. This matches how 'aspire new' handles it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 56 +++++++++++++++++------------ 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 6bb1e6dc45b..e9f0aa3b7eb 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -693,21 +693,31 @@ Before validating, present the user with optional quality-of-life improvements. 1. **Cookie and session isolation with `dev.localhost`**: When multiple services run on `localhost`, they share cookies and session storage — which can cause hard-to-debug auth problems. Using `*.dev.localhost` subdomains isolates each service's cookies and storage. Note: URLs still include ports (e.g., `frontend.dev.localhost:5173`), but the subdomain isolation prevents cross-service cookie collisions. > "Would you like me to set up `dev.localhost` subdomains for your services? This gives each service its own cookie/session scope so they don't interfere with each other. URLs will look like `frontend.dev.localhost:5173` — the `*.dev.localhost` domain resolves to 127.0.0.1 automatically on most systems, no `/etc/hosts` changes needed." - ```csharp - // C# - var frontend = builder.AddViteApp("frontend", "../frontend") - .WithHttpsDeveloperCertificate() - .WithHttpsEndpoint(env: "PORT") - .WithUrlForEndpoint("https", url => url.Host = "frontend.dev.localhost"); + **How to do it:** Update the `profiles` section in `aspire.config.json` — replace `localhost` with `.dev.localhost` in all URLs. This is the same mechanism `aspire new` uses. **Do NOT use `withUrlForEndpoint` in the AppHost for this** — the config file is the right place. + + ```json + { + "profiles": { + "https": { + "applicationUrl": "https://myproject.dev.localhost:17042;http://myproject.dev.localhost:15042", + "environmentVariables": { + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://myproject.dev.localhost:21042", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://myproject.dev.localhost:22042" + } + }, + "http": { + "applicationUrl": "http://myproject.dev.localhost:15042", + "environmentVariables": { + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://myproject.dev.localhost:19042", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://myproject.dev.localhost:20042", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + } + } + } ``` - ```typescript - // TypeScript - const frontend = builder.addViteApp("frontend", "../frontend") - .withHttpsDeveloperCertificate() - .withHttpsEndpoint({ env: "PORT" }) - .withUrlForEndpoint("https", url => { url.host = "frontend.dev.localhost"; }); - ``` + Use the project/repo name (lowercased) as the subdomain prefix. Keep the existing port numbers — just swap `localhost` for `.dev.localhost`. 2. **Custom URL labels in the dashboard**: Rename endpoint URLs in the Aspire dashboard for clarity: ```csharp @@ -957,22 +967,22 @@ var api = builder.AddCSharpApp("api", "../src/Api") .WithHttpsEndpoint(name: "internal", port: 8444); ``` -**Cookie/session isolation with `dev.localhost`**: When multiple services share `localhost`, cookies and session storage can leak between them. Using `*.dev.localhost` subdomains gives each service its own cookie scope. URLs still have ports (e.g., `frontend.dev.localhost:5173`), but the subdomain isolation prevents cross-service collisions: +**Cookie/session isolation with `dev.localhost`**: When multiple services share `localhost`, cookies and session storage can leak between them. Using `*.dev.localhost` subdomains gives each service its own cookie scope. URLs still have ports (e.g., `frontend.dev.localhost:5173`), but the subdomain isolation prevents cross-service collisions. -```csharp -var frontend = builder.AddViteApp("frontend", "../frontend") - .WithHttpsDeveloperCertificate() - .WithHttpsEndpoint(env: "PORT") - .WithUrlForEndpoint("https", url => url.Host = "frontend.dev.localhost"); +**The right way**: Update `applicationUrl` in the `profiles` section of `aspire.config.json` — replace `localhost` with `.dev.localhost`. Do NOT use `withUrlForEndpoint` in the AppHost for this. Example: -var api = builder.AddCSharpApp("api", "../src/Api") - .WithUrlForEndpoint("https", url => url.Host = "api.dev.localhost"); +```json +{ + "profiles": { + "https": { + "applicationUrl": "https://myapp.dev.localhost:17042;http://myapp.dev.localhost:15042" + } + } +} ``` > Note: `*.dev.localhost` resolves to `127.0.0.1` on most systems without any `/etc/hosts` changes. -Use `aspire docs search "url for endpoint"` to check the latest API shape if unsure. - ### Dependency ordering: `WaitFor` and `WaitForCompletion` **`WaitFor()`** — delay starting a resource until another resource is healthy/ready: From 9ebfdef9ef5b7393f9cd5fdee8aaab5359b9b58c Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 15:48:25 -0400 Subject: [PATCH 28/48] Fix aspire.config.json message: always say 'Created' during init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The language selection step writes aspire.config.json first, so DropAspireConfig was saying 'Updated' even on fresh repos. From the user's perspective this is all one init flow — say 'Created'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/InitCommand.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 629c511f0e6..b0c9b6273d4 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -281,7 +281,6 @@ private void DropAspireConfig(DirectoryInfo directory, string appHostPath, strin var configPath = Path.Combine(directory.FullName, AspireConfigFile.FileName); JsonObject settings; - var isUpdate = false; if (File.Exists(configPath)) { @@ -290,7 +289,6 @@ private void DropAspireConfig(DirectoryInfo directory, string appHostPath, strin settings = string.IsNullOrWhiteSpace(existingContent) ? new JsonObject() : JsonNode.Parse(existingContent)?.AsObject() ?? new JsonObject(); - isUpdate = true; } else { @@ -353,8 +351,7 @@ private void DropAspireConfig(DirectoryInfo directory, string appHostPath, strin var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; File.WriteAllText(configPath, settings.ToJsonString(jsonOptions)); - var verb = isUpdate ? "Updated" : "Created"; - InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"{verb} {AspireConfigFile.FileName}"); + InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"Created {AspireConfigFile.FileName}"); } } From cf4a67653f0001480a7d44c673386bd456c06183 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 15:49:37 -0400 Subject: [PATCH 29/48] Improve init closing message with one-shot agent commands Say 'AppHost created' instead of 'skeleton created', and print copy-pasteable commands for GitHub Copilot, Claude Code, and OpenCode to invoke the aspire-init skill. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/InitCommand.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index b0c9b6273d4..1b0b78ae166 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -91,10 +91,11 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var workspaceRoot = solutionFile?.Directory ?? workingDirectory; var agentInitResult = await _agentInitCommand.PromptAndChainAsync(_hostEnvironment, InteractionService, ExitCodeConstants.Success, workspaceRoot, cancellationToken); - // Step 5: Print closing message. + // Step 5: Print closing message with one-shot agent command. InteractionService.DisplayEmptyLine(); - InteractionService.DisplayMessage(KnownEmojis.Sparkles, "Aspire skeleton created! Open your agent and ask it to complete setup."); - InteractionService.DisplaySubtleMessage("The aspire-init skill will guide your agent through wiring up projects, dependencies, and validation."); + InteractionService.DisplayMessage(KnownEmojis.Sparkles, "Aspire AppHost created! To complete setup, run:"); + InteractionService.DisplayEmptyLine(); + InteractionService.DisplaySubtleMessage(" \"run the aspire-init skill\""); return agentInitResult; } From 003af46d21ee1a36a6bdf48776fd96c1c0612736 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 16:02:51 -0400 Subject: [PATCH 30/48] Add summary output to skill validation step After validation passes, the agent prints a summary with the full dashboard URL (including auth token) and resource health status from aspire describe. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index e9f0aa3b7eb..1035f7064e8 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -744,6 +744,23 @@ Once the app is running, use the Aspire CLI to verify everything is wired up cor **This skill is not done until `aspire start` runs without errors and all resources are healthy.** If anything fails, diagnose, fix, and run `aspire start` again. Keep iterating until it works — do not move on to Step 11 with a broken app. +Once everything is healthy, print a summary for the user: + +``` +✅ Aspire init complete! + +Dashboard: + +Resources: + + + ... + + +``` + +Get the dashboard URL from `aspire start` output (always include the `?t=` parameter). Get resource status from `aspire describe`. This summary is the user's confirmation that init worked — make it complete and accurate. + Common issues: - **TypeScript**: missing dependency install, TS compilation errors, port conflicts From a10ccedec83ac8e2e0e0d635ab5dc44f8a61b88f Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 16:04:11 -0400 Subject: [PATCH 31/48] Leave AppHost running after init completes The agent should hand the user a fully running app with the dashboard open, not stop it during cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 1035f7064e8..16b4caf473a 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -783,7 +783,7 @@ Ensure both the AppHost and ServiceDefaults projects appear. After successful validation: -1. Stop the running AppHost: `aspire stop` +1. **Leave the AppHost running** — the user gets a fully running app with the dashboard open. Do not call `aspire stop`. 2. **Delete this skill** — remove the `aspire-init/` skill directory from all locations where it was installed (check `.agents/skills/`, `.github/skills/`, `.claude/skills/`) 3. Confirm the evergreen `aspire` skill is present for ongoing AppHost work From fcb5e09d44991c392234bf1a64a368c4178e1051 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 16:12:59 -0400 Subject: [PATCH 32/48] Strengthen withUrlForEndpoint and hardcoded URL prohibitions in skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'Never hardcode URLs' and 'Never use withUrlForEndpoint for dev.localhost' to Key Rules section - Add new 'Never hardcode URLs — use endpoint references' guiding principle with ✅/❌ code examples - Update Step 9 dev.localhost guidance with ⚠️ callout and real-world aspire.config.json example (separate otlp/resources subdomains) - Clarify withUrlForEndpoint is ONLY for DisplayText, never for url.Url - Add 'NEVER DO THIS' anti-pattern to cross-service env var wiring - Remove http-only profile from dev.localhost example (match a3 pattern) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 60 ++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 16b4caf473a..5bee5a726ef 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -163,6 +163,23 @@ var api = builder.AddCSharpApp("api", "../src/Api"); > **Note**: These certificate APIs are experimental (`ASPIRECERTIFICATES001`). Use `aspire docs search "certificate configuration"` to check the latest API shape. If `WithHttpsDeveloperCertificate` causes errors for a resource type, fall back to `WithHttpEndpoint()`. +### Never hardcode URLs — use endpoint references + +When a service needs another service's URL as an environment variable, **always** pass an endpoint reference — never a hardcoded string. Hardcoded URLs break whenever Aspire assigns different ports. + +```typescript +// ✅ CORRECT — endpoint reference, Aspire resolves the actual URL at runtime +const roomEndpoint = await room.getEndpoint("http"); +builder.addViteApp("frontend", "./frontend") + .withEnvironment("VITE_APP_WS_SERVER_URL", roomEndpoint); + +// ❌ WRONG — hardcoded URL, breaks when ports change +builder.addViteApp("frontend", "./frontend") + .withEnvironment("VITE_APP_WS_SERVER_URL", "http://localhost:3002"); +``` + +Similarly, **never use `withUrlForEndpoint` / `WithUrlForEndpoint` to set `dev.localhost` URLs**. That API is ONLY for setting display labels in the dashboard (e.g., `url.DisplayText = "Web UI"`). `dev.localhost` configuration belongs in `aspire.config.json` profiles — see Step 9. + ### Optimize for local dev, not deployment This skill is about getting a great **local development experience**. Don't worry about production deployment manifests, cloud provisioning, or publish configuration — that's a separate concern for later. @@ -693,7 +710,11 @@ Before validating, present the user with optional quality-of-life improvements. 1. **Cookie and session isolation with `dev.localhost`**: When multiple services run on `localhost`, they share cookies and session storage — which can cause hard-to-debug auth problems. Using `*.dev.localhost` subdomains isolates each service's cookies and storage. Note: URLs still include ports (e.g., `frontend.dev.localhost:5173`), but the subdomain isolation prevents cross-service cookie collisions. > "Would you like me to set up `dev.localhost` subdomains for your services? This gives each service its own cookie/session scope so they don't interfere with each other. URLs will look like `frontend.dev.localhost:5173` — the `*.dev.localhost` domain resolves to 127.0.0.1 automatically on most systems, no `/etc/hosts` changes needed." - **How to do it:** Update the `profiles` section in `aspire.config.json` — replace `localhost` with `.dev.localhost` in all URLs. This is the same mechanism `aspire new` uses. **Do NOT use `withUrlForEndpoint` in the AppHost for this** — the config file is the right place. + **How to do it:** Update the `profiles` section in `aspire.config.json` — replace `localhost` with `.dev.localhost` in `applicationUrl`, and use descriptive subdomains like `otlp.dev.localhost` and `resources.dev.localhost` for the infrastructure URLs. This is the same mechanism `aspire new` uses. + + > ⚠️ **Do NOT use `withUrlForEndpoint` / `WithUrlForEndpoint` in the AppHost for `dev.localhost`** — the config file is the right place. `withUrlForEndpoint` is ONLY for dashboard display labels. + + Real-world example: ```json { @@ -701,28 +722,21 @@ Before validating, present the user with optional quality-of-life improvements. "https": { "applicationUrl": "https://myproject.dev.localhost:17042;http://myproject.dev.localhost:15042", "environmentVariables": { - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://myproject.dev.localhost:21042", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://myproject.dev.localhost:22042" - } - }, - "http": { - "applicationUrl": "http://myproject.dev.localhost:15042", - "environmentVariables": { - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://myproject.dev.localhost:19042", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://myproject.dev.localhost:20042", - "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://otlp.dev.localhost:21042", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://resources.dev.localhost:22042" } } } } ``` - Use the project/repo name (lowercased) as the subdomain prefix. Keep the existing port numbers — just swap `localhost` for `.dev.localhost`. + Use the project/repo name (lowercased) as the subdomain prefix for `applicationUrl`. Use `otlp` and `resources` for the infrastructure URLs. Keep the existing port numbers — just swap `localhost` for the appropriate `*.dev.localhost` subdomain. -2. **Custom URL labels in the dashboard**: Rename endpoint URLs in the Aspire dashboard for clarity: +2. **Custom URL labels in the dashboard** (display text only): Rename endpoint URLs in the Aspire dashboard for clarity. This is the ONLY valid use of `withUrlForEndpoint` — setting `DisplayText`, nothing else: ```csharp .WithUrlForEndpoint("https", url => url.DisplayText = "Web UI") ``` + Never set `url.Url` in this callback — that's what `aspire.config.json` profiles are for. 3. **OpenTelemetry** (if not done in Step 8): "Would you like me to add observability to your services so they appear in the Aspire dashboard's traces and metrics views?" @@ -794,6 +808,8 @@ After successful validation: - **Respect existing project structure** — don't reorganize the repo - **This is a one-time skill** — delete it after successful init - **If stuck, use `aspire doctor`** to diagnose environment issues +- **Never hardcode URLs in `withEnvironment`** — when a service needs another service's URL (e.g., `VITE_APP_WS_SERVER_URL`), pass an endpoint reference, NOT a string literal. Use `room.getEndpoint("http")` (TS) or `room.GetEndpoint("http")` (C#) and pass that to `withEnvironment`. Hardcoded URLs break when ports change. +- **Never use `withUrlForEndpoint` to set `dev.localhost` URLs** — `dev.localhost` configuration belongs in `aspire.config.json` profiles, not in AppHost code. `withUrlForEndpoint` is ONLY for setting display labels (e.g., `url.DisplayText = "Web UI"`). ## Looking up APIs and integrations @@ -948,18 +964,20 @@ Aspire assigns a port and injects it as the specified environment variable. The ### Cross-service environment variable wiring -When a service expects a **specific env var name** for a dependency's URL (not the standard `services__` format from `WithReference`), use `WithEnvironment` with an endpoint reference: +When a service expects a **specific env var name** for a dependency's URL (not the standard `services__` format from `WithReference`), use `WithEnvironment` with an endpoint reference — **never a hardcoded string**: ```typescript -// Get the endpoint from the dependency +// ✅ CORRECT — endpoint reference resolves to the actual URL at runtime const roomEndpoint = await room.getEndpoint("http"); -// Pass it as the specific env var the consuming app expects const frontend = await builder .addViteApp("frontend", "./frontend") - .withEnvironment("VITE_APP_WS_SERVER_URL", roomEndpoint) // EndpointReference accepted + .withEnvironment("VITE_APP_WS_SERVER_URL", roomEndpoint) // EndpointReference, not a string .withReference(room) // also sets up standard service discovery .waitFor(room); + +// ❌ WRONG — hardcoded URL breaks when Aspire assigns different ports + .withEnvironment("VITE_APP_WS_SERVER_URL", "http://localhost:3002") // NEVER DO THIS ``` ```csharp @@ -986,13 +1004,17 @@ var api = builder.AddCSharpApp("api", "../src/Api") **Cookie/session isolation with `dev.localhost`**: When multiple services share `localhost`, cookies and session storage can leak between them. Using `*.dev.localhost` subdomains gives each service its own cookie scope. URLs still have ports (e.g., `frontend.dev.localhost:5173`), but the subdomain isolation prevents cross-service collisions. -**The right way**: Update `applicationUrl` in the `profiles` section of `aspire.config.json` — replace `localhost` with `.dev.localhost`. Do NOT use `withUrlForEndpoint` in the AppHost for this. Example: +**The right way**: Update `applicationUrl` in the `profiles` section of `aspire.config.json` — replace `localhost` with `.dev.localhost`, and use `otlp.dev.localhost` / `resources.dev.localhost` for infrastructure URLs. **Never** use `withUrlForEndpoint` to set `dev.localhost` URLs — that API is ONLY for dashboard display labels. Example: ```json { "profiles": { "https": { - "applicationUrl": "https://myapp.dev.localhost:17042;http://myapp.dev.localhost:15042" + "applicationUrl": "https://myapp.dev.localhost:17042;http://myapp.dev.localhost:15042", + "environmentVariables": { + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://otlp.dev.localhost:21042", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://resources.dev.localhost:22042" + } } } } From b313c8d8a500638d872015d7b4813e29c264ba77 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 16:33:45 -0400 Subject: [PATCH 33/48] Fix init skill follow-up commands Only print the aspire-init handoff when the user actually selects that one-time skill during agent init. Also switch the follow-up from the generic placeholder command to tool-specific commands based on the selected skill locations: - copilot -i for Copilot skill locations - claude with an initial prompt for Claude Code - opencode --prompt for OpenCode Thread the selected skill/location information back from AgentInitCommand so init can make the follow-up conditional. Adjust NewCommand for the updated return type and add tests for the new init messaging behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AgentInitCommand.cs | 23 +++-- src/Aspire.Cli/Commands/InitCommand.cs | 51 ++++++++++-- src/Aspire.Cli/Commands/NewCommand.cs | 4 +- .../Commands/InitCommandTests.cs | 83 +++++++++++++++++++ 4 files changed, 146 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Cli/Commands/AgentInitCommand.cs b/src/Aspire.Cli/Commands/AgentInitCommand.cs index 848d3867aef..a194a46d925 100644 --- a/src/Aspire.Cli/Commands/AgentInitCommand.cs +++ b/src/Aspire.Cli/Commands/AgentInitCommand.cs @@ -75,7 +75,7 @@ internal Task ExecuteCommandAsync(ParseResult parseResult, CancellationToke /// Prompts the user to run agent init after a successful command, then chains into agent init if accepted. /// Used by commands (e.g. aspire init, aspire new) to offer agent init as a follow-up step. ///
- internal async Task PromptAndChainAsync( + internal async Task PromptAndChainAsync( ICliHostEnvironment hostEnvironment, IInteractionService interactionService, int previousResultExitCode, @@ -84,12 +84,12 @@ internal async Task PromptAndChainAsync( { if (previousResultExitCode != ExitCodeConstants.Success) { - return previousResultExitCode; + return new(previousResultExitCode, [], []); } if (!hostEnvironment.SupportsInteractiveInput) { - return ExitCodeConstants.Success; + return new(ExitCodeConstants.Success, [], []); } var runAgentInit = await interactionService.ConfirmAsync( @@ -102,13 +102,14 @@ internal async Task PromptAndChainAsync( return await ExecuteAgentInitAsync(workspaceRoot, cancellationToken); } - return ExitCodeConstants.Success; + return new(ExitCodeConstants.Success, [], []); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { var workspaceRoot = await PromptForWorkspaceRootAsync(cancellationToken); - return await ExecuteAgentInitAsync(workspaceRoot, cancellationToken); + var result = await ExecuteAgentInitAsync(workspaceRoot, cancellationToken); + return result.ExitCode; } private async Task PromptForWorkspaceRootAsync(CancellationToken cancellationToken) @@ -141,7 +142,7 @@ private async Task PromptForWorkspaceRootAsync(CancellationToken return new DirectoryInfo(workspaceRootPath); } - private async Task ExecuteAgentInitAsync(DirectoryInfo workspaceRoot, CancellationToken cancellationToken) + private async Task ExecuteAgentInitAsync(DirectoryInfo workspaceRoot, CancellationToken cancellationToken) { var context = new AgentEnvironmentScanContext { @@ -343,7 +344,10 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo workspaceRoot, Cance _interactionService.DisplaySuccess(McpCommandStrings.InitCommand_ConfigurationComplete); } - return hasErrors ? ExitCodeConstants.InvalidCommand : ExitCodeConstants.Success; + return new( + hasErrors ? ExitCodeConstants.InvalidCommand : ExitCodeConstants.Success, + selectedLocations, + selectedSkills); } /// @@ -423,3 +427,8 @@ private static async Task> GetSkillFilesAsync(Skil throw new InvalidOperationException($"Skill '{skill.Name}' does not define installable files."); } } + +internal readonly record struct AgentInitExecutionResult( + int ExitCode, + IReadOnlyList SelectedLocations, + IReadOnlyList SelectedSkills); diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 1b0b78ae166..e8471f0d6b2 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; +using Aspire.Cli.Agents; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.Projects; @@ -91,13 +92,51 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var workspaceRoot = solutionFile?.Directory ?? workingDirectory; var agentInitResult = await _agentInitCommand.PromptAndChainAsync(_hostEnvironment, InteractionService, ExitCodeConstants.Success, workspaceRoot, cancellationToken); - // Step 5: Print closing message with one-shot agent command. - InteractionService.DisplayEmptyLine(); - InteractionService.DisplayMessage(KnownEmojis.Sparkles, "Aspire AppHost created! To complete setup, run:"); - InteractionService.DisplayEmptyLine(); - InteractionService.DisplaySubtleMessage(" \"run the aspire-init skill\""); + // Step 5: Print follow-up commands only when the user selected the one-time init skill. + if (agentInitResult.ExitCode == ExitCodeConstants.Success && + agentInitResult.SelectedSkills.Contains(SkillDefinition.AspireInit)) + { + var commands = GetAspireInitCommands(agentInitResult.SelectedLocations); + if (commands.Count > 0) + { + InteractionService.DisplayEmptyLine(); + InteractionService.DisplayMessage( + KnownEmojis.Sparkles, + commands.Count == 1 + ? "Aspire AppHost created! To complete setup, run:" + : "Aspire AppHost created! To complete setup, run one of:"); + InteractionService.DisplayEmptyLine(); + + foreach (var command in commands) + { + InteractionService.DisplaySubtleMessage($" {command}"); + } + } + } + + return agentInitResult.ExitCode; + } + + private static IReadOnlyList GetAspireInitCommands(IReadOnlyList selectedLocations) + { + var commands = new List(); + + if (selectedLocations.Contains(SkillLocation.Standard) || selectedLocations.Contains(SkillLocation.GitHubSkills)) + { + commands.Add("""copilot -i "run the aspire-init skill" --yolo"""); + } + + if (selectedLocations.Contains(SkillLocation.ClaudeCode)) + { + commands.Add("claude \"run the aspire-init skill\""); + } + + if (selectedLocations.Contains(SkillLocation.OpenCode)) + { + commands.Add("opencode --prompt \"run the aspire-init skill\""); + } - return agentInitResult; + return commands; } private async Task DropCSharpSkeletonAsync(DirectoryInfo workingDirectory, FileInfo? solutionFile, CancellationToken cancellationToken) diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index e0987fb5610..fb93663b3f1 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -387,14 +387,14 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var templateResult = await template.ApplyTemplateAsync(inputs, parseResult, cancellationToken); var workspaceRoot = new DirectoryInfo(templateResult.OutputPath ?? ExecutionContext.WorkingDirectory.FullName); - var exitCode = await _agentInitCommand.PromptAndChainAsync(_hostEnvironment, InteractionService, templateResult.ExitCode, workspaceRoot, cancellationToken); + var agentInitResult = await _agentInitCommand.PromptAndChainAsync(_hostEnvironment, InteractionService, templateResult.ExitCode, workspaceRoot, cancellationToken); if (templateResult.OutputPath is not null && ExtensionHelper.IsExtensionHost(InteractionService, out var extensionInteractionService, out _)) { extensionInteractionService.OpenEditor(templateResult.OutputPath); } - return exitCode; + return agentInitResult.ExitCode; } private static bool ShouldResolveCliTemplateVersion(ITemplate template) diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index 53998e37f4f..f3b4f4114db 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using Aspire.Cli.Agents; using Aspire.Cli.Commands; using Aspire.Cli.Interaction; using Aspire.Cli.NuGet; @@ -650,6 +651,88 @@ public async Task InitCommand_WhenTypeScriptInitializationFails_DisplaysCreation Assert.Contains(expectedMessage, testInteractionService.DisplayedErrors); } + [Fact] + public async Task InitCommand_WhenAspireInitSkillSelected_PrintsToolSpecificFollowUpCommands() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var interactionService = new TestInteractionService + { + ConfirmCallback = (_, _) => true + }; + + var subtleMessages = new List(); + interactionService.DisplaySubtleMessageCallback = subtleMessages.Add; + interactionService.PromptForSelectionsCallback = (_, choices, _, _) => + { + var items = choices.Cast().ToList(); + + if (items.FirstOrDefault() is SkillLocation) + { + return [SkillLocation.Standard, SkillLocation.ClaudeCode, SkillLocation.OpenCode]; + } + + return [SkillDefinition.AspireInit]; + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + }); + + var serviceProvider = services.BuildServiceProvider(); + var initCommand = serviceProvider.GetRequiredService(); + + var parseResult = initCommand.Parse("init --language typescript"); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.Contains(interactionService.DisplayedMessages, m => m.Message == "Aspire AppHost created! To complete setup, run one of:"); + Assert.Contains(" copilot -i \"run the aspire-init skill\" --yolo", subtleMessages); + Assert.Contains(" claude \"run the aspire-init skill\"", subtleMessages); + Assert.Contains(" opencode --prompt \"run the aspire-init skill\"", subtleMessages); + } + + [Fact] + public async Task InitCommand_WhenAspireInitSkillNotSelected_DoesNotPrintFollowUpCommands() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var interactionService = new TestInteractionService + { + ConfirmCallback = (_, _) => true + }; + + var subtleMessages = new List(); + interactionService.DisplaySubtleMessageCallback = subtleMessages.Add; + interactionService.PromptForSelectionsCallback = (_, choices, _, _) => + { + var items = choices.Cast().ToList(); + + if (items.FirstOrDefault() is SkillLocation) + { + return [SkillLocation.Standard]; + } + + return [SkillDefinition.Aspire]; + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + }); + + var serviceProvider = services.BuildServiceProvider(); + var initCommand = serviceProvider.GetRequiredService(); + + var parseResult = initCommand.Parse("init --language typescript"); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.DoesNotContain(interactionService.DisplayedMessages, m => m.Message.Contains("To complete setup", StringComparison.Ordinal)); + Assert.DoesNotContain(subtleMessages, m => m.Contains("run the aspire-init skill", StringComparison.Ordinal)); + } + private sealed class TestPackagingServiceWithChannelTracking(Action onChannelUsed) : IPackagingService { public Task> GetChannelsAsync(CancellationToken cancellationToken = default) From 7d63fe00294f13646f005f62989ca7968ff0710a Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 17:43:33 -0400 Subject: [PATCH 34/48] Strengthen init skill port guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 5bee5a726ef..0a3240704da 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -14,7 +14,8 @@ This is a **one-time setup skill**. It completes the Aspire initialization that The default stance is **adapt the AppHost to fit the app, not the other way around**. The user's services already work — the goal is to model them in Aspire without breaking anything. - Prefer `WithEnvironment()` to match existing env var names over asking users to rename vars in their code -- Use `WithHttpsEndpoint(port: )` to match hardcoded ports rather than changing the service +- Prefer Aspire-managed ports (`WithHttpsEndpoint(env: "PORT")`, `WithHttpEndpoint(env: "PORT")`, or no explicit port when supported) over fixed ports +- Only preserve a specific port when the user confirms it is actually significant (for example: external callbacks, OAuth redirect URIs, browser extensions, webhooks, or a repo-documented hard requirement) - Map existing `docker-compose.yml` config 1:1 before optimizing - Don't restructure project directories, rename files, or change build scripts @@ -25,8 +26,8 @@ Sometimes a small code change unlocks significantly better Aspire integration. W - **Connection strings**: A service reads `DATABASE_URL` but Aspire injects `ConnectionStrings__mydb`. You can use `WithEnvironment("DATABASE_URL", db.Resource.ConnectionStringExpression)` (zero code change) or suggest the service reads from config so `WithReference(db)` just works (enables service discovery, health checks, auto-retry). → Ask: *"Your API reads DATABASE_URL. I can map that with WithEnvironment (no code change) or you could switch to reading ConnectionStrings:mydb which unlocks WithReference and automatic service discovery. Which do you prefer?"* -- **Port binding**: A service hardcodes `PORT=3000`. You can match it with `WithHttpsEndpoint(port: 3000)` (zero change) or suggest reading from env so Aspire can assign ports dynamically and avoid conflicts. - → Ask: *"Your frontend hardcodes port 3000. I can match that, but if you read PORT from env instead, Aspire can assign ports dynamically and avoid conflicts when running multiple services. Want me to make that change?"* +- **Port binding**: A service hardcodes `PORT=3000`. You can preserve that with `WithHttpsEndpoint(port: 3000)` (zero code change) or switch the service to read `PORT` from env so Aspire can manage ports dynamically and avoid conflicts. + → Ask: *"Your frontend is currently fixed to port 3000. Unless that exact port is important for something external, I recommend switching it to read PORT from env so Aspire can manage the port and avoid conflicts. If you need 3000 to stay stable, I can preserve it. Which do you want?"* - **OTel setup**: Service has its own tracing config pointing to Jaeger. You can leave it (Aspire won't show its traces) or suggest switching the exporter to read `OTEL_EXPORTER_OTLP_ENDPOINT` (which Aspire injects). → Ask: *"Your API exports traces to Jaeger directly. I can leave that, or switch it to use the OTEL_EXPORTER_OTLP_ENDPOINT env var so traces show up in the Aspire dashboard. The Jaeger endpoint would still work in non-Aspire environments. Want me to update it?"* @@ -40,7 +41,7 @@ Sometimes a small code change unlocks significantly better Aspire integration. W ### When in doubt, ask -If you're unsure whether something is a service, whether two services depend on each other, whether a port is significant, or whether a Docker Compose service should be modeled — ask. Don't guess at architectural intent. +If you're unsure whether something is a service, whether two services depend on each other, whether a port is truly significant, or whether a Docker Compose service should be modeled — ask. Don't guess at architectural intent. ### Always use latest Aspire APIs — verify before you write @@ -131,6 +132,8 @@ Not all frameworks read ports from env vars the same way: | Next.js | `PORT` env or `--port` | `.withHttpEndpoint({ env: "PORT" })` | | CRA | `PORT` env | `.withHttpEndpoint({ env: "PORT" })` | +When the framework supports reading the port from an env var or Aspire already handles it, **prefer that over pinning a fixed port**. Managed ports make repeated local runs more reliable and work better when multiple services or multiple Aspire apps are running. + **Suppress auto-browser-open:** Many dev servers (Vite, CRA, Next.js) auto-open a browser on start. Add `.withEnvironment("BROWSER", "none")` to prevent this in Aspire-managed apps. Vite also respects `server.open: false` in its config. ### Never call it ".NET Aspire" @@ -899,6 +902,16 @@ var api = builder.AddCSharpApp("api", "../src/Api") **Prefer HTTPS by default.** Use `WithHttpsEndpoint()` for all services and fall back to `WithHttpEndpoint()` only if HTTPS doesn't work for that resource. +**Prefer Aspire-managed ports by default.** For most local development scenarios, let Aspire assign the port and inject it into the service. This avoids port collisions, makes multiple AppHosts easier to run side-by-side, and keeps cross-service wiring flexible. + +**Ask before pinning a fixed port.** If the repo already uses a hardcoded port, do **not** silently preserve it just because it exists. Ask whether that port is actually required. Good reasons to keep a fixed port include: + +- OAuth/callback URLs or external webhooks that expect a stable local address +- Browser extensions or desktop/mobile clients that are already hardcoded to a specific port +- Repo docs, scripts, or test tooling that explicitly depend on that exact port + +If none of those apply, steer the user toward managed ports. + **`WithHttpsEndpoint()`** — expose an HTTPS endpoint. For services that serve traffic: ```csharp @@ -906,7 +919,7 @@ var api = builder.AddCSharpApp("api", "../src/Api") var api = builder.AddCSharpApp("api", "../src/Api") .WithHttpsEndpoint(); -// Use a specific port +// Use a specific port only when the user confirms it is required var api = builder.AddCSharpApp("api", "../src/Api") .WithHttpsEndpoint(port: 5001); @@ -962,6 +975,10 @@ var frontend = builder.AddViteApp("frontend", "../frontend") Aspire assigns a port and injects it as the specified environment variable. The service should read it and listen on that port. +**Recommended ask when a repo already hardcodes ports:** + +> "I found this service pinned to port 3000 today. Unless that exact port is needed for an external callback or another hard requirement, I recommend switching it to read PORT from env and letting Aspire manage the port. That avoids collisions and makes the AppHost more portable. Should I keep 3000 or make it Aspire-managed?" + ### Cross-service environment variable wiring When a service expects a **specific env var name** for a dependency's URL (not the standard `services__` format from `WithReference`), use `WithEnvironment` with an endpoint reference — **never a hardcoded string**: @@ -1120,4 +1137,3 @@ var db = builder.AddPostgres("pg") const db = await builder.addPostgres("pg") .withDataVolume("pg-data"); ``` - From c693ab7f73ed8d09b04b33d30c2b536e5438fdea Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 17:50:25 -0400 Subject: [PATCH 35/48] Clarify mixed SDK AppHost guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 0a3240704da..6502c3dce64 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -262,6 +262,35 @@ For C# AppHosts, there are two sub-modes: Check which mode you're in by looking at what exists at the `appHost.path` location. +### Mixed SDK repos: keep a `.csproj` AppHost on .NET 10 without changing .NET 8 services + +This guidance is specifically for **full project mode** C# AppHosts (the AppHost has its own `.csproj`). + +Some repos pin the root `global.json` to an older SDK such as .NET 8. A `.csproj`-based Aspire AppHost should still stay on the current Aspire-supported SDK (for example, .NET 10), while the existing service projects can remain on `net8.0`. + +**Do not downgrade the AppHost project to match the repo's root SDK pin.** Instead, create an SDK boundary around the AppHost: + +- Keep the repo root `global.json` unchanged +- Put the AppHost in its own directory +- Add a **nested `global.json` next to the AppHost** that pins the newer SDK +- Leave existing services targeting `net8.0` + +This works because the .NET 10 SDK can build and run `net8.0` projects just fine. + +**Important caveat:** if the repo's root solution is normally built from the repo root under SDK 8, do not assume that build can own a `net10.0` AppHost project. In mixed-SDK repos, prefer a **separate AppHost project folder** with a nested `global.json`, kept outside the repo's normal root-build path when necessary. + +If you use full project mode because a solution exists, be careful: adding a `net10.0` AppHost project to a root solution that is built under SDK 8 may break the repo's normal build. If that's likely, tell the user and prefer keeping the AppHost isolated rather than silently wiring it into the root solution. + +Example nested `global.json` beside the AppHost: + +```json +{ + "sdk": { + "version": "10.0.100" + } +} +``` + ## Workflow Follow these steps in order. If any step fails, diagnose and fix before continuing. **The goal is a working `aspire start` — keep going until every resource starts cleanly and the dashboard is accessible. Do not stop at partial success.** From e332ba9551e01f347cc07fe74954de6009b9269a Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 18:10:12 -0400 Subject: [PATCH 36/48] Fix solution-aware init scaffold Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/InitCommand.cs | 15 +- .../Commands/InitCommandTests.cs | 610 +----------------- 2 files changed, 36 insertions(+), 589 deletions(-) diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index e8471f0d6b2..a8eab2779a0 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -16,10 +16,11 @@ namespace Aspire.Cli.Commands; /// -/// Drops a skeleton AppHost and aspire.config.json, then installs the appropriate -/// init skill for an agent to complete the wiring. This is a thin launcher — the -/// heavy lifting (project discovery, dependency configuration, validation) is -/// delegated to the aspire-init-typescript or aspire-init-csharp skill. +/// Drops a skeleton AppHost and, when applicable, an aspire.config.json, then +/// installs the appropriate init skill for an agent to complete the wiring. This is a +/// thin launcher — the heavy lifting (project discovery, dependency configuration, +/// validation) is delegated to the aspire-init-typescript or +/// aspire-init-csharp skill. /// internal sealed class InitCommand : BaseCommand { @@ -76,7 +77,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell solutionFile = await _solutionLocator.FindSolutionFileAsync(workingDirectory, cancellationToken); } - // Step 3: Drop the skeleton AppHost + aspire.config.json. + // Step 3: Drop the skeleton AppHost and any related config files needed for that mode. var dropResult = isCSharp ? await DropCSharpSkeletonAsync(workingDirectory, solutionFile, cancellationToken) : await DropPolyglotSkeletonAsync(selectedProject.LanguageId, workingDirectory, cancellationToken); @@ -228,10 +229,6 @@ private Task DropCSharpProjectSkeletonAsync(FileInfo solutionFile, Cancella InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"Created {appHostDirName}/"); - // Drop aspire.config.json at solution root - var relativeAppHostPath = Path.Combine(appHostDirName, "apphost.cs"); - DropAspireConfig(solutionDir, relativeAppHostPath, language: null); - return Task.FromResult(ExitCodeConstants.Success); } diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index f3b4f4114db..b4a4a3092ab 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -1,15 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Globalization; +using System.Text.Json.Nodes; using Aspire.Cli.Agents; using Aspire.Cli.Commands; -using Aspire.Cli.Interaction; -using Aspire.Cli.NuGet; -using Aspire.Cli.Packaging; using Aspire.Cli.Projects; -using Aspire.Cli.Resources; -using Aspire.Cli.Scaffolding; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; @@ -23,12 +18,10 @@ public class InitCommandTests(ITestOutputHelper outputHelper) [InlineData("Test.csproj")] [InlineData("Test.fsproj")] [InlineData("Test.vbproj")] - public async Task InitCommand_WhenSolutionAndProjectInSameDirectory_ReturnsError(string projectFileName) + public async Task InitCommand_WhenSolutionAndProjectInSameDirectory_CreatesProjectModeAppHost(string projectFileName) { - // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); - // Create a solution file and a project file in the same directory var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); File.WriteAllText(solutionFile.FullName, "Fake solution file"); @@ -37,14 +30,12 @@ public async Task InitCommand_WhenSolutionAndProjectInSameDirectory_ReturnsError var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.DotNetCliRunnerFactory = (sp) => + options.DotNetCliRunnerFactory = _ => { var runner = new TestDotNetCliRunner(); - // GetSolutionProjectsAsync should not be called because the check - // happens before reading solution projects runner.GetSolutionProjectsAsyncCallback = (_, _, _) => { - throw new InvalidOperationException("GetSolutionProjectsAsync should not be called when solution and project are in the same directory."); + throw new InvalidOperationException("GetSolutionProjectsAsync should not be called by init."); }; return runner; }; @@ -53,568 +44,76 @@ public async Task InitCommand_WhenSolutionAndProjectInSameDirectory_ReturnsError var serviceProvider = services.BuildServiceProvider(); var initCommand = serviceProvider.GetRequiredService(); - // Act var parseResult = initCommand.Parse("init"); var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); - // Assert - Assert.Equal(ExitCodeConstants.FailedToCreateNewProject, exitCode); + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.AppHost", "apphost.cs"))); + Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.AppHost", "Test.AppHost.csproj"))); + Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"))); } [Fact] - public async Task InitCommand_WhenSolutionDirectoryHasNoProjectFiles_Proceeds() + public async Task InitCommand_WhenSolutionDirectoryHasNoProjectFiles_CreatesProjectModeAppHost() { - // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); - // Create a solution file only (no project files in the same directory) var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); File.WriteAllText(solutionFile.FullName, "Fake solution file"); - var getSolutionProjectsCalled = false; var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.DotNetCliRunnerFactory = (sp) => + options.DotNetCliRunnerFactory = _ => { var runner = new TestDotNetCliRunner(); runner.GetSolutionProjectsAsyncCallback = (_, _, _) => { - getSolutionProjectsCalled = true; - // Return success with no projects - the test verifies the check passed - return (0, Array.Empty()); - }; - runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) => - { - // Create the expected directories so the code can find them - var appHostDir = Path.Combine(outputPath, "Test.AppHost"); - var serviceDefaultsDir = Path.Combine(outputPath, "Test.ServiceDefaults"); - Directory.CreateDirectory(appHostDir); - Directory.CreateDirectory(serviceDefaultsDir); - File.WriteAllText(Path.Combine(appHostDir, "Test.AppHost.csproj"), ""); - File.WriteAllText(Path.Combine(serviceDefaultsDir, "Test.ServiceDefaults.csproj"), ""); - return 0; + throw new InvalidOperationException("GetSolutionProjectsAsync should not be called by init."); }; return runner; }; - options.PackagingServiceFactory = (sp) => - { - return new TestPackagingService(); - }; }); var serviceProvider = services.BuildServiceProvider(); var initCommand = serviceProvider.GetRequiredService(); - // Act var parseResult = initCommand.Parse("init"); var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); - // Assert - the command should have proceeded past the directory check and created projects - Assert.True(getSolutionProjectsCalled, "GetSolutionProjectsAsync should have been called when no project files are in the solution directory."); Assert.Equal(ExitCodeConstants.Success, exitCode); Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.AppHost", "Test.AppHost.csproj"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.ServiceDefaults", "Test.ServiceDefaults.csproj"))); - } - - [Fact] - public void InitContext_RequiredAppHostFramework_ReturnsHighestTfm() - { - // Arrange - var initContext = new InitContext(); - - // Act & Assert - No projects selected returns default - Assert.Equal("net9.0", initContext.RequiredAppHostFramework); - - // Set up projects with different TFMs - initContext.ExecutableProjectsToAddToAppHost = new List - { - new() { ProjectFile = new FileInfo("/test/project1.csproj"), TargetFramework = "net8.0" }, - new() { ProjectFile = new FileInfo("/test/project2.csproj"), TargetFramework = "net9.0" }, - new() { ProjectFile = new FileInfo("/test/project3.csproj"), TargetFramework = "net10.0" } - }; - - // Act - var result = initContext.RequiredAppHostFramework; - - // Assert - Assert.Equal("net10.0", result); - - // Test with only lower versions - initContext.ExecutableProjectsToAddToAppHost = new List - { - new() { ProjectFile = new FileInfo("/test/project1.csproj"), TargetFramework = "net8.0" }, - new() { ProjectFile = new FileInfo("/test/project2.csproj"), TargetFramework = "net9.0" } - }; - - result = initContext.RequiredAppHostFramework; - Assert.Equal("net9.0", result); - - // Test with only net8.0 - initContext.ExecutableProjectsToAddToAppHost = new List - { - new() { ProjectFile = new FileInfo("/test/project1.csproj"), TargetFramework = "net8.0" } - }; - - result = initContext.RequiredAppHostFramework; - Assert.Equal("net8.0", result); + Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.AppHost", "apphost.cs"))); + Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"))); } [Fact] - public async Task InitCommand_WhenGetSolutionProjectsFails_SetsOutputCollectorAndCallsCallbacks() + public async Task InitCommand_WhenNoSolutionExists_CreatesSingleFileAppHostAndAspireConfig() { - // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); - // Create a solution file to trigger InitializeExistingSolutionAsync path - var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); - File.WriteAllText(solutionFile.FullName, "Fake solution file"); - - const string testErrorMessage = "Test error from dotnet sln list"; - var standardOutputCallbackInvoked = false; - var standardErrorCallbackInvoked = false; - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - // Mock the runner to return an error when GetSolutionProjectsAsync is called - options.DotNetCliRunnerFactory = (sp) => - { - var runner = new TestDotNetCliRunner(); - - runner.GetSolutionProjectsAsyncCallback = (solutionFile, invocationOptions, cancellationToken) => - { - // Verify that the OutputCollector callbacks are wired up - Assert.NotNull(invocationOptions.StandardOutputCallback); - Assert.NotNull(invocationOptions.StandardErrorCallback); - - // Simulate calling the callbacks to verify they work - invocationOptions.StandardOutputCallback?.Invoke("Some output"); - standardOutputCallbackInvoked = true; - - invocationOptions.StandardErrorCallback?.Invoke(testErrorMessage); - standardErrorCallbackInvoked = true; - - // Return a non-zero exit code to trigger the error path - return (1, Array.Empty()); - }; - - return runner; - }; - }); - - var serviceProvider = services.BuildServiceProvider(); - var initCommand = serviceProvider.GetRequiredService(); - - // Act - Invoke init command - var parseResult = initCommand.Parse("init"); - var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); - - // Assert - Assert.Equal(1, exitCode); // Should return the error exit code - Assert.True(standardOutputCallbackInvoked, "StandardOutputCallback should have been invoked"); - Assert.True(standardErrorCallbackInvoked, "StandardErrorCallback should have been invoked"); - } - - [Fact] - public async Task InitCommand_WhenNewProjectFails_SetsOutputCollectorAndCallsCallbacks() - { - // Arrange - using var workspace = TemporaryWorkspace.Create(outputHelper); - - // Create a solution file to trigger InitializeExistingSolutionAsync path - var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); - File.WriteAllText(solutionFile.FullName, "Fake solution file"); - - const string testErrorMessage = "Test error from dotnet new"; - var standardOutputCallbackInvoked = false; - var standardErrorCallbackInvoked = false; - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - // Mock the runner - options.DotNetCliRunnerFactory = (sp) => - { - var runner = new TestDotNetCliRunner(); - - runner.GetSolutionProjectsAsyncCallback = (solutionFile, invocationOptions, cancellationToken) => - { - return (0, Array.Empty()); - }; - - runner.GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, invocationOptions, cancellationToken) => - { - return (0, null); - }; - - runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, cancellationToken) => - { - return (0, "10.0.0"); - }; - - runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, cancellationToken) => - { - // Verify that the OutputCollector callbacks are wired up - Assert.NotNull(invocationOptions.StandardOutputCallback); - Assert.NotNull(invocationOptions.StandardErrorCallback); - - // Simulate calling the callbacks to verify they work - invocationOptions.StandardOutputCallback?.Invoke("Some output"); - standardOutputCallbackInvoked = true; - - invocationOptions.StandardErrorCallback?.Invoke(testErrorMessage); - standardErrorCallbackInvoked = true; - - // Return a non-zero exit code to trigger the error path - return 1; - }; - - return runner; - }; - - options.InteractionServiceFactory = (sp) => - { - var interactionService = new TestInteractionService(); - return interactionService; - }; - - // Mock packaging service - options.PackagingServiceFactory = (sp) => - { - return new TestPackagingService(); - }; - }); - + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var serviceProvider = services.BuildServiceProvider(); var initCommand = serviceProvider.GetRequiredService(); - // Act - Invoke init command var parseResult = initCommand.Parse("init"); var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); - // Assert - Assert.Equal(1, exitCode); // Should return the error exit code - Assert.True(standardOutputCallbackInvoked, "StandardOutputCallback should have been invoked"); - Assert.True(standardErrorCallbackInvoked, "StandardErrorCallback should have been invoked"); - } - - [Fact] - public async Task InitCommand_WithSingleFileAppHost_DoesNotPromptForProjectNameOrOutputPath() - { - // Arrange - var promptedForProjectName = false; - var promptedForOutputPath = false; - - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - // Set up prompter to track if prompts are called - options.NewCommandPrompterFactory = (sp) => - { - var interactionService = sp.GetRequiredService(); - var prompter = new TestNewCommandPrompter(interactionService); - - prompter.PromptForProjectNameCallback = (defaultName) => - { - promptedForProjectName = true; - throw new InvalidOperationException("PromptForProjectName should not be called for init command with single-file AppHost"); - }; - - prompter.PromptForOutputPathCallback = (path) => - { - promptedForOutputPath = true; - throw new InvalidOperationException("PromptForOutputPath should not be called for init command with single-file AppHost"); - }; - - // PromptForTemplatesVersion is expected to be called - prompter.PromptForTemplatesVersionCallback = (packages) => packages.First(); - - return prompter; - }; - - // Mock the runner to avoid actual template installation and project creation - options.DotNetCliRunnerFactory = (sp) => - { - var runner = new TestDotNetCliRunner(); - - // Mock template installation - runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, cancellationToken) => - { - return (ExitCode: 0, TemplateVersion: "10.0.0"); - }; - - // Mock project creation - runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, cancellationToken) => - { - // Verify the expected values are being used - Assert.Equal(workspace.WorkspaceRoot.Name, projectName); - Assert.Equal(workspace.WorkspaceRoot.FullName, Path.GetFullPath(outputPath)); - - // Create a minimal file to simulate successful template creation - var appHostFile = Path.Combine(outputPath, "apphost.cs"); - File.WriteAllText(appHostFile, "// Test apphost file"); - - return 0; - }; - - // Mock package search for template version selection - runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetConfigFile, useCache, invocationOptions, cancellationToken) => - { - var package = new Aspire.Shared.NuGetPackageCli - { - Id = "Aspire.ProjectTemplates", - Source = "nuget", - Version = "10.0.0" - }; - - return (0, new[] { package }); - }; - - return runner; - }; - - // Mock packaging service to return fake channels - options.PackagingServiceFactory = (sp) => - { - return new TestPackagingService(); - }; - }); - - var serviceProvider = services.BuildServiceProvider(); - var initCommand = serviceProvider.GetRequiredService(); - - // Act - Invoke init command - var parseResult = initCommand.Parse("init"); - var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); - - // Assert - Assert.Equal(0, exitCode); - Assert.False(promptedForProjectName, "Should not have prompted for project name"); - Assert.False(promptedForOutputPath, "Should not have prompted for output path"); - } - - // Test implementation of INewCommandPrompter - private sealed class TestNewCommandPrompter(IInteractionService interactionService) : NewCommandPrompter(interactionService) - { - public Func, (Aspire.Shared.NuGetPackageCli Package, PackageChannel Channel)>? PromptForTemplatesVersionCallback { get; set; } - public Func? PromptForProjectNameCallback { get; set; } - public Func? PromptForOutputPathCallback { get; set; } - - public override Task<(Aspire.Shared.NuGetPackageCli Package, PackageChannel Channel)> PromptForTemplatesVersionAsync(IEnumerable<(Aspire.Shared.NuGetPackageCli Package, PackageChannel Channel)> candidatePackages, CancellationToken cancellationToken) - { - return PromptForTemplatesVersionCallback switch - { - { } callback => Task.FromResult(callback(candidatePackages)), - _ => Task.FromResult(candidatePackages.First()) - }; - } - - public override Task PromptForProjectNameAsync(string defaultName, CancellationToken cancellationToken) - { - return PromptForProjectNameCallback switch - { - { } callback => Task.FromResult(callback(defaultName)), - _ => Task.FromResult(defaultName) - }; - } - - public override Task PromptForOutputPath(string defaultPath, CancellationToken cancellationToken) - { - return PromptForOutputPathCallback switch - { - { } callback => Task.FromResult(callback(defaultPath)), - _ => Task.FromResult(defaultPath) - }; - } - } - - // Test implementation of IPackagingService - private sealed class TestPackagingService : IPackagingService - { - public Task> GetChannelsAsync(CancellationToken cancellationToken = default) - { - // Return a fake channel with the implicit type (meaning use default NuGet sources) - var testChannel = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache()); - return Task.FromResult>(new[] { testChannel }); - } - } - - private sealed class FakeNuGetPackageCache : INuGetPackageCache - { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) - { - var package = new Aspire.Shared.NuGetPackageCli - { - Id = "Aspire.ProjectTemplates", - Source = "nuget", - Version = "10.0.0" - }; - return Task.FromResult>(new[] { package }); - } - - public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) - { - return Task.FromResult>(Array.Empty()); - } - - public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) - { - return Task.FromResult>(Array.Empty()); - } - - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) - { - return Task.FromResult>(Array.Empty()); - } - } - - [Fact] - public async Task InitCommandWithChannelOptionUsesSpecifiedChannel() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - string? channelNameUsed = null; - bool promptedForVersion = false; - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.NewCommandPrompterFactory = (sp) => - { - var interactionService = sp.GetRequiredService(); - var prompter = new TestNewCommandPrompter(interactionService); - - prompter.PromptForTemplatesVersionCallback = (packages) => - { - promptedForVersion = true; - throw new InvalidOperationException("Should not prompt for version when --channel is specified"); - }; - - return prompter; - }; - - options.PackagingServiceFactory = (sp) => - { - return new TestPackagingServiceWithChannelTracking((channelName) => channelNameUsed = channelName); - }; - - options.DotNetCliRunnerFactory = (sp) => - { - var runner = new TestDotNetCliRunner(); - runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, ct) => - { - return (0, version); - }; - runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) => - { - var appHostFile = Path.Combine(outputPath, "apphost.cs"); - File.WriteAllText(appHostFile, "// Test apphost file"); - return 0; - }; - return runner; - }; - }); - var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("init --channel stable"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - // Assert - Assert.Equal(0, exitCode); - Assert.Equal("stable", channelNameUsed); - Assert.False(promptedForVersion); - } - - [Fact] - public async Task InitCommandWithInvalidChannelShowsError() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.PackagingServiceFactory = (sp) => - { - return new TestPackagingServiceWithChannelTracking(_ => { }); - }; - }); - var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("init --channel invalid-channel"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - // Assert - should fail with non-zero exit code for invalid channel - Assert.NotEqual(0, exitCode); - } - - [Fact] - public async Task InitCommand_WhenCSharpInitializationFails_DisplaysCreationErrorMessage() - { - TestInteractionService? testInteractionService = null; - - using var workspace = TemporaryWorkspace.Create(outputHelper); - - // Create a solution file only (no project files in the same directory) - var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); - File.WriteAllText(solutionFile.FullName, "Fake solution file"); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.InteractionServiceFactory = (sp) => - { - testInteractionService = new TestInteractionService(); - return testInteractionService; - }; - - options.DotNetCliRunnerFactory = (sp) => - { - var runner = new TestDotNetCliRunner(); - runner.GetSolutionProjectsAsyncCallback = (_, _, _) => - { - return (0, Array.Empty()); - }; - runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) => - { - return 1; // Simulate failure for C# template - }; - return runner; - }; - options.PackagingServiceFactory = (sp) => - { - return new TestPackagingService(); - }; - }); - - var serviceProvider = services.BuildServiceProvider(); - var initCommand = serviceProvider.GetRequiredService(); - - var parseResult = initCommand.Parse("init"); - var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); - - var executionContext = serviceProvider.GetRequiredService(); - var expectedMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ProjectCouldNotBeCreated, executionContext.LogFilePath); + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"))); - Assert.NotEqual(0, exitCode); - Assert.NotNull(testInteractionService); - Assert.Contains(expectedMessage, testInteractionService.DisplayedErrors); + var config = JsonNode.Parse(File.ReadAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json")))!.AsObject(); + var appHost = config["appHost"]!.AsObject(); + Assert.Equal("apphost.cs", appHost["path"]!.GetValue()); + Assert.Null(appHost["language"]); } [Fact] - public async Task InitCommand_WhenTypeScriptInitializationFails_DisplaysCreationErrorMessage() + public async Task InitCommand_WhenTypeScriptSelected_CreatesAppHostAndAspireConfig() { - TestInteractionService? testInteractionService = null; - using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.InteractionServiceFactory = (sp) => - { - testInteractionService = new TestInteractionService(); - return testInteractionService; - }; - options.LanguageServiceFactory = (sp) => { var projectFactory = sp.GetRequiredService(); @@ -629,26 +128,19 @@ public async Task InitCommand_WhenTypeScriptInitializationFails_DisplaysCreation }; }); - services.AddSingleton(new TestScaffoldingService - { - ScaffoldAsyncCallback = (context, cancellationToken) => - { - return Task.FromResult(false); // Simulate failure for TypeScript scaffolding - } - }); - var serviceProvider = services.BuildServiceProvider(); var initCommand = serviceProvider.GetRequiredService(); var parseResult = initCommand.Parse("init"); var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); - var executionContext = serviceProvider.GetRequiredService(); - var expectedMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ProjectCouldNotBeCreated, executionContext.LogFilePath); + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"))); - Assert.NotEqual(0, exitCode); - Assert.NotNull(testInteractionService); - Assert.Contains(expectedMessage, testInteractionService.DisplayedErrors); + var config = JsonNode.Parse(File.ReadAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json")))!.AsObject(); + var appHost = config["appHost"]!.AsObject(); + Assert.Equal("apphost.ts", appHost["path"]!.GetValue()); + Assert.Equal("typescript/nodejs", appHost["language"]!.GetValue()); } [Fact] @@ -678,6 +170,7 @@ public async Task InitCommand_WhenAspireInitSkillSelected_PrintsToolSpecificFoll var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.InteractionServiceFactory = _ => interactionService; + options.CliHostEnvironmentFactory = _ => global::Aspire.Cli.Tests.TestHelpers.CreateInteractiveHostEnvironment(); }); var serviceProvider = services.BuildServiceProvider(); @@ -720,6 +213,7 @@ public async Task InitCommand_WhenAspireInitSkillNotSelected_DoesNotPrintFollowU var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.InteractionServiceFactory = _ => interactionService; + options.CliHostEnvironmentFactory = _ => global::Aspire.Cli.Tests.TestHelpers.CreateInteractiveHostEnvironment(); }); var serviceProvider = services.BuildServiceProvider(); @@ -732,48 +226,4 @@ public async Task InitCommand_WhenAspireInitSkillNotSelected_DoesNotPrintFollowU Assert.DoesNotContain(interactionService.DisplayedMessages, m => m.Message.Contains("To complete setup", StringComparison.Ordinal)); Assert.DoesNotContain(subtleMessages, m => m.Contains("run the aspire-init skill", StringComparison.Ordinal)); } - - private sealed class TestPackagingServiceWithChannelTracking(Action onChannelUsed) : IPackagingService - { - public Task> GetChannelsAsync(CancellationToken cancellationToken = default) - { - var stableCache = new FakeNuGetPackageCacheWithTracking("stable", onChannelUsed); - var dailyCache = new FakeNuGetPackageCacheWithTracking("daily", onChannelUsed); - - var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], stableCache); - var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache); - - return Task.FromResult>(new[] { stableChannel, dailyChannel }); - } - } - - private sealed class FakeNuGetPackageCacheWithTracking(string channelName, Action onChannelUsed) : INuGetPackageCache - { - public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) - { - onChannelUsed(channelName); - var package = new Aspire.Shared.NuGetPackageCli - { - Id = "Aspire.ProjectTemplates", - Source = "nuget", - Version = "10.0.0" - }; - return Task.FromResult>(new[] { package }); - } - - public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) - { - return Task.FromResult>(Array.Empty()); - } - - public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) - { - return Task.FromResult>(Array.Empty()); - } - - public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) - { - return Task.FromResult>(Array.Empty()); - } - } } From d1d239c97919a4b62fb92352f65a8cfe5792ee39 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 18:46:36 -0400 Subject: [PATCH 37/48] Modularize init solution guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 43 ++-- .../references/full-solution-apphosts.md | 190 ++++++++++++++++++ 2 files changed, 206 insertions(+), 27 deletions(-) create mode 100644 .agents/skills/aspire-init/references/full-solution-apphosts.md diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 6502c3dce64..d6280899027 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -7,6 +7,8 @@ description: "One-time skill for completing Aspire initialization after `aspire This is a **one-time setup skill**. It completes the Aspire initialization that `aspire init` started. After this skill finishes successfully, it should be deleted — the evergreen `aspire` skill handles ongoing AppHost work. +Keep this as **one skill with context-specific references**. Load the reference files that match the repo you discover instead of trying to keep every edge case in the main document. + ## Guiding principles ### Minimize changes to the user's code @@ -262,34 +264,13 @@ For C# AppHosts, there are two sub-modes: Check which mode you're in by looking at what exists at the `appHost.path` location. -### Mixed SDK repos: keep a `.csproj` AppHost on .NET 10 without changing .NET 8 services - -This guidance is specifically for **full project mode** C# AppHosts (the AppHost has its own `.csproj`). - -Some repos pin the root `global.json` to an older SDK such as .NET 8. A `.csproj`-based Aspire AppHost should still stay on the current Aspire-supported SDK (for example, .NET 10), while the existing service projects can remain on `net8.0`. - -**Do not downgrade the AppHost project to match the repo's root SDK pin.** Instead, create an SDK boundary around the AppHost: - -- Keep the repo root `global.json` unchanged -- Put the AppHost in its own directory -- Add a **nested `global.json` next to the AppHost** that pins the newer SDK -- Leave existing services targeting `net8.0` - -This works because the .NET 10 SDK can build and run `net8.0` projects just fine. - -**Important caveat:** if the repo's root solution is normally built from the repo root under SDK 8, do not assume that build can own a `net10.0` AppHost project. In mixed-SDK repos, prefer a **separate AppHost project folder** with a nested `global.json`, kept outside the repo's normal root-build path when necessary. - -If you use full project mode because a solution exists, be careful: adding a `net10.0` AppHost project to a root solution that is built under SDK 8 may break the repo's normal build. If that's likely, tell the user and prefer keeping the AppHost isolated rather than silently wiring it into the root solution. - -Example nested `global.json` beside the AppHost: +If you're in **full project mode**, also load [references/full-solution-apphosts.md](references/full-solution-apphosts.md). It covers: -```json -{ - "sdk": { - "version": "10.0.100" - } -} -``` +- mixed-SDK solution boundaries +- when to add or avoid solution membership +- ServiceDefaults in solution-backed repos +- legacy `Program.cs` / `Startup.cs` / `IHostBuilder` migration decisions +- validation specific to `.csproj` AppHosts ## Workflow @@ -374,6 +355,8 @@ Ask the user: > **Skip this step for TypeScript AppHosts.** OTel is handled in Step 8. +If the AppHost is in **full project mode**, consult [references/full-solution-apphosts.md](references/full-solution-apphosts.md) before making ServiceDefaults changes. Some existing solutions need bootstrap updates before `AddServiceDefaults()` and `MapDefaultEndpoints()` can be applied safely. + If no ServiceDefaults project exists in the repo, create one: ```bash @@ -593,6 +576,8 @@ If no `tsconfig.json` exists and `aspire restore` didn't create one, create a mi > **Skip this step for TypeScript AppHosts.** +If any selected .NET service still uses a legacy `IHostBuilder` / `Startup.cs` bootstrap, consult [references/full-solution-apphosts.md](references/full-solution-apphosts.md) before editing it. Do not assume ServiceDefaults can be dropped into old hosting patterns unchanged. + For each .NET project that the user selected for ServiceDefaults: ```bash @@ -881,6 +866,10 @@ After adding, run `aspire restore` (TypeScript) or `dotnet restore` (C#) to upda **Always prefer a typed integration over raw `AddExecutable`/`AddContainer`.** Typed integrations handle working directories, port injection, health checks, and dashboard integration automatically. +## References + +- For solution-backed C# AppHosts (`.sln`/`.slnx` + `.csproj` AppHost), see [references/full-solution-apphosts.md](references/full-solution-apphosts.md). + ## AppHost wiring reference This section covers the patterns you'll need when writing Step 5 (Wire up the AppHost). Refer back to it as needed. diff --git a/.agents/skills/aspire-init/references/full-solution-apphosts.md b/.agents/skills/aspire-init/references/full-solution-apphosts.md new file mode 100644 index 00000000000..0dc8f604c47 --- /dev/null +++ b/.agents/skills/aspire-init/references/full-solution-apphosts.md @@ -0,0 +1,190 @@ +# Full-solution C# AppHosts + +Use this reference when `aspire init` created a **full project mode** AppHost because a `.sln` or `.slnx` was discovered. + +This is the high-friction path: solution-backed repos often have older bootstrap patterns, SDK pins, existing ServiceDefaults-like code, and build constraints that do not exist in single-file mode. + +## What this reference is for + +Load this reference when any of the following are true: + +- `appHost.path` points to a directory containing `apphost.cs` and a `.csproj` +- a `.sln` or `.slnx` exists near the AppHost +- the repo has a root `global.json` +- selected .NET services still use `Program.cs` + `Startup.cs`, `Host.CreateDefaultBuilder`, `ConfigureWebHostDefaults`, `UseStartup`, or other `IHostBuilder` patterns + +## Core rule: solution-backed AppHosts are not single-file AppHosts + +Treat these repos as **solution-aware C# init**, not as generic AppHost setup. + +- The AppHost may need project references +- The AppHost may need its own SDK boundary +- The solution may or may not be able to own the AppHost safely +- ServiceDefaults changes may require bootstrap modernization + +Do not apply single-file assumptions here. + +## Mixed SDK repos + +Some repos pin the root `global.json` to an older SDK such as .NET 8. A `.csproj`-based Aspire AppHost should still stay on the current Aspire-supported SDK (for example, .NET 10), while existing service projects can remain on `net8.0`. + +**Do not downgrade the AppHost project to match the repo's root SDK pin.** + +Preferred approach: + +1. Keep the repo root `global.json` unchanged. +2. Keep the AppHost in its own directory. +3. Add a **nested `global.json` next to the AppHost** that pins the newer SDK. +4. Leave existing services targeting their current TFM unless the user explicitly asks to migrate them. + +Example nested `global.json` beside the AppHost: + +```json +{ + "sdk": { + "version": "10.0.100" + } +} +``` + +### Important solution caveat + +If the repo's normal root build runs under SDK 8, do **not** assume it can safely own a `net10.0` AppHost project. + +When that's likely to break the repo's normal build: + +- tell the user explicitly +- prefer keeping the AppHost isolated in its own folder +- only add it to the root solution if the user wants that tradeoff + +## Solution membership + +A discovered solution means the AppHost was created in project mode, but that does **not** always mean every new project should be added to the root solution automatically. + +Use this decision order: + +1. If the root solution already includes the services being modeled and is the normal local entry point, prefer adding the AppHost and ServiceDefaults there. +2. If the root solution is tightly coupled to an older SDK/toolchain and adding a `net10.0` AppHost is likely to break routine builds, keep the AppHost outside the solution or in a safer sibling solution boundary. +3. If you're unsure, ask instead of guessing. + +## ServiceDefaults in solution-backed repos + +Before creating or wiring ServiceDefaults: + +1. Look for an existing ServiceDefaults project or equivalent shared bootstrap code. +2. Check whether selected services already have tracing, health checks, or service discovery setup. +3. Check whether the service bootstrap is modern enough for `AddServiceDefaults()` and `MapDefaultEndpoints()`. + +If a ServiceDefaults project already exists, reuse it instead of creating another one. + +## Legacy bootstrap detection: `IHostBuilder` vs `IHostApplicationBuilder` + +This is the easy-to-forget gotcha. + +The generated ServiceDefaults extensions typically target **`IHostApplicationBuilder`** and **`WebApplication`** patterns: + +```csharp +builder.AddServiceDefaults(); +app.MapDefaultEndpoints(); +``` + +That drops cleanly into modern code such as: + +- `var builder = WebApplication.CreateBuilder(args);` +- `var builder = Host.CreateApplicationBuilder(args);` + +It does **not** automatically map onto older patterns such as: + +- `Host.CreateDefaultBuilder(args)` +- `ConfigureWebHostDefaults(...)` +- `UseStartup()` +- `IHostBuilder`-only worker/bootstrap code + +### What to do when you find legacy hosting + +Do **not** silently jam ServiceDefaults into the old shape. + +Present the user with the decision: + +1. **Keep the service code unchanged for now** + - model the service in the AppHost + - skip ServiceDefaults injection for that project + - use AppHost-side environment wiring only + - note that full Aspire service-defaults behavior is deferred + +2. **Modernize the bootstrap** + - convert the service to `WebApplication.CreateBuilder(args)` or `Host.CreateApplicationBuilder(args)` + - then add `builder.AddServiceDefaults()` + - for ASP.NET Core apps, add `app.MapDefaultEndpoints()` before `app.Run()` + +If the repo is conservative or large, default to **asking**, not migrating automatically. + +## Modernization guidance + +### ASP.NET Core app using `IHostBuilder` / `Startup` + +If the user wants full ServiceDefaults support, migrate toward a `WebApplicationBuilder` shape. + +Target pattern: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapControllers(); +app.MapDefaultEndpoints(); + +app.Run(); +``` + +Preserve existing service registrations and middleware ordering carefully. Move only what is required to land on a `WebApplicationBuilder`/`WebApplication` pipeline. + +### Worker/background service using `IHostBuilder` + +If the service is a worker and the user wants ServiceDefaults, migrate toward `Host.CreateApplicationBuilder(args)`: + +```csharp +var builder = Host.CreateApplicationBuilder(args); + +builder.AddServiceDefaults(); +builder.Services.AddHostedService(); + +var host = builder.Build(); +await host.RunAsync(); +``` + +For non-web workers, `MapDefaultEndpoints()` usually does not apply unless the app exposes HTTP endpoints. + +## AppHost project references + +For full project mode, prefer explicit project references from the AppHost to selected .NET services: + +```bash +dotnet add reference +``` + +This keeps solution-backed AppHosts easier to navigate and build. + +## Validation checklist for full-solution mode + +Before declaring success: + +1. The AppHost project builds under its intended SDK boundary. +2. The root solution still behaves the way the user expects, or the user has explicitly accepted any tradeoff. +3. Any ServiceDefaults changes compile in the selected services. +4. `aspire start` works from the AppHost context. +5. Legacy `IHostBuilder` services were either modernized intentionally or explicitly left unchanged. + +## When to ask the user instead of deciding + +Ask when: + +- adding the AppHost to the root solution might break the repo's normal SDK/build +- a service uses `Startup.cs` / `IHostBuilder` and would need real bootstrap surgery +- there are multiple plausible ServiceDefaults/shared-bootstrap projects to reuse +- the repo has mixed solution boundaries and it's unclear which one is the real developer entry point From af2381f16d9ca6f5cb73a67fb66235161eb0fba0 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Tue, 7 Apr 2026 18:59:03 -0400 Subject: [PATCH 38/48] Clarify init validation states Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 16 ++++++++++++++-- .../references/full-solution-apphosts.md | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index d6280899027..e228ed91511 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -773,7 +773,19 @@ Once the app is running, use the Aspire CLI to verify everything is wired up cor 4. **No startup errors**: `aspire logs ` — check logs for each resource to ensure clean startup with no crashes, missing config, or connection failures. 5. **Dashboard is accessible**: Confirm the dashboard URL (including the login token) is printed and can be opened. The full URL looks like `http://localhost:18888/login?t=` — always include the token. -**This skill is not done until `aspire start` runs without errors and all resources are healthy.** If anything fails, diagnose, fix, and run `aspire start` again. Keep iterating until it works — do not move on to Step 11 with a broken app. +**This skill is not done until `aspire start` runs without errors and every resource is in an expected terminal/runtime state.** Acceptable end states are: + +- **Healthy / Running** for long-lived services +- **Finished** only for resources that were intentionally modeled as one-shot tasks (for example migrations or seed steps) **and** only if they exited cleanly with no errors +- **Not started** only when that is intentional and understood (for example, an optional resource the user chose not to run yet) + +Treat these as failure states unless you intentionally designed for them: + +- **Finished** for long-lived APIs, frontends, workers, or databases +- **Finished** after an exception, crash, or non-zero exit +- unhealthy, degraded, failed, or crash-looping resources + +If anything lands in an unexpected state, diagnose it, fix it, and run `aspire start` again. Keep iterating until the app behaves as expected — do not move on to Step 11 with crash-shaped "success". Once everything is healthy, print a summary for the user: @@ -790,7 +802,7 @@ Resources: ``` -Get the dashboard URL from `aspire start` output (always include the `?t=` parameter). Get resource status from `aspire describe`. This summary is the user's confirmation that init worked — make it complete and accurate. +Get the dashboard URL from `aspire start` output (always include the `?t=` parameter). Get resource status from `aspire describe`. If any resource shows `Finished`, confirm from logs that it was an intentional one-shot resource that exited successfully before including it as success. This summary is the user's confirmation that init worked — make it complete and accurate. Common issues: diff --git a/.agents/skills/aspire-init/references/full-solution-apphosts.md b/.agents/skills/aspire-init/references/full-solution-apphosts.md index 0dc8f604c47..c0d9c742460 100644 --- a/.agents/skills/aspire-init/references/full-solution-apphosts.md +++ b/.agents/skills/aspire-init/references/full-solution-apphosts.md @@ -177,7 +177,7 @@ Before declaring success: 1. The AppHost project builds under its intended SDK boundary. 2. The root solution still behaves the way the user expects, or the user has explicitly accepted any tradeoff. 3. Any ServiceDefaults changes compile in the selected services. -4. `aspire start` works from the AppHost context. +4. `aspire start` works from the AppHost context, and long-lived app resources are healthy rather than merely `Finished`. 5. Legacy `IHostBuilder` services were either modernized intentionally or explicitly left unchanged. ## When to ask the user instead of deciding From 3749481aa1026590ef6eef73167fe4db773357bc Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Wed, 8 Apr 2026 16:29:36 -0400 Subject: [PATCH 39/48] Improve aspire-init skill for large/complex repos - Add User Secrets migration guidance to SKILL.md - Add docker-compose profiles awareness to Step 1 scanning - Create references/docker-compose.md for rich compose migration - Rewrite references/full-solution-apphosts.md with large solution triage, incremental core-loop wiring, migration runner modeling, batch legacy pattern decisions, custom MSBuild SDK detection, conditional compilation awareness, and multi-source-root repos Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 31 ++- .../aspire-init/references/docker-compose.md | 199 ++++++++++++++++++ .../references/full-solution-apphosts.md | 128 ++++++++++- 3 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 .agents/skills/aspire-init/references/docker-compose.md diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index e228ed91511..096741e68a9 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -241,6 +241,25 @@ Some teams still need `.env` files for CI, Docker Compose, or developers who hav Present this as a recommendation. Walk through the `.env` contents with the user and classify each variable together. Some values may be intentionally local-only and the user may prefer to keep them — that's fine. +### Migrate .NET User Secrets into AppHost parameters + +.NET projects often use `dotnet user-secrets` for local development configuration. Look for: + +- `dev/secrets.json.example` or `secrets.json.example` files — these document expected secrets +- `` in `.csproj` files — indicates the project uses User Secrets +- Documentation referencing `dotnet user-secrets set` or `setup_secrets` scripts + +**Aspire's `AddParameter(name, secret: true)` stores values in the same .NET User Secrets store under the hood.** This means migration is seamless — the developer's existing workflow stays nearly identical, but secrets are now centralized in the AppHost instead of scattered across individual service projects. + +Migration approach: + +1. **Inventory existing secrets** — check `secrets.json.example` files or setup scripts to understand what secrets the repo expects +2. **Classify each secret** — same as `.env` migration: connection strings become Aspire resources, API keys become parameters, plain config becomes `WithEnvironment()` +3. **Present the migration** — show the user which secrets will become AppHost parameters and which will become Aspire resources: + → *"Your services use dotnet user-secrets with 8 configured values. I'll migrate the SQL connection string to an Aspire Postgres resource, the 3 API keys to secret parameters, and the remaining config to environment variables. The secrets will still be stored in user-secrets but centralized under the AppHost. Sound good?"* + +**Important:** Don't delete or modify existing `UserSecretsId` entries in service `.csproj` files — other tooling or non-Aspire workflows may still depend on them. + ## Prerequisites Before running this skill, `aspire init` must have already: @@ -301,16 +320,23 @@ Analyze the repository to discover all projects and services that could be model - **Java apps**: directories with `pom.xml` or `build.gradle` - **Dockerfiles**: standalone `Dockerfile` entries representing services - **Docker Compose**: `docker-compose.yml` or `compose.yml` files — these are a goldmine. Parse them to extract: - - **Services**: each named service maps to a potential AppHost resource + - **Profiles**: if any service has a `profiles:` key, the compose file uses profiles to organize services into groups (e.g., `cloud`, `storage`, `mssql`, `postgres`). When profiles exist: + 1. List the available profiles and what services each includes + 2. Ask the user which profile(s) to target for the AppHost (e.g., *"Your docker-compose uses profiles: cloud, mssql, postgres, storage, redis. Which represent your local dev stack?"*) + 3. Only model services that belong to the selected profile(s) — skip the rest + 4. If a service has no `profiles:` key, it runs in all profiles — always include it + - **Services**: each named service (in the selected profiles) maps to a potential AppHost resource - **Images**: container images used (e.g., `postgres:16`, `redis:7`) → these become `AddContainer()` or typed Aspire integrations (e.g., `AddPostgres()`, `AddRedis()`) - **Ports**: published port mappings → `WithHttpsEndpoint()` or `WithEndpoint()` - - **Environment variables**: env vars and `.env` file references → `WithEnvironment()` + - **Environment variables**: env vars and `.env` file references → `WithEnvironment()`. Watch for `${VAR}` interpolation syntax — trace these back to `.env` files and migrate them to AppHost parameters - **Volumes**: named/bind volumes → `WithVolume()` or `WithBindMount()` - **Dependencies**: `depends_on` → `WithReference()` and `WaitFor()` - **Build contexts**: `build:` entries → `AddDockerfile()` pointing to the build context directory - Prefer typed Aspire integrations over raw `AddContainer()` when the image matches a known integration (use `aspire docs search` to check). For example, `postgres:16` → `AddPostgres()`, `redis:7` → `AddRedis()`, `rabbitmq:3` → `AddRabbitMQ()`. + - For complex compose files, also load [references/docker-compose.md](references/docker-compose.md) for detailed migration patterns. - **Static frontends**: Vite, Next.js, Create React App, or other frontend framework configs - **`.env` files**: Scan for `.env`, `.env.local`, `.env.development`, `.env.example`, etc. These contain configuration that should be migrated into AppHost parameters (see Guiding Principles above) +- **User Secrets**: Scan for `secrets.json.example` files and `` in `.csproj` files. These indicate .NET User Secrets are in use — migrate them into AppHost parameters (see Guiding Principles above) - **Package manager**: Detect which Node.js package manager the repo uses by looking for lock files: `pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `package-lock.json` or none → npm. Use the detected package manager for all install/run commands throughout this skill. **Ignore:** @@ -881,6 +907,7 @@ After adding, run `aspire restore` (TypeScript) or `dotnet restore` (C#) to upda ## References - For solution-backed C# AppHosts (`.sln`/`.slnx` + `.csproj` AppHost), see [references/full-solution-apphosts.md](references/full-solution-apphosts.md). +- For repos with `docker-compose.yml` or `compose.yml`, see [references/docker-compose.md](references/docker-compose.md). ## AppHost wiring reference diff --git a/.agents/skills/aspire-init/references/docker-compose.md b/.agents/skills/aspire-init/references/docker-compose.md new file mode 100644 index 00000000000..bb04da345e6 --- /dev/null +++ b/.agents/skills/aspire-init/references/docker-compose.md @@ -0,0 +1,199 @@ +# Docker Compose migration + +Use this reference when the repo has a `docker-compose.yml` or `compose.yml` file. Docker Compose files are one of the most valuable discovery sources — they document the infrastructure the app actually needs to run locally. + +## When to load this reference + +- A `docker-compose.yml`, `compose.yml`, or `docker-compose.override.yml` exists anywhere in the repo +- The repo has setup scripts that call `docker compose up` as part of the dev workflow + +## Profiles + +Docker Compose files can use `profiles:` to organize services into named groups. Not all services run at once — the developer chooses which profiles to activate. + +Example from a real repo: + +```yaml +services: + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + profiles: [cloud, mssql] + postgres: + image: postgres:14 + profiles: [postgres, ef] + redis: + image: redis:alpine + profiles: [redis, cloud] + storage: + image: mcr.microsoft.com/azure-storage/azurite:latest + profiles: [storage, cloud] + mail: + image: sj26/mailcatcher:latest + profiles: [mail] +``` + +When profiles are present: + +1. **List them clearly** — show the user which profiles exist and what services each activates +2. **Ask which to target** — *"Your docker-compose has profiles: cloud, mssql, postgres, storage, redis, mail. Which ones represent your typical local dev stack?"* +3. **Model only selected profiles** — services in unselected profiles are skipped entirely +4. **Services without profiles always run** — if a service has no `profiles:` key, include it regardless of profile selection +5. **Profile-specific infrastructure implies choices** — `mssql` vs `postgres` profiles often mean the repo supports multiple database backends. Ask which one to model in the AppHost. + +## Image-to-integration mapping + +Prefer typed Aspire integrations over raw `AddContainer()`. Use `aspire docs search ` to check for available integrations. + +Common mappings: + +| Compose image | Aspire integration | Method | +|---------------|-------------------|--------| +| `postgres:*` | `Aspire.Hosting.PostgreSQL` | `AddPostgres()` | +| `mcr.microsoft.com/mssql/server:*` | `Aspire.Hosting.SqlServer` | `AddSqlServer()` | +| `mysql:*` / `mariadb:*` | `Aspire.Hosting.MySql` | `AddMySql()` | +| `redis:*` | `Aspire.Hosting.Redis` | `AddRedis()` | +| `rabbitmq:*` | `Aspire.Hosting.RabbitMQ` | `AddRabbitMQ()` | +| `mongo:*` | `Aspire.Hosting.MongoDB` | `AddMongoDB()` | +| `mcr.microsoft.com/azure-storage/azurite:*` | `Aspire.Hosting.Azure.Storage` | `AddAzureStorage().RunAsEmulator()` | +| `kafka`, `confluentinc/cp-kafka:*` | `Aspire.Hosting.Kafka` | `AddKafka()` | +| `nats:*` | `Aspire.Hosting.Nats` | `AddNats()` | +| `mcr.microsoft.com/azure-messaging/servicebus-emulator:*` | `Aspire.Hosting.Azure.ServiceBus` | `AddAzureServiceBus().RunAsEmulator()` | + +For images not in this list, use `aspire docs search` to check, then fall back to `AddContainer()`. + +## Environment variable interpolation + +Compose files use `${VAR}` syntax to reference variables from `.env` files: + +```yaml +environment: + MSSQL_SA_PASSWORD: ${MSSQL_PASSWORD} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} +``` + +When you see this pattern: + +1. **Trace the variable** — find it in the `.env` or `.env.example` file +2. **Classify it** — is it a secret (password, key, token) or plain config? +3. **Model it** — secrets become `AddParameter(name, secret: true)`, plain config becomes `AddParameter(name)` with a default or `WithEnvironment()` directly +4. **For typed integrations, check if Aspire manages it automatically** — for example, `AddPostgres()` auto-generates a password, so you don't need to model `POSTGRES_PASSWORD` separately. The compose variable was only needed because Compose didn't manage passwords — Aspire does. + +## Volume mapping + +| Compose volume type | Aspire equivalent | Notes | +|--------------------|--------------------|-------| +| Named volume (`mssql_data:/var/opt/mssql`) | `WithDataVolume()` | Preferred — Aspire manages lifecycle | +| Named volume (custom name) | `WithDataVolume(name: "custom")` | Preserves the name for familiarity | +| Bind mount (`./data:/app/data`) | `WithBindMount("./data", "/app/data")` | Use for config files, scripts, or shared data | +| Bind mount for config (`./config.json:/etc/config.json`) | `WithBindMount(...)` | Preserve for config injection | + +**Tip:** If the compose file mounts migration scripts or SQL files into a database container, those are likely init scripts. See "Init and setup scripts" below. + +## Dependency ordering + +Compose `depends_on` maps to Aspire's `WaitFor()`: + +```yaml +# Compose +services: + api: + depends_on: + - mssql + - redis +``` + +```csharp +// Aspire +var api = builder.AddProject("api") + .WithReference(mssql) + .WithReference(redis) + .WaitFor(mssql) + .WaitFor(redis); +``` + +`WithReference()` establishes the connection. `WaitFor()` ensures the dependency is healthy before the consuming service starts. Use both together. + +For `depends_on` with `condition: service_healthy`, the `WaitFor()` mapping is especially important — it replicates the same behavior. + +## Build contexts + +Compose services with `build:` are built from source, not pulled as images: + +```yaml +services: + worker: + build: + context: ./worker + dockerfile: Dockerfile +``` + +Map these to `AddDockerfile()`: + +```csharp +var worker = builder.AddDockerfile("worker", "../worker") + .WithHttpEndpoint(targetPort: 8080); +``` + +However, **prefer native Aspire project hosting over Dockerfiles when possible**. If the build context contains a `.csproj`, use `AddProject()`. If it's a Node.js app, use `AddNodeApp()` or `AddViteApp()`. Dockerfiles are a last resort for Aspire modeling — they lose service discovery, health checks, and hot reload. + +## Init and setup scripts + +Repos often have setup scripts alongside their compose files: + +- `setup_azurite.ps1` — initializes storage emulator containers and queues +- `migrate.ps1` / `ef_migrate.ps1` — runs database migrations +- `setup_secrets.ps1` — configures .NET user secrets +- `create_certificates_*.sh` — generates dev certificates + +**Present the user with options for how to handle these:** + +1. **Model as a lifecycle command on the relevant resource** — for example, a database migration script can be a startup command on the database resource. This runs automatically when the resource starts. + → *"Your repo has a migrate.ps1 that runs SQL migrations against the database. I can model this as a startup lifecycle hook on the database resource so migrations run automatically when you `aspire start`. Want that?"* + +2. **Model as a standalone executable resource** — for scripts that don't map cleanly to a single resource, use `AddExecutable()` with `WaitForCompletion()` so dependent services wait for the script to finish. + +3. **Leave as manual** — some setup scripts are one-time-only (like certificate generation) and don't need to run every time. Note them in the AppHost as a comment and move on. + +The right choice depends on the script. Present the tradeoff and let the user decide. + +## Putting it together + +A compose file like: + +```yaml +services: + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + MSSQL_SA_PASSWORD: ${MSSQL_PASSWORD} + volumes: + - mssql_data:/var/opt/mssql + ports: + - "1433:1433" + redis: + image: redis:alpine + volumes: + - redis_data:/data + ports: + - "6379:6379" +``` + +Becomes: + +```csharp +var sqlPassword = builder.AddParameter("mssql-password", secret: true); +var mssql = builder.AddSqlServer("mssql", sqlPassword) + .WithDataVolume(); + +var redis = builder.AddRedis("redis") + .WithDataVolume(); +``` + +Note: for typed integrations like `AddSqlServer()` and `AddRedis()`, you don't need to map ports — Aspire handles that. You also don't need to model `redis_data` as a named volume — `WithDataVolume()` handles persistence. + +## Common pitfalls + +- **Don't model every compose service** — some are dev-only tools (mailcatcher, reverse proxies, SAML IdPs for testing). Ask the user which are essential vs nice-to-have. +- **Don't preserve hardcoded ports from compose** — Aspire manages ports dynamically. Only preserve a port if the user confirms it's required for external reasons (OAuth callbacks, etc.). +- **Don't duplicate compose's `.env` interpolation** — Aspire parameters replace this pattern. Trace each `${VAR}` to its source and model it properly. +- **Watch for services that conflict on the same port** — compose profiles often have services sharing ports (e.g., `mssql` and `postgres` both on different profiles). If the user selects conflicting profiles, surface the conflict. diff --git a/.agents/skills/aspire-init/references/full-solution-apphosts.md b/.agents/skills/aspire-init/references/full-solution-apphosts.md index c0d9c742460..d064a2eb8d2 100644 --- a/.agents/skills/aspire-init/references/full-solution-apphosts.md +++ b/.agents/skills/aspire-init/references/full-solution-apphosts.md @@ -2,7 +2,7 @@ Use this reference when `aspire init` created a **full project mode** AppHost because a `.sln` or `.slnx` was discovered. -This is the high-friction path: solution-backed repos often have older bootstrap patterns, SDK pins, existing ServiceDefaults-like code, and build constraints that do not exist in single-file mode. +This is the high-friction path: solution-backed repos often have older bootstrap patterns, SDK pins, existing ServiceDefaults-like code, build constraints, and significantly more projects than single-file repos. Some of these solutions have dozens or hundreds of projects — the skill must triage smartly, not try to wire everything. ## What this reference is for @@ -24,6 +24,120 @@ Treat these repos as **solution-aware C# init**, not as generic AppHost setup. Do not apply single-file assumptions here. +## Large solution triage + +When a solution contains more than a handful of projects, don't try to model everything at once. Classify projects first, then present a focused list. + +### Step 1: Classify all projects + +For every `.csproj` in the solution, determine its role: + +| Classification | How to detect | Action | +|---------------|---------------|--------| +| **Runnable service** | `OutputType` = `Exe` or `WinExe`, not a test project, not the AppHost | Candidate for AppHost modeling | +| **Class library** | `OutputType` = `Library` | Skip — these are dependencies, not services | +| **Test project** | References xUnit/NUnit/MSTest, or name ends in `.Test`/`.Tests`/`.IntegrationTest` | Skip | +| **Migration runner** | Name contains `Migrat`, or references `DbUp`/`FluentMigrator`/EF migrations tooling | Special handling — see below | +| **Utility/tool** | Located in `util/`, `tools/`, or `scripts/` directories; not a long-running service | Skip unless user requests | +| **AppHost** | `IsAspireHost` = `true` | Skip — this is the host itself | + +Run `dotnet msbuild -getProperty:OutputType` to classify. For large solutions, batch these calls. + +### Step 2: Check for multiple source roots + +Some repos keep code in more than one top-level directory. Common patterns: + +- `src/` + `bitwarden_license/src/` (open-source vs commercial) +- `src/` + `util/` (services vs utilities) +- `apps/` + `packages/` (monorepo with shared packages) + +Scan all directories in the solution, not just `src/`. If you find projects outside `src/`, note them and ask the user if they should be included. + +### Step 3: Present the focused list + +Group runnable services by category and present them concisely. For a repo with 60+ projects, show something like: + +> *"I found 8 runnable web services (Api, Admin, Identity, Billing, Events, EventsProcessor, Icons, Notifications), 5 class libraries (Core, SharedWeb, Infrastructure.Dapper, Infrastructure.EntityFramework, Sql), 3 migration runners (Migrator, MsSqlMigratorUtility, PostgresMigrations), and 40+ test projects.* +> +> *I recommend starting with the core services. Which of the 8 web services should I include in the AppHost?"* + +**Do not dump a flat list of 60+ projects.** Classify, summarize, and let the user choose. + +### Incremental "core loop" wiring + +For solutions with 5 or more runnable services, recommend starting with a **core loop** — the minimum set of services needed for a useful local dev session — and expanding from there. + +The core loop is typically: + +1. The primary API service +2. Its database dependency +3. Any authentication/identity service +4. The essential cache (Redis, etc.) + +Once the core loop works with `aspire start`, add services incrementally. This prevents a failed first experience where 8 services are wired but 3 have config issues that block everything. + +Present this explicitly: + +> *"You have 8 services. I recommend wiring the core loop first — Api, Identity, and the database — so we can validate `aspire start` works. Then we'll add the remaining services. Sound good?"* + +## Migration runners and setup utilities + +Migration runners (database migrations, schema updates, data seeders) deserve special handling. They aren't long-running services — they run once and exit. + +Present the user with options: + +1. **Model as a project resource with `WaitForCompletion()`** — the migration runs at startup and dependent services wait for it to finish before starting: + +```csharp +var db = builder.AddSqlServer("mssql").AddDatabase("vault"); +var migrator = builder.AddProject("migrator") + .WithReference(db) + .WaitFor(db); + +var api = builder.AddProject("api") + .WithReference(db) + .WaitForCompletion(migrator); // api waits for migrations to finish +``` + +2. **Leave as manual** — the developer runs migrations separately before `aspire start`. Note this in an AppHost comment: + +```csharp +// Run migrations manually: dotnet run --project ../util/Migrator +var db = builder.AddSqlServer("mssql").AddDatabase("vault"); +``` + +Recommend option 1 for repos that currently run migrations as part of their docker-compose or startup scripts. Recommend option 2 for repos where migrations are a deliberate, explicit step. + +## Custom MSBuild SDKs + +Check `global.json` for `msbuild-sdks` entries beyond the standard Microsoft ones. Common SDKs like `Microsoft.Build.Traversal` are well-known, but custom SDKs (e.g., `Bitwarden.Server.Sdk`) are opaque. + +When you find a custom MSBuild SDK: + +- **Don't assume project properties are reliable** — the custom SDK may override `OutputType`, inject implicit references, or modify build behavior in ways you can't see +- **Note it to the user** — *"This repo uses a custom MSBuild SDK (Bitwarden.Server.Sdk). I'll classify projects based on their directory structure and names as well as MSBuild properties, since the custom SDK may affect property evaluation."* +- **Cross-reference with directory structure** — if `OutputType` says `Library` but the project is in `src/Api/` and has a `Program.cs` and `Startup.cs`, it's likely a runnable service whose OutputType is set by the custom SDK + +## Conditional compilation + +Some repos use `#if` / `#endif` to maintain multiple build variants from the same source: + +```csharp +#if OSS + services.AddOosServices(); +#else + services.AddCommercialCoreServices(); +#endif +``` + +When you detect conditional compilation in `Program.cs` or `Startup.cs`: + +1. **Surface it early** — *"Your services use `#if OSS` conditional compilation, which means the app behaves differently depending on the build configuration. Which variant should the AppHost target — OSS or commercial?"* +2. **Don't try to model both** — pick the variant the user selects and wire accordingly +3. **Note the other variant** — leave a comment in the AppHost: `// This AppHost targets the OSS build. For commercial, adjust service registrations.` + +This is not a priority to solve perfectly — just make sure the agent doesn't silently pick the wrong variant. + ## Mixed SDK repos Some repos pin the root `global.json` to an older SDK such as .NET 8. A `.csproj`-based Aspire AppHost should still stay on the current Aspire-supported SDK (for example, .NET 10), while existing service projects can remain on `net8.0`. @@ -104,7 +218,11 @@ It does **not** automatically map onto older patterns such as: Do **not** silently jam ServiceDefaults into the old shape. -Present the user with the decision: +**When multiple services share the same legacy pattern, batch the decision.** If 8 services all use `Host.CreateDefaultBuilder` + `UseStartup`, don't ask 8 times. Ask once: + +> *"All 8 of your web services use the legacy IHostBuilder + Startup pattern. I can either (a) model them all in the AppHost without ServiceDefaults for now — they'll appear in the dashboard and get environment wiring but won't have health checks or service discovery — or (b) modernize their bootstrap to the WebApplicationBuilder pattern so ServiceDefaults works fully. Which approach do you prefer? You can also mix — modernize a few key services and leave the rest."* + +For individual or mixed-pattern services, present the decision per-service: 1. **Keep the service code unchanged for now** - model the service in the AppHost @@ -144,6 +262,8 @@ app.Run(); Preserve existing service registrations and middleware ordering carefully. Move only what is required to land on a `WebApplicationBuilder`/`WebApplication` pipeline. +When the `Startup` class has custom extension methods (e.g., `UseBitwardenSdk()`, `AddGlobalSettingsServices()`), those typically need to be called on the new `builder` or `app` in the appropriate phase. Don't silently drop them — trace each call to its registration phase (services vs middleware) and preserve it. + ### Worker/background service using `IHostBuilder` If the service is a worker and the user wants ServiceDefaults, migrate toward `Host.CreateApplicationBuilder(args)`: @@ -179,6 +299,7 @@ Before declaring success: 3. Any ServiceDefaults changes compile in the selected services. 4. `aspire start` works from the AppHost context, and long-lived app resources are healthy rather than merely `Finished`. 5. Legacy `IHostBuilder` services were either modernized intentionally or explicitly left unchanged. +6. Migration runners, if modeled, complete successfully before dependent services start. ## When to ask the user instead of deciding @@ -188,3 +309,6 @@ Ask when: - a service uses `Startup.cs` / `IHostBuilder` and would need real bootstrap surgery - there are multiple plausible ServiceDefaults/shared-bootstrap projects to reuse - the repo has mixed solution boundaries and it's unclear which one is the real developer entry point +- the repo has a custom MSBuild SDK and project classification is ambiguous +- the repo uses conditional compilation and the target variant is unclear +- there are more than 5 runnable services and you need to decide which to wire first From bab910562e24ca1da71f520660beb3cf3c217226 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Wed, 8 Apr 2026 17:06:07 -0400 Subject: [PATCH 40/48] Add critical rules: no workloads, no SDK changes, no TFM changes - Add 'Critical rules' section to SKILL.md with hard DO NOT rules for workload installation, SDK version changes, TFM changes - Add guidance to use aspire CLI commands over raw dotnet for Aspire ops - Strengthen nested global.json guidance in full-solution reference with explicit steps and check-before-create Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 27 +++++++++++++++++++ .../references/full-solution-apphosts.md | 20 ++++++++------ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 096741e68a9..3d93e007a98 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -269,6 +269,33 @@ Before running this skill, `aspire init` must have already: Verify both exist before proceeding. +## Critical rules — read before doing anything + +These are hard rules. Do not break them. + +### Never install the Aspire workload + +**Do not run `dotnet workload install aspire` or any `dotnet workload` command.** The Aspire workload is obsolete. The Aspire CLI (`aspire start`, `aspire run`, etc.) handles everything — SDK resolution, package restoration, building, and launching. The workload is not needed and installing it can cause version conflicts. + +If a web search, documentation page, or blog post tells you to install the workload, **ignore that advice** — it is outdated. + +### Do not change the repo's .NET SDK version + +**Do not modify the root `global.json`.** The repo's SDK pin is intentional. The AppHost may need a newer SDK (e.g., .NET 10) than the repo uses (e.g., .NET 8) — that's fine. `aspire init` already created the AppHost with the correct TFM. If the AppHost is in full project mode and the repo pins an older SDK, add a **nested `global.json` inside the AppHost directory only** — never change the root one. + +### Do not change existing project target frameworks + +**Do not modify `` in any existing service project.** If a service targets `net8.0`, leave it on `net8.0`. The Aspire AppHost can orchestrate services on older TFMs without any changes. Only the AppHost itself needs the Aspire-supported TFM. + +### Use the Aspire CLI, not raw dotnet commands, for Aspire operations + +- Use `aspire start` to launch the AppHost (not `dotnet run`) +- Use `aspire add ` to add hosting integrations (not `dotnet add package`) +- Use `aspire restore` to restore TypeScript AppHost dependencies +- Use `aspire docs search` to look up APIs + +Standard `dotnet` commands (`dotnet build`, `dotnet add reference`, `dotnet sln add`) are fine for normal .NET operations like building projects, adding project references, and managing solution membership. + ## Determine your context Read `aspire.config.json` at the repository root. Key fields: diff --git a/.agents/skills/aspire-init/references/full-solution-apphosts.md b/.agents/skills/aspire-init/references/full-solution-apphosts.md index d064a2eb8d2..fdc53525786 100644 --- a/.agents/skills/aspire-init/references/full-solution-apphosts.md +++ b/.agents/skills/aspire-init/references/full-solution-apphosts.md @@ -142,25 +142,29 @@ This is not a priority to solve perfectly — just make sure the agent doesn't s Some repos pin the root `global.json` to an older SDK such as .NET 8. A `.csproj`-based Aspire AppHost should still stay on the current Aspire-supported SDK (for example, .NET 10), while existing service projects can remain on `net8.0`. -**Do not downgrade the AppHost project to match the repo's root SDK pin.** +**Do not downgrade the AppHost project to match the repo's root SDK pin. Do not change the root `global.json`. Do not change any existing project's ``.** -Preferred approach: +### Create a nested `global.json` for the AppHost -1. Keep the repo root `global.json` unchanged. -2. Keep the AppHost in its own directory. -3. Add a **nested `global.json` next to the AppHost** that pins the newer SDK. -4. Leave existing services targeting their current TFM unless the user explicitly asks to migrate them. +If the repo's root `global.json` pins an older SDK and the AppHost is in full project mode, you **must** create a nested `global.json` inside the AppHost directory so it builds with the correct SDK. Check whether one already exists before creating it. + +Steps: -Example nested `global.json` beside the AppHost: +1. Keep the repo root `global.json` unchanged. +2. Check if a `global.json` already exists in the AppHost directory — if so, skip this. +3. Create a `global.json` next to the AppHost `.csproj` that pins the Aspire-supported SDK: ```json { "sdk": { - "version": "10.0.100" + "version": "10.0.100", + "rollForward": "latestFeature" } } ``` +4. Leave existing services targeting their current TFM unless the user explicitly asks to migrate them. + ### Important solution caveat If the repo's normal root build runs under SDK 8, do **not** assume it can safely own a `net10.0` AppHost project. From de34384a3d0f3bef8bc2caf883fa023e78abfa34 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Wed, 8 Apr 2026 17:09:21 -0400 Subject: [PATCH 41/48] Clarify config files per AppHost mode, fix ServiceDefaults placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add config file table: aspire.config.json vs launchSettings.json vs appsettings.json — which mode uses which, do not confuse them - Full project mode uses Properties/launchSettings.json (standard .NET), not aspire.config.json - Tell agents: do not create/modify config files for service projects - Fix Step 2 smoke-test to reference correct config per mode - Update Step 4 ServiceDefaults: placement is agent's decision based on SDK boundary and repo structure, not hardcoded Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 40 +++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 3d93e007a98..1b031ce8821 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -298,17 +298,30 @@ Standard `dotnet` commands (`dotnet build`, `dotnet add reference`, `dotnet sln ## Determine your context -Read `aspire.config.json` at the repository root. Key fields: +Read `aspire.config.json` at the repository root **if it exists**. Key fields: - **`appHost.language`**: `"typescript/nodejs"` or `"csharp"` — determines which syntax and tooling to use - **`appHost.path`**: path to the AppHost file or project directory — this is where you'll edit code For C# AppHosts, there are two sub-modes: -- **Single-file mode**: `appHost.path` points directly to an `apphost.cs` file using the `#:sdk` directive. No `.csproj` needed. -- **Full project mode**: `appHost.path` points to a directory containing a `.csproj` and `apphost.cs`. This was created because a `.sln`/`.slnx` was found — full project mode is required so the AppHost can be opened in Visual Studio alongside the rest of the solution. +- **Single-file mode**: `appHost.path` points directly to an `apphost.cs` file using the `#:sdk` directive. No `.csproj` needed. Configuration lives in `aspire.config.json`. +- **Full project mode**: A directory containing a `.csproj` and `Program.cs` (or `apphost.cs`). This was created because a `.sln`/`.slnx` was found. Configuration lives in `Properties/launchSettings.json` inside the AppHost project directory (standard .NET launch settings), **not** in `aspire.config.json`. -Check which mode you're in by looking at what exists at the `appHost.path` location. +### Configuration files — which is which + +**Do not confuse these files. Do not create or modify config files for the user's service projects — only for the AppHost.** + +| File | Where it lives | What it's for | Who uses it | +|------|---------------|---------------|-------------| +| `aspire.config.json` | Repo root | AppHost path, language, profiles (ports, env vars) | Single-file and polyglot AppHosts only | +| `Properties/launchSettings.json` | Inside the AppHost project dir | Launch profiles (ports, env vars) | Full project mode `.csproj` AppHosts (standard .NET) | +| `appsettings.json` | Inside service projects | Service-specific configuration | The services themselves — **do not create or modify these** | +| `launchSettings.json` in service projects | Inside service project dirs | Service launch config | The services themselves — **do not create or modify these** | + +**Key rule:** When the AppHost is a `.csproj` project, it's a standard .NET project. It uses `Properties/launchSettings.json` for launch profiles, just like any other .NET project. `aspire init` creates this file with the correct ports. Do not create a duplicate `aspire.config.json` for project-mode AppHosts. + +Check which mode you're in by looking at what exists at the `appHost.path` location. If there's no `aspire.config.json`, look for a `.csproj` AppHost directory with `Properties/launchSettings.json` instead. If you're in **full project mode**, also load [references/full-solution-apphosts.md](references/full-solution-apphosts.md). It covers: @@ -380,12 +393,13 @@ Before investing time in wiring, verify that the Aspire skeleton boots correctly aspire start ``` -The empty AppHost should start successfully — the dashboard should come up and the process should run without errors. You won't see any resources yet (that's expected), but if `aspire start` fails here, the problem is in the generated `aspire.config.json` or the skeleton AppHost file. Fix the issue before proceeding. +The empty AppHost should start successfully — the dashboard should come up and the process should run without errors. You won't see any resources yet (that's expected), but if `aspire start` fails here, fix the issue before proceeding. Common failures at this stage: -- **Missing profiles in `aspire.config.json`**: The file must have a `profiles` section with `applicationUrl`. Re-run `aspire init` to regenerate it. -- **Missing dependencies**: For TypeScript, ensure `@aspect/aspire-hosting` or the `.modules/aspire.js` SDK is available. Run `aspire restore` if needed. +- **Missing launch profile**: For full project mode, the AppHost needs `Properties/launchSettings.json` with an `applicationUrl`. For single-file mode, `aspire.config.json` needs a `profiles` section. If either is missing, re-run `aspire init` to regenerate. +- **SDK version mismatch**: For full project mode in a repo with an older root `global.json`, the AppHost directory needs its own nested `global.json` pinning the Aspire-supported SDK (see Critical Rules above). +- **Missing dependencies**: For TypeScript, ensure the `.modules/aspire.js` SDK is available. Run `aspire restore` if needed. - **Port conflicts**: If another Aspire app is running, the randomly assigned ports may conflict. Stop other instances first. Once it boots, stop it (Ctrl+C) and continue. @@ -410,20 +424,24 @@ Ask the user: If the AppHost is in **full project mode**, consult [references/full-solution-apphosts.md](references/full-solution-apphosts.md) before making ServiceDefaults changes. Some existing solutions need bootstrap updates before `AddServiceDefaults()` and `MapDefaultEndpoints()` can be applied safely. -If no ServiceDefaults project exists in the repo, create one: +**Placement is your decision.** Where to put ServiceDefaults depends on the repo's structure: + +- If the AppHost has its own nested SDK boundary (nested `global.json`), ServiceDefaults should live next to the AppHost in that same boundary so it can target the same TFM. +- If the repo's root SDK is compatible with the AppHost's TFM, ServiceDefaults can live alongside existing source (e.g., in `src/`). +- If a ServiceDefaults project already exists (look for references to `Microsoft.Extensions.ServiceDiscovery` or `Aspire.ServiceDefaults`), skip creation and use the existing one. + +To create one: ```bash dotnet new aspire-servicedefaults -n .ServiceDefaults -o ``` -Place it alongside the AppHost (e.g., `src/` or solution root). If a `.sln` exists, add it: +If a `.sln` exists and the ServiceDefaults project is compatible with the solution's SDK, add it: ```bash dotnet sln add ``` -If a ServiceDefaults project already exists (look for references to `Microsoft.Extensions.ServiceDiscovery` or `Aspire.ServiceDefaults`), skip creation and use the existing one. - ### Step 5: Wire up the AppHost Edit the skeleton AppHost file to add resource definitions for each selected project. Use the appropriate syntax based on language. From 06a0c2cf0d4893ae1215b405d035cfeda14ff5f4 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Wed, 8 Apr 2026 17:40:10 -0400 Subject: [PATCH 42/48] Fix issues from bitwarden eval run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docker-compose ref: Make auto-generated passwords a loud callout with ❌/✅ examples. Agent must NOT create AddParameter for passwords that typed integrations manage (Postgres, SQL Server, Redis, etc.) - Full-solution ref: Rewrite ServiceDefaults decision tree — exactly two options: skip or modernize individually. Never create IHostBuilder adapter shims. Batch decision for N identical services. - SKILL.md: Add 'Check what integrations auto-manage' section with table of common auto-managed values and docs lookup commands Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 25 +++++++++++++++++ .../aspire-init/references/docker-compose.md | 25 +++++++++++++---- .../references/full-solution-apphosts.md | 28 ++++++++++--------- 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 1b031ce8821..bab9f2854b4 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -932,6 +932,31 @@ aspire list integrations Use `aspire docs search` to find the right builder methods, configuration options, and patterns. Use `aspire docs get ` to read the full doc page. Use `aspire list integrations` to discover packages you might not have known about. Do not guess API shapes — Aspire has many resource types with specific overloads. +### Check what integrations auto-manage + +Before modeling environment variables, passwords, ports, or volumes for a typed integration, **check the docs to see what the integration handles automatically**. Many typed integrations auto-generate passwords, manage ports dynamically, and handle volumes — duplicating this config causes errors or conflicts. + +```bash +# Check what AddPostgres manages automatically +aspire docs get "postgresql-hosting-integration" --section "Connection properties" + +# Check what AddSqlServer manages +aspire docs get "sql-server-integration" --section "Hosting integration" +``` + +Look for the **"Connection properties"** section — it lists what the integration injects into consuming services. If it lists `Password`, `Host`, `Port` — the integration manages those. Do not create `AddParameter()` for values the integration already handles. + +Common auto-managed values (do NOT model these manually): + +| Integration | Auto-managed | +|-------------|-------------| +| `AddPostgres()` | Password, host, port, connection string | +| `AddSqlServer()` | SA password, host, port, connection string | +| `AddRedis()` | Connection string, port | +| `AddMySql()` | Root password, host, port, connection string | +| `AddRabbitMQ()` | Username, password, host, port, connection string | +| `AddMongoDB()` | Connection string, port | + To add an integration package (which unlocks typed builder methods): ```bash diff --git a/.agents/skills/aspire-init/references/docker-compose.md b/.agents/skills/aspire-init/references/docker-compose.md index bb04da345e6..28d96725d1c 100644 --- a/.agents/skills/aspire-init/references/docker-compose.md +++ b/.agents/skills/aspire-init/references/docker-compose.md @@ -71,12 +71,27 @@ environment: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} ``` -When you see this pattern: +**⚠️ CRITICAL: Do not model passwords for typed Aspire integrations.** -1. **Trace the variable** — find it in the `.env` or `.env.example` file -2. **Classify it** — is it a secret (password, key, token) or plain config? -3. **Model it** — secrets become `AddParameter(name, secret: true)`, plain config becomes `AddParameter(name)` with a default or `WithEnvironment()` directly -4. **For typed integrations, check if Aspire manages it automatically** — for example, `AddPostgres()` auto-generates a password, so you don't need to model `POSTGRES_PASSWORD` separately. The compose variable was only needed because Compose didn't manage passwords — Aspire does. +`AddPostgres()`, `AddSqlServer()`, `AddRedis()`, `AddMySql()`, `AddRabbitMQ()`, and other typed integrations **auto-generate secure passwords**. The compose file needed `POSTGRES_PASSWORD` because Compose doesn't manage credentials — Aspire does. If you see a compose password variable that maps to a typed integration, **skip it entirely**. Do not create an `AddParameter` for it. + +```csharp +// ❌ WRONG — don't model passwords that Aspire auto-generates +var pgPassword = builder.AddParameter("postgres-password", secret: true); +var postgres = builder.AddPostgres("postgres", password: pgPassword); + +// ✅ RIGHT — let Aspire handle the password +var postgres = builder.AddPostgres("postgres"); +``` + +Use `aspire docs get ` to check what each typed integration manages automatically. Look for the "Connection properties" section — if it lists `Password`, the integration handles it. + +When you see a `${VAR}` pattern in compose: + +1. **Check if it maps to a typed integration** — if `POSTGRES_PASSWORD`, `MSSQL_SA_PASSWORD`, `MYSQL_ROOT_PASSWORD`, `RABBITMQ_DEFAULT_PASS`, etc. are used by a typed Aspire integration, **skip them** — Aspire manages these +2. **Trace non-integration variables** — find them in the `.env` or `.env.example` file +3. **Classify** — is it a secret (API key, token) or plain config? +4. **Model it** — secrets become `AddParameter(name, secret: true)`, plain config becomes `AddParameter(name)` with a default or `WithEnvironment()` directly ## Volume mapping diff --git a/.agents/skills/aspire-init/references/full-solution-apphosts.md b/.agents/skills/aspire-init/references/full-solution-apphosts.md index fdc53525786..9d55e73dcfb 100644 --- a/.agents/skills/aspire-init/references/full-solution-apphosts.md +++ b/.agents/skills/aspire-init/references/full-solution-apphosts.md @@ -220,24 +220,26 @@ It does **not** automatically map onto older patterns such as: ### What to do when you find legacy hosting -Do **not** silently jam ServiceDefaults into the old shape. +Do **not** silently jam ServiceDefaults into the old shape. **Do not create adapter extension methods on `IHostBuilder`** — ServiceDefaults is designed for `IHostApplicationBuilder` and should only be used with the modern bootstrap pattern. -**When multiple services share the same legacy pattern, batch the decision.** If 8 services all use `Host.CreateDefaultBuilder` + `UseStartup`, don't ask 8 times. Ask once: +There are exactly two options. Present them clearly: -> *"All 8 of your web services use the legacy IHostBuilder + Startup pattern. I can either (a) model them all in the AppHost without ServiceDefaults for now — they'll appear in the dashboard and get environment wiring but won't have health checks or service discovery — or (b) modernize their bootstrap to the WebApplicationBuilder pattern so ServiceDefaults works fully. Which approach do you prefer? You can also mix — modernize a few key services and leave the rest."* +1. **Skip ServiceDefaults for now** (recommended for initial setup) + - Model the service in the AppHost with `AddProject()` or `AddCSharpApp()` + - The service appears in the dashboard, gets environment wiring, and shows logs + - No code changes to the service project needed + - Health checks, service discovery, and OTel from ServiceDefaults are deferred -For individual or mixed-pattern services, present the decision per-service: +2. **Modernize the service's bootstrap** (larger change, per-service) + - Convert `Program.cs` from `Host.CreateDefaultBuilder()` to `WebApplication.CreateBuilder()` + - Inline the `Startup.ConfigureServices()` into `builder.Services.*` calls + - Inline the `Startup.Configure()` into the `app.*` middleware pipeline + - Then add `builder.AddServiceDefaults()` and `app.MapDefaultEndpoints()` + - Existing `IHostBuilder` extensions (like custom logging, SDK setup) can be called via `builder.Host.*` -1. **Keep the service code unchanged for now** - - model the service in the AppHost - - skip ServiceDefaults injection for that project - - use AppHost-side environment wiring only - - note that full Aspire service-defaults behavior is deferred +**When multiple services share the same legacy pattern, batch the decision.** If 8 services all use `Host.CreateDefaultBuilder` + `UseStartup`, don't ask 8 times. Ask once: -2. **Modernize the bootstrap** - - convert the service to `WebApplication.CreateBuilder(args)` or `Host.CreateApplicationBuilder(args)` - - then add `builder.AddServiceDefaults()` - - for ASP.NET Core apps, add `app.MapDefaultEndpoints()` before `app.Run()` +> *"All 8 of your web services use the legacy IHostBuilder + Startup pattern. I can either (a) model them all in the AppHost without ServiceDefaults for now — they'll appear in the dashboard and get environment wiring but won't have health checks or service discovery — or (b) modernize each service's bootstrap to the WebApplicationBuilder pattern so ServiceDefaults works fully. Which approach do you prefer? You can also mix — modernize a few key services and leave the rest."* If the repo is conservative or large, default to **asking**, not migrating automatically. From 966377b3297901467241b19a846dd0fa3b571d4b Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Wed, 8 Apr 2026 17:47:18 -0400 Subject: [PATCH 43/48] Use aspire-apphost template for full-solution init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InitCommand now invokes 'dotnet new aspire-apphost' via IDotNetCliRunner for full project mode instead of hand-crafting csproj/apphost.cs. The template generates correct launchSettings.json, proper SDK version pins, and a complete Program.cs — eliminating the config file confusion that caused ASPNETCORE_URLS crashes. - Replace DropCSharpProjectSkeletonAsync with template invocation - Inject IDotNetCliRunner into InitCommand - Update SKILL.md to reflect template-generated AppHost is correct - Update tests to verify template invocation instead of file contents Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 13 ++-- src/Aspire.Cli/Commands/InitCommand.cs | 65 ++++++++----------- .../Commands/InitCommandTests.cs | 29 +++++++-- 3 files changed, 61 insertions(+), 46 deletions(-) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index bab9f2854b4..17172748b16 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -306,7 +306,7 @@ Read `aspire.config.json` at the repository root **if it exists**. Key fields: For C# AppHosts, there are two sub-modes: - **Single-file mode**: `appHost.path` points directly to an `apphost.cs` file using the `#:sdk` directive. No `.csproj` needed. Configuration lives in `aspire.config.json`. -- **Full project mode**: A directory containing a `.csproj` and `Program.cs` (or `apphost.cs`). This was created because a `.sln`/`.slnx` was found. Configuration lives in `Properties/launchSettings.json` inside the AppHost project directory (standard .NET launch settings), **not** in `aspire.config.json`. +- **Full project mode**: A directory containing a `.csproj`, `Program.cs`, and `Properties/launchSettings.json`. This was created by `aspire init` using the `aspire-apphost` template because a `.sln`/`.slnx` was found. **The template-generated AppHost is correct and complete** — it has proper SDK references, launch profiles with randomized ports, and a skeleton `Program.cs`. Do not recreate or hand-edit the `.csproj` or `launchSettings.json`. Configuration lives in `Properties/launchSettings.json` inside the AppHost project directory (standard .NET launch settings), **not** in `aspire.config.json`. ### Configuration files — which is which @@ -395,13 +395,16 @@ aspire start The empty AppHost should start successfully — the dashboard should come up and the process should run without errors. You won't see any resources yet (that's expected), but if `aspire start` fails here, fix the issue before proceeding. -Common failures at this stage: +For full project mode, the AppHost was created from the `aspire-apphost` template and should work out of the box. If it fails, common causes are: -- **Missing launch profile**: For full project mode, the AppHost needs `Properties/launchSettings.json` with an `applicationUrl`. For single-file mode, `aspire.config.json` needs a `profiles` section. If either is missing, re-run `aspire init` to regenerate. -- **SDK version mismatch**: For full project mode in a repo with an older root `global.json`, the AppHost directory needs its own nested `global.json` pinning the Aspire-supported SDK (see Critical Rules above). -- **Missing dependencies**: For TypeScript, ensure the `.modules/aspire.js` SDK is available. Run `aspire restore` if needed. +- **SDK version mismatch**: The repo's root `global.json` may pin an older SDK (e.g., 8.0). The AppHost directory needs its own nested `global.json` pinning the Aspire-supported SDK. Create one if missing (see Critical Rules above). - **Port conflicts**: If another Aspire app is running, the randomly assigned ports may conflict. Stop other instances first. +For single-file mode: + +- **Missing profiles in `aspire.config.json`**: The file must have a `profiles` section with `applicationUrl`. Re-run `aspire init` to regenerate. +- **Missing dependencies**: For TypeScript, ensure the `.modules/aspire.js` SDK is available. Run `aspire restore` if needed. + Once it boots, stop it (Ctrl+C) and continue. ### Step 3: Present findings and confirm with the user diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index a8eab2779a0..ce8c4f8a37a 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -7,6 +7,7 @@ using System.Text.Json.Nodes; using Aspire.Cli.Agents; using Aspire.Cli.Configuration; +using Aspire.Cli.DotNet; using Aspire.Cli.Interaction; using Aspire.Cli.Projects; using Aspire.Cli.Resources; @@ -31,6 +32,7 @@ internal sealed class InitCommand : BaseCommand private readonly ISolutionLocator _solutionLocator; private readonly AgentInitCommand _agentInitCommand; private readonly ICliHostEnvironment _hostEnvironment; + private readonly IDotNetCliRunner _runner; private readonly Option _languageOption; @@ -43,7 +45,8 @@ public InitCommand( CliExecutionContext executionContext, IInteractionService interactionService, AgentInitCommand agentInitCommand, - ICliHostEnvironment hostEnvironment) + ICliHostEnvironment hostEnvironment, + IDotNetCliRunner runner) : base("init", InitCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { _executionContext = executionContext; @@ -51,6 +54,7 @@ public InitCommand( _solutionLocator = solutionLocator; _agentInitCommand = agentInitCommand; _hostEnvironment = hostEnvironment; + _runner = runner; _languageOption = new Option("--language") { @@ -180,10 +184,8 @@ private Task DropCSharpSingleFileSkeletonAsync(DirectoryInfo workingDirecto return Task.FromResult(ExitCodeConstants.Success); } - private Task DropCSharpProjectSkeletonAsync(FileInfo solutionFile, CancellationToken cancellationToken) + private async Task DropCSharpProjectSkeletonAsync(FileInfo solutionFile, CancellationToken cancellationToken) { - _ = cancellationToken; - var solutionDir = solutionFile.Directory!; var solutionName = Path.GetFileNameWithoutExtension(solutionFile.Name); var appHostDirName = $"{solutionName}.AppHost"; @@ -192,44 +194,33 @@ private Task DropCSharpProjectSkeletonAsync(FileInfo solutionFile, Cancella if (Directory.Exists(appHostDirPath)) { InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"{appHostDirName}/ already exists — skipping."); - return Task.FromResult(ExitCodeConstants.Success); + return ExitCodeConstants.Success; } - Directory.CreateDirectory(appHostDirPath); - - // Drop bare apphost.cs - var appHostContent = """ - var builder = DistributedApplication.CreateBuilder(args); - - // The aspire-init-csharp skill will wire up your projects here. - - builder.Build().Run(); - """; - File.WriteAllText(Path.Combine(appHostDirPath, "apphost.cs"), appHostContent); - - // Drop minimal .csproj - var csprojContent = $""" - - - - Exe - net10.0 - enable - enable - true - - - - - - - - """; - File.WriteAllText(Path.Combine(appHostDirPath, $"{appHostDirName}.csproj"), csprojContent); + // Use the aspire-apphost template to generate a correct AppHost project + // with proper launchSettings.json, .csproj, and Program.cs. + var result = await InteractionService.ShowStatusAsync( + "Creating AppHost from template...", + async () => + { + return await _runner.NewProjectAsync( + "aspire-apphost", + appHostDirName, + appHostDirPath, + extraArgs: [], + options: new ProcessInvocationOptions(), + cancellationToken: cancellationToken); + }); + + if (result != 0) + { + InteractionService.DisplayError($"Failed to create AppHost from template (exit code {result})."); + return ExitCodeConstants.FailedToCreateNewProject; + } InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"Created {appHostDirName}/"); - return Task.FromResult(ExitCodeConstants.Success); + return ExitCodeConstants.Success; } private Task DropPolyglotSkeletonAsync(string languageId, DirectoryInfo workingDirectory, CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index b4a4a3092ab..b7f4c709e92 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -28,6 +28,10 @@ public async Task InitCommand_WhenSolutionAndProjectInSameDirectory_CreatesProje var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, projectFileName)); File.WriteAllText(projectFile.FullName, ""); + string? capturedTemplateName = null; + string? capturedName = null; + string? capturedOutputPath = null; + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.DotNetCliRunnerFactory = _ => @@ -37,6 +41,15 @@ public async Task InitCommand_WhenSolutionAndProjectInSameDirectory_CreatesProje { throw new InvalidOperationException("GetSolutionProjectsAsync should not be called by init."); }; + runner.NewProjectAsyncCallback = (templateName, name, outputPath, _, _) => + { + capturedTemplateName = templateName; + capturedName = name; + capturedOutputPath = outputPath; + // Simulate template creating the directory + Directory.CreateDirectory(outputPath); + return 0; + }; return runner; }; }); @@ -48,8 +61,9 @@ public async Task InitCommand_WhenSolutionAndProjectInSameDirectory_CreatesProje var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.AppHost", "apphost.cs"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.AppHost", "Test.AppHost.csproj"))); + Assert.Equal("aspire-apphost", capturedTemplateName); + Assert.Equal("Test.AppHost", capturedName); + Assert.Contains("Test.AppHost", capturedOutputPath); Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"))); } @@ -61,6 +75,8 @@ public async Task InitCommand_WhenSolutionDirectoryHasNoProjectFiles_CreatesProj var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); File.WriteAllText(solutionFile.FullName, "Fake solution file"); + string? capturedTemplateName = null; + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.DotNetCliRunnerFactory = _ => @@ -70,6 +86,12 @@ public async Task InitCommand_WhenSolutionDirectoryHasNoProjectFiles_CreatesProj { throw new InvalidOperationException("GetSolutionProjectsAsync should not be called by init."); }; + runner.NewProjectAsyncCallback = (templateName, name, outputPath, _, _) => + { + capturedTemplateName = templateName; + Directory.CreateDirectory(outputPath); + return 0; + }; return runner; }; }); @@ -81,8 +103,7 @@ public async Task InitCommand_WhenSolutionDirectoryHasNoProjectFiles_CreatesProj var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.AppHost", "Test.AppHost.csproj"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.AppHost", "apphost.cs"))); + Assert.Equal("aspire-apphost", capturedTemplateName); Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"))); } From 7c58b65ee92b6dcabc418436e93c1a1a9489adb4 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Wed, 8 Apr 2026 18:12:20 -0400 Subject: [PATCH 44/48] =?UTF-8?q?Make=20core=20loop=20expansion=20explicit?= =?UTF-8?q?=20=E2=80=94=20don't=20stop=20early?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add step-by-step loop: validate core → add next batch → validate → repeat until all selected services are wired. Explicitly state the skill is not complete until everything the user selected is running. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/full-solution-apphosts.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.agents/skills/aspire-init/references/full-solution-apphosts.md b/.agents/skills/aspire-init/references/full-solution-apphosts.md index 9d55e73dcfb..d5939db33d3 100644 --- a/.agents/skills/aspire-init/references/full-solution-apphosts.md +++ b/.agents/skills/aspire-init/references/full-solution-apphosts.md @@ -74,12 +74,24 @@ The core loop is typically: 3. Any authentication/identity service 4. The essential cache (Redis, etc.) -Once the core loop works with `aspire start`, add services incrementally. This prevents a failed first experience where 8 services are wired but 3 have config issues that block everything. - Present this explicitly: > *"You have 8 services. I recommend wiring the core loop first — Api, Identity, and the database — so we can validate `aspire start` works. Then we'll add the remaining services. Sound good?"* +**After the core loop succeeds with `aspire start`:** + +1. Stop the AppHost +2. Add the next batch of services (2-3 at a time) to the AppHost +3. Run `aspire start` again to validate +4. If it fails, diagnose and fix before adding more +5. Repeat until all selected services are wired + +Present progress to the user as you go: + +> *"Core loop is working (Api + Identity + Postgres + Redis). Adding the next batch: Admin, Billing, and Events..."* + +Do not consider the skill complete until all services the user selected in Step 3 are wired and `aspire start` runs with all of them healthy. The core loop is a risk-reduction strategy, not an excuse to stop early. + ## Migration runners and setup utilities Migration runners (database migrations, schema updates, data seeders) deserve special handling. They aren't long-running services — they run once and exit. From 4dbde16bfa7c65acdcababf05ad0d0e1d4a4c765 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Thu, 9 Apr 2026 17:07:46 -0400 Subject: [PATCH 45/48] Add Redis auto-TLS, stable SDK, and ServiceDefaults dedup guidance - Document WithoutHttpsCertificate() for plain Redis instead of AddContainer() fallback - Add rule to check/fix preview SDK versions in template-generated AppHost .csproj - Add guidance to search for existing OTel/Polly/health checks before creating ServiceDefaults Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index 17172748b16..f5c00beb398 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -146,6 +146,19 @@ Always refer to the product as just **Aspire**, never ".NET Aspire". This applie When printing or displaying the Aspire dashboard URL to the user, always include the full login token query parameter. The dashboard requires authentication — a bare URL like `http://localhost:18888` won't work. Use the full URL as printed by `aspire start` (e.g., `http://localhost:18888/login?t=`). +### Redis and auto-TLS + +Aspire's infrastructure automatically provisions TLS certificates for container resources that register `WithHttpsCertificateConfiguration` callbacks. `AddRedis()` registers one by default, which means **Redis will get TLS automatically** when the Aspire dev cert infrastructure is active. This is usually fine, but some apps expect plain (non-TLS) Redis. + +If Redis health checks fail with `SslStream` / `RedisConnectionException` errors about SSL/TLS handshake failures, the cause is this auto-TLS behavior. **Do not fall back to `AddContainer()`.** Instead, disable the certificate on the Redis resource: + +```csharp +var redis = builder.AddRedis("redis") + .WithoutHttpsCertificate(); // plain Redis, no TLS +``` + +`WithoutHttpsCertificate()` suppresses the auto-TLS cert injection so Redis stays on plain TCP. Use this when the consuming services don't support TLS Redis connections. + ### Prefer HTTPS over HTTP Always set up HTTPS endpoints by default. Use `WithHttpsEndpoint()` instead of `WithHttpEndpoint()` unless HTTPS doesn't work for a specific integration. @@ -287,6 +300,10 @@ If a web search, documentation page, or blog post tells you to install the workl **Do not modify `` in any existing service project.** If a service targets `net8.0`, leave it on `net8.0`. The Aspire AppHost can orchestrate services on older TFMs without any changes. Only the AppHost itself needs the Aspire-supported TFM. +### Use the latest stable Aspire SDK + +If `aspire init` created a `.csproj` AppHost, check the `Aspire.AppHost.Sdk` version in the `.csproj`. If it references a preview version that isn't available on NuGet (common when using a PR build of the CLI), update it to the latest stable release. Run `dotnet nuget list source` and check NuGet.org for the current stable version (e.g., `13.2.2`). Do not leave the AppHost pinned to an unavailable preview SDK — `dotnet build` will fail. + ### Use the Aspire CLI, not raw dotnet commands, for Aspire operations - Use `aspire start` to launch the AppHost (not `dotnet run`) @@ -427,6 +444,17 @@ Ask the user: If the AppHost is in **full project mode**, consult [references/full-solution-apphosts.md](references/full-solution-apphosts.md) before making ServiceDefaults changes. Some existing solutions need bootstrap updates before `AddServiceDefaults()` and `MapDefaultEndpoints()` can be applied safely. +**Before creating ServiceDefaults, check for existing observability setup.** Many repos already have their own OpenTelemetry, Polly (HTTP resilience), or health check wiring — often in a shared extension method or SDK package. Search for: + +- `AddOpenTelemetry`, `UseOpenTelemetry`, `AddTracing`, `WithTracing`, `WithMetrics` — existing OTel setup +- `AddStandardHttp`, `AddPolicyHandler`, `AddHttpStandardResilienceHandler` — existing Polly/resilience config +- `AddHealthChecks`, `MapHealthChecks` — existing health check registration +- Custom SDK extensions (e.g., `UseBitwardenSdk()`, `UseCompanySdk()`) — these often bundle OTel, health checks, and auth in one call + +If the repo already has OTel/health checks/resilience in a shared extension, **strip those from the generated ServiceDefaults** to avoid duplication. Only keep the parts that don't overlap. For example, if `UseBitwardenSdk()` already sets up OTel tracing and metrics, the ServiceDefaults should skip the OTel builder calls and only add service discovery and health endpoint mapping. + +Present the overlap to the user: *"Your services already set up OpenTelemetry via `UseBitwardenSdk()`. I'll create ServiceDefaults without the OTel setup to avoid duplication."* + **Placement is your decision.** Where to put ServiceDefaults depends on the repo's structure: - If the AppHost has its own nested SDK boundary (nested `global.json`), ServiceDefaults should live next to the AppHost in that same boundary so it can target the same TFM. From e98cb152ec9cb77b2e189f82a72253ddf8aa96b8 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Fri, 10 Apr 2026 13:49:45 -0400 Subject: [PATCH 46/48] Optimize aspire-init skill: deduplicate, split references, trim - Reduce SKILL.md from 1,297 to 664 lines (-49%) - Fix docker-compose password contradiction (example now matches guidance) - Deduplicate rules repeated 3-5x: WithUrlForEndpoint, hardcoded URLs, API lookup, HTTPS guidance, dashboard token - Extract 3 new reference files for progressive disclosure: - apphost-wiring.md: API lookup tiers + wiring patterns (365 lines) - opentelemetry.md: per-language OTel setup recipes (112 lines) - javascript-apps.md: JS resource types + TS config (121 lines) - Trim over-explanations (motivational .env text, OTel intro, etc.) - All references remain one level deep from SKILL.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/SKILL.md | 671 +----------------- .../aspire-init/references/apphost-wiring.md | 365 ++++++++++ .../aspire-init/references/docker-compose.md | 6 +- .../aspire-init/references/javascript-apps.md | 121 ++++ .../aspire-init/references/opentelemetry.md | 112 +++ 5 files changed, 620 insertions(+), 655 deletions(-) create mode 100644 .agents/skills/aspire-init/references/apphost-wiring.md create mode 100644 .agents/skills/aspire-init/references/javascript-apps.md create mode 100644 .agents/skills/aspire-init/references/opentelemetry.md diff --git a/.agents/skills/aspire-init/SKILL.md b/.agents/skills/aspire-init/SKILL.md index f5c00beb398..d2d0e87aced 100644 --- a/.agents/skills/aspire-init/SKILL.md +++ b/.agents/skills/aspire-init/SKILL.md @@ -47,96 +47,13 @@ If you're unsure whether something is a service, whether two services depend on ### Always use latest Aspire APIs — verify before you write -**Do not assume APIs exist.** Aspire evolves fast. Before writing any AppHost code, look up the correct API. Follow this **tiered preference** when choosing how to model a resource: +**Do not assume APIs exist.** Before writing any AppHost code, look up the correct API using `aspire docs search` and `aspire docs get`. Follow the tiered preference: Tier 1 (first-party `Aspire.Hosting.*`) → Tier 2 (community `CommunityToolkit.Aspire.Hosting.*`) → Tier 3 (raw `AddExecutable`/`AddDockerfile`/`AddContainer`). See the "Looking up APIs and integrations" section below for full discovery workflow, tier details, and auto-managed values. -#### Tier 1: First-party Aspire hosting packages (always prefer) - -Packages named `Aspire.Hosting.*` — these are maintained by the Aspire team and ship with every release. Examples: - -| Package | Unlocks | -|---------|---------| -| `Aspire.Hosting.Python` | `AddPythonApp()`, `AddUvicornApp()` | -| `Aspire.Hosting.JavaScript` | `AddJavaScriptApp()`, `AddNodeApp()`, `AddViteApp()`, `.WithYarn()`, `.WithPnpm()` | -| `Aspire.Hosting.PostgreSQL` | `AddPostgres()`, `AddDatabase()` | -| `Aspire.Hosting.Redis` | `AddRedis()` | - -#### Tier 2: Community Toolkit packages (use when no first-party exists) - -Packages named `CommunityToolkit.Aspire.Hosting.*` — maintained by the community, documented on aspire.dev, and installable via `aspire add`. Examples: - -| Package | Unlocks | -|---------|---------| -| `CommunityToolkit.Aspire.Hosting.Golang` | `AddGolangApp()` — handles `go run .`, working dir, PORT env | -| `CommunityToolkit.Aspire.Hosting.Rust` | `AddRustApp()` | -| `CommunityToolkit.Aspire.Hosting.Java` | Java hosting support | - -These provide typed APIs with proper endpoint handling, health checks, and dashboard integration — significantly better than raw executables. - -#### Tier 3: Raw fallbacks (last resort) - -`AddExecutable()`, `AddDockerfile()`, `AddContainer()` — use only when no Tier 1 or Tier 2 package exists for the technology, or when the user's setup is too custom for a typed integration. - -#### How to discover available packages - -Before writing any builder call: - -1. Run `aspire docs search ""` (e.g., `aspire docs search "golang"`, `aspire docs search "python"`) -2. Run `aspire docs get ""` to read the full API surface and installation instructions -3. Run `aspire list integrations` to see all available packages (requires Aspire MCP — if unavailable, rely on docs search) -4. Install with `aspire add ` (e.g., `aspire add communitytoolkit-golang`) -5. For TypeScript, run `aspire restore` then check `.modules/aspire.ts` to see what's available - -**Don't invent APIs** — if the docs search and integration list don't return it, it doesn't exist. Fall back to Tier 3 and note the limitation to the user. - -**API shapes differ between C# and TypeScript** — always check the correct language docs. +**Don't invent APIs** — if docs search and integration list don't return it, it doesn't exist. Fall back to Tier 3. **API shapes differ between C# and TypeScript** — always check the correct language docs. ### Choosing the right JavaScript resource type -The `Aspire.Hosting.JavaScript` package provides three resource types. Pick the right one: - -| Signal | Use | Example | -|--------|-----|---------| -| Vite app (has `vite.config.*`) | `AddViteApp(name, dir)` | Frontend SPA, Vite + React/Vue/Svelte | -| App runs via package.json script only | `AddJavaScriptApp(name, dir, { runScriptName })` | CRA app, Next.js, monorepo root scripts | -| App has a specific Node entry file (`.js`/`.ts`) and uses a dev script like `ts-node-dev` | `AddNodeApp(name, dir, "entry.js")` + `.WithRunScript("start:dev")` | Express/Fastify API, Socket.IO server | - -**Key distinctions:** -- `AddNodeApp` is for apps that run a **specific file** with Node (e.g., an Express server at `src/index.ts`). Use `.WithRunScript("start:dev")` to override the dev-time command (e.g., `ts-node-dev`). -- `AddJavaScriptApp` runs a **package.json script** — simpler, good when the script handles everything. -- `AddViteApp` is `AddJavaScriptApp` with Vite-specific defaults (auto-HTTPS config augmentation, `dev` as default script). - -### JavaScript dev scripts - -Use `.WithRunScript()` to control which package.json script runs during development: - -```typescript -// Express API with TypeScript: uses ts-node-dev for hot reload in dev -const api = await builder - .addNodeApp("api", "./api", "src/index.ts") - .withRunScript("start:dev") // runs "yarn start:dev" (ts-node-dev) - .withYarn() - .withHttpEndpoint({ env: "PORT" }); - -// Vite frontend: default "dev" script is fine, just add yarn -const web = await builder - .addViteApp("web", "./frontend") - .withYarn(); -``` - -### Framework-specific port binding - -Not all frameworks read ports from env vars the same way: - -| Framework | Port mechanism | AppHost pattern | -|-----------|---------------|-----------------| -| Express/Fastify | `process.env.PORT` | `.withHttpEndpoint({ env: "PORT" })` | -| Vite | `--port` CLI arg or `server.port` in config | `.withHttpEndpoint({ env: "PORT" })` — Aspire's Vite integration handles this automatically | -| Next.js | `PORT` env or `--port` | `.withHttpEndpoint({ env: "PORT" })` | -| CRA | `PORT` env | `.withHttpEndpoint({ env: "PORT" })` | - -When the framework supports reading the port from an env var or Aspire already handles it, **prefer that over pinning a fixed port**. Managed ports make repeated local runs more reliable and work better when multiple services or multiple Aspire apps are running. - -**Suppress auto-browser-open:** Many dev servers (Vite, CRA, Next.js) auto-open a browser on start. Add `.withEnvironment("BROWSER", "none")` to prevent this in Aspire-managed apps. Vite also respects `server.open: false` in its config. +For JavaScript/TypeScript apps, pick the right resource type (`AddViteApp`, `AddNodeApp`, or `AddJavaScriptApp`) and configure dev scripts and port binding. See [references/javascript-apps.md](references/javascript-apps.md) for the selection table, dev script patterns, framework-specific port binding, and browser suppression. ### Never call it ".NET Aspire" @@ -161,40 +78,11 @@ var redis = builder.AddRedis("redis") ### Prefer HTTPS over HTTP -Always set up HTTPS endpoints by default. Use `WithHttpsEndpoint()` instead of `WithHttpEndpoint()` unless HTTPS doesn't work for a specific integration. - -For JavaScript and Python apps, call `WithHttpsDeveloperCertificate()` to configure the ASP.NET Core dev cert for serving HTTPS. Some apps may also need `WithDeveloperCertificateTrust(true)` so they trust the dev cert for outbound calls (e.g., to the dashboard OTLP collector). If HTTPS causes issues for a specific resource, fall back to HTTP and leave a comment explaining why. - -```csharp -// JavaScript/Vite — HTTPS with dev cert -var frontend = builder.AddViteApp("frontend", "../frontend") - .WithHttpsDeveloperCertificate() - .WithHttpsEndpoint(env: "PORT"); - -// Python — HTTPS with dev cert -var pyApi = builder.AddUvicornApp("py-api", "../py-api", "app:main") - .WithHttpsDeveloperCertificate(); - -// .NET — HTTPS works out of the box, no extra config needed -var api = builder.AddCSharpApp("api", "../src/Api"); -``` - -> **Note**: These certificate APIs are experimental (`ASPIRECERTIFICATES001`). Use `aspire docs search "certificate configuration"` to check the latest API shape. If `WithHttpsDeveloperCertificate` causes errors for a resource type, fall back to `WithHttpEndpoint()`. +Always set up HTTPS endpoints by default. Use `WithHttpsEndpoint()` instead of `WithHttpEndpoint()` unless HTTPS doesn't work for a specific integration. For JavaScript and Python apps, call `WithHttpsDeveloperCertificate()` to configure the dev cert. If HTTPS causes issues for a specific resource, fall back to HTTP and leave a comment explaining why. See the "Endpoints and ports" section in the AppHost wiring reference below for detailed patterns and examples. ### Never hardcode URLs — use endpoint references -When a service needs another service's URL as an environment variable, **always** pass an endpoint reference — never a hardcoded string. Hardcoded URLs break whenever Aspire assigns different ports. - -```typescript -// ✅ CORRECT — endpoint reference, Aspire resolves the actual URL at runtime -const roomEndpoint = await room.getEndpoint("http"); -builder.addViteApp("frontend", "./frontend") - .withEnvironment("VITE_APP_WS_SERVER_URL", roomEndpoint); - -// ❌ WRONG — hardcoded URL, breaks when ports change -builder.addViteApp("frontend", "./frontend") - .withEnvironment("VITE_APP_WS_SERVER_URL", "http://localhost:3002"); -``` +When a service needs another service's URL as an environment variable, **always** pass an endpoint reference — never a hardcoded string. Hardcoded URLs break whenever Aspire assigns different ports. See the "Cross-service environment variable wiring" section in the AppHost wiring reference below for examples. Similarly, **never use `withUrlForEndpoint` / `WithUrlForEndpoint` to set `dev.localhost` URLs**. That API is ONLY for setting display labels in the dashboard (e.g., `url.DisplayText = "Web UI"`). `dev.localhost` configuration belongs in `aspire.config.json` profiles — see Step 9. @@ -242,11 +130,6 @@ var api = builder.AddCSharpApp("api", "../src/Api") .WithEnvironment("DEBUG", "true"); // plain config ``` -**The goal is to make `.env` files unnecessary** so all configuration flows through the AppHost. This means: -- No more "did you copy the .env.example?" onboarding friction -- Secrets are stored securely (not in plaintext files that get accidentally committed) -- All service config is visible in one place (the dashboard) - **Important: Never delete `.env` files automatically.** After migrating all values into the AppHost, explicitly ask the user: > "I've migrated all the values from your `.env` file into the AppHost. The `.env` file is no longer needed for running via Aspire, but it still works for non-Aspire workflows. Would you like me to remove it, or keep it around?" @@ -262,7 +145,7 @@ Present this as a recommendation. Walk through the `.env` contents with the user - `` in `.csproj` files — indicates the project uses User Secrets - Documentation referencing `dotnet user-secrets set` or `setup_secrets` scripts -**Aspire's `AddParameter(name, secret: true)` stores values in the same .NET User Secrets store under the hood.** This means migration is seamless — the developer's existing workflow stays nearly identical, but secrets are now centralized in the AppHost instead of scattered across individual service projects. +**Aspire's `AddParameter(name, secret: true)` stores values in the same .NET User Secrets store under the hood**, so secrets are centralized in the AppHost instead of scattered across individual service projects. Migration approach: @@ -594,8 +477,7 @@ Always check `aspire list integrations` and `aspire docs search ""` to **Important rules:** -- Use `aspire docs search` and `aspire docs get` to look up the correct builder API for each resource type. Do not guess API shapes. -- Check `.modules/aspire.ts` (TypeScript) or NuGet package APIs (C#) to confirm available methods. +- **Look up APIs before writing code** — see "Looking up APIs and integrations" section below. Do not guess API shapes. - Use meaningful resource names derived from the project/directory name. - Wire up `WithReference()`/`withReference()` and `WaitFor()`/`waitFor()` for services that depend on each other (ask the user if relationships are unclear). - Use `WithExternalHttpEndpoints()`/`withExternalHttpEndpoints()` for user-facing frontends. @@ -604,67 +486,11 @@ Always check `aspire list integrations` and `aspire docs search ""` to #### TypeScript AppHost -**package.json** — if one exists at the root, augment it (do not overwrite). Add/merge these scripts that delegate to the Aspire CLI: - -```json -{ - "type": "module", - "scripts": { - "dev": "aspire run", - "build": "tsc", - "watch": "tsc --watch" - } -} -``` - -If no root `package.json` exists, create a minimal one matching the canonical Aspire template: - -```json -{ - "name": "", - "private": true, - "type": "module", - "scripts": { - "dev": "aspire run", - "build": "tsc", - "watch": "tsc --watch" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } -} -``` - -**Important**: Scripts should point to `aspire run`/`aspire start` — the Aspire CLI handles TypeScript compilation internally. Do not use `npx tsc && node apphost.js` patterns. - -Never overwrite existing `scripts`, `dependencies`, or `devDependencies` — merge only. Do not manually add Aspire SDK packages — `aspire restore` handles those. - -Run `aspire restore` to generate the `.modules/` directory with TypeScript SDK bindings, then install dependencies with the repo's package manager (`npm install`, `pnpm install`, or `yarn`). - -**tsconfig.json** — augment if it exists: - -- Ensure `".modules/**/*.ts"` and `"apphost.ts"` are in `include` -- Ensure `"module"` is `"nodenext"` or `"node16"` (ESM required) -- Ensure `"moduleResolution"` matches - -If no `tsconfig.json` exists and `aspire restore` didn't create one, create a minimal one: - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "nodenext", - "moduleResolution": "nodenext", - "esModuleInterop": true, - "strict": true, - "outDir": "./dist", - "rootDir": "." - }, - "include": ["apphost.ts", ".modules/**/*.ts"] -} -``` +See [references/javascript-apps.md](references/javascript-apps.md) for `package.json`, `tsconfig.json`, and ESLint configuration patterns. Key points: -**ESLint** — only augment if config already exists. If it uses `parserOptions.project` or `parserOptions.projectService`, ensure the AppHost tsconfig is discoverable. Do not create ESLint configuration from scratch. +- Augment existing files, never overwrite +- Run `aspire restore` to generate `.modules/`, then install deps with the repo's package manager +- Do not manually add Aspire SDK packages — `aspire restore` handles those #### C# AppHost @@ -702,123 +528,14 @@ Be careful with code placement — look at existing structure (top-level stateme ### Step 8: Wire up OpenTelemetry -OpenTelemetry makes your services' traces, metrics, and logs visible in the Aspire dashboard. For .NET services, ServiceDefaults handles this automatically. For everything else, the services need a small setup to export telemetry. Aspire automatically injects `OTEL_EXPORTER_OTLP_ENDPOINT` into all managed resources — the services just need to read it. +For .NET services, ServiceDefaults handles OTel automatically. For everything else, the services need a small setup to export telemetry. Aspire automatically injects `OTEL_EXPORTER_OTLP_ENDPOINT` into all managed resources — the services just need to read it. **Present this to the user as an option, not a mandatory step.** Some users may want to add OTel later, and that's fine — their services will still run, they just won't appear in the dashboard's trace/metrics views. **For each service that doesn't already have OTel, ask:** > "Would you like me to add OpenTelemetry instrumentation to ``? This lets the Aspire dashboard show its traces, metrics, and logs. I'll need to add a few packages and an instrumentation setup file." -If they say yes, follow the per-language guide below. - -#### Node.js/TypeScript services - -```bash -# Use the repo's package manager (npm/pnpm/yarn) -npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-otlp-grpc -# or: pnpm add ... -# or: yarn add ... -``` - -Create an instrumentation file (e.g., `instrumentation.ts` or `instrumentation.js`): - -```typescript -import { NodeSDK } from '@opentelemetry/sdk-node'; -import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; -import { OTLPTraceExporter } from '@opentelemetry/exporter-otlp-grpc'; -import { OTLPMetricExporter } from '@opentelemetry/exporter-otlp-grpc'; -import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; - -const sdk = new NodeSDK({ - traceExporter: new OTLPTraceExporter(), - metricReader: new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter(), - }), - instrumentations: [getNodeAutoInstrumentations()], - serviceName: process.env.OTEL_SERVICE_NAME, -}); - -sdk.start(); -``` - -Then ensure the service loads it early — either via `--require`/`--import` in the start script or by importing it as the first line of the entry point. - -#### Python services - -```bash -pip install opentelemetry-distro opentelemetry-exporter-otlp -opentelemetry-bootstrap -a install # auto-detect and install framework instrumentations -``` - -Add to the service's startup (e.g., top of `main.py` or as a separate `instrumentation.py`): - -```python -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor -from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter -from opentelemetry.sdk.metrics import MeterProvider -from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader -from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter -from opentelemetry import trace, metrics -import os - -resource = Resource.create({"service.name": os.environ.get("OTEL_SERVICE_NAME", "unknown")}) - -# Traces -trace.set_tracer_provider(TracerProvider(resource=resource)) -trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) - -# Metrics -metrics.set_meter_provider(MeterProvider( - resource=resource, - metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())], -)) -``` - -Or more simply, run with the auto-instrumentation wrapper: - -```bash -opentelemetry-instrument uvicorn main:app --host 0.0.0.0 -``` - -#### Go services - -```bash -go get go.opentelemetry.io/otel -go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc -go get go.opentelemetry.io/otel/sdk/trace -go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp -``` - -Add initialization in `main()`: - -```go -import ( - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" - sdktrace "go.opentelemetry.io/otel/sdk/trace" -) - -func initTracer() func() { - exporter, _ := otlptracegrpc.New(context.Background()) - tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter)) - otel.SetTracerProvider(tp) - return func() { tp.Shutdown(context.Background()) } -} -``` - -Wrap HTTP handlers with `otelhttp.NewHandler()` for automatic HTTP span creation. - -#### Java services - -Point the user to the [OpenTelemetry Java Agent](https://opentelemetry.io/docs/zero-code/java/agent/) — it's the easiest approach: - -```bash -java -javaagent:opentelemetry-javaagent.jar -jar myapp.jar -``` - -The agent auto-instruments common frameworks. Aspire injects `OTEL_EXPORTER_OTLP_ENDPOINT` automatically. +If they say yes, follow the per-language setup guides in [references/opentelemetry.md](references/opentelemetry.md). ### Step 9: Offer dev experience enhancements @@ -831,8 +548,6 @@ Before validating, present the user with optional quality-of-life improvements. **How to do it:** Update the `profiles` section in `aspire.config.json` — replace `localhost` with `.dev.localhost` in `applicationUrl`, and use descriptive subdomains like `otlp.dev.localhost` and `resources.dev.localhost` for the infrastructure URLs. This is the same mechanism `aspire new` uses. - > ⚠️ **Do NOT use `withUrlForEndpoint` / `WithUrlForEndpoint` in the AppHost for `dev.localhost`** — the config file is the right place. `withUrlForEndpoint` is ONLY for dashboard display labels. - Real-world example: ```json @@ -851,11 +566,10 @@ Before validating, present the user with optional quality-of-life improvements. Use the project/repo name (lowercased) as the subdomain prefix for `applicationUrl`. Use `otlp` and `resources` for the infrastructure URLs. Keep the existing port numbers — just swap `localhost` for the appropriate `*.dev.localhost` subdomain. -2. **Custom URL labels in the dashboard** (display text only): Rename endpoint URLs in the Aspire dashboard for clarity. This is the ONLY valid use of `withUrlForEndpoint` — setting `DisplayText`, nothing else: +2. **Custom URL labels in the dashboard** (display text only): Rename endpoint URLs in the Aspire dashboard for clarity: ```csharp .WithUrlForEndpoint("https", url => url.DisplayText = "Web UI") ``` - Never set `url.Url` in this callback — that's what `aspire.config.json` profiles are for. 3. **OpenTelemetry** (if not done in Step 8): "Would you like me to add observability to your services so they appear in the Aspire dashboard's traces and metrics views?" @@ -873,7 +587,7 @@ Once the app is running, use the Aspire CLI to verify everything is wired up cor 2. **Environment flows correctly**: `aspire describe` — check that environment variables (connection strings, ports, secrets from parameters) are injected into each resource as expected. Verify `.env` values that were migrated to parameters are present. 3. **OTel is flowing** (if configured in Step 8): `aspire otel` — verify that services instrumented with OpenTelemetry are exporting traces and metrics to the Aspire dashboard collector. 4. **No startup errors**: `aspire logs ` — check logs for each resource to ensure clean startup with no crashes, missing config, or connection failures. -5. **Dashboard is accessible**: Confirm the dashboard URL (including the login token) is printed and can be opened. The full URL looks like `http://localhost:18888/login?t=` — always include the token. +5. **Dashboard is accessible**: Confirm the dashboard URL is printed and can be opened (remember: include the login token — see "Dashboard URL must include auth token" above). **This skill is not done until `aspire start` runs without errors and every resource is in an expected terminal/runtime state.** Acceptable end states are: @@ -904,7 +618,7 @@ Resources: ``` -Get the dashboard URL from `aspire start` output (always include the `?t=` parameter). Get resource status from `aspire describe`. If any resource shows `Finished`, confirm from logs that it was an intentional one-shot resource that exited successfully before including it as success. This summary is the user's confirmation that init worked — make it complete and accurate. +Get the dashboard URL (with login token) from `aspire start` output. Get resource status from `aspire describe`. If any resource shows `Finished`, confirm from logs that it was an intentional one-shot resource that exited successfully before including it as success. This summary is the user's confirmation that init worked — make it complete and accurate. Common issues: @@ -937,361 +651,14 @@ After successful validation: - **Never overwrite existing files** — always augment/merge - **Ask the user before modifying service code** (especially OTel and ServiceDefaults injection) - **Respect existing project structure** — don't reorganize the repo -- **This is a one-time skill** — delete it after successful init - **If stuck, use `aspire doctor`** to diagnose environment issues - **Never hardcode URLs in `withEnvironment`** — when a service needs another service's URL (e.g., `VITE_APP_WS_SERVER_URL`), pass an endpoint reference, NOT a string literal. Use `room.getEndpoint("http")` (TS) or `room.GetEndpoint("http")` (C#) and pass that to `withEnvironment`. Hardcoded URLs break when ports change. - **Never use `withUrlForEndpoint` to set `dev.localhost` URLs** — `dev.localhost` configuration belongs in `aspire.config.json` profiles, not in AppHost code. `withUrlForEndpoint` is ONLY for setting display labels (e.g., `url.DisplayText = "Web UI"`). -## Looking up APIs and integrations - -Before writing AppHost code for an unfamiliar resource type or integration, **always** look it up. Follow the tiered preference from the principles section (first-party → community toolkit → raw fallbacks). - -```bash -# Search for documentation on a topic -aspire docs search "redis" -aspire docs search "golang" -aspire docs search "python uvicorn" - -# Get a specific doc page by slug (returned from search results) -aspire docs get "redis-integration" -aspire docs get "go-integration" - -# List ALL available integrations (first-party and community toolkit) -# Note: requires the Aspire MCP server to be connected. If this fails, use aspire docs search instead. -aspire list integrations -``` - -Use `aspire docs search` to find the right builder methods, configuration options, and patterns. Use `aspire docs get ` to read the full doc page. Use `aspire list integrations` to discover packages you might not have known about. Do not guess API shapes — Aspire has many resource types with specific overloads. - -### Check what integrations auto-manage - -Before modeling environment variables, passwords, ports, or volumes for a typed integration, **check the docs to see what the integration handles automatically**. Many typed integrations auto-generate passwords, manage ports dynamically, and handle volumes — duplicating this config causes errors or conflicts. - -```bash -# Check what AddPostgres manages automatically -aspire docs get "postgresql-hosting-integration" --section "Connection properties" - -# Check what AddSqlServer manages -aspire docs get "sql-server-integration" --section "Hosting integration" -``` - -Look for the **"Connection properties"** section — it lists what the integration injects into consuming services. If it lists `Password`, `Host`, `Port` — the integration manages those. Do not create `AddParameter()` for values the integration already handles. - -Common auto-managed values (do NOT model these manually): - -| Integration | Auto-managed | -|-------------|-------------| -| `AddPostgres()` | Password, host, port, connection string | -| `AddSqlServer()` | SA password, host, port, connection string | -| `AddRedis()` | Connection string, port | -| `AddMySql()` | Root password, host, port, connection string | -| `AddRabbitMQ()` | Username, password, host, port, connection string | -| `AddMongoDB()` | Connection string, port | - -To add an integration package (which unlocks typed builder methods): - -```bash -# First-party -aspire add redis -aspire add python -aspire add nodejs - -# Community Toolkit -aspire add communitytoolkit-golang -aspire add communitytoolkit-rust -``` - -After adding, run `aspire restore` (TypeScript) or `dotnet restore` (C#) to update available APIs, then check what methods are now available. - -**Always prefer a typed integration over raw `AddExecutable`/`AddContainer`.** Typed integrations handle working directories, port injection, health checks, and dashboard integration automatically. - ## References +- For AppHost wiring patterns, API lookup, endpoint configuration, and resource wiring, see [references/apphost-wiring.md](references/apphost-wiring.md). - For solution-backed C# AppHosts (`.sln`/`.slnx` + `.csproj` AppHost), see [references/full-solution-apphosts.md](references/full-solution-apphosts.md). - For repos with `docker-compose.yml` or `compose.yml`, see [references/docker-compose.md](references/docker-compose.md). - -## AppHost wiring reference - -This section covers the patterns you'll need when writing Step 5 (Wire up the AppHost). Refer back to it as needed. - -### Service communication: `WithReference` vs `WithEnvironment` - -**`WithReference()`** is the primary way to connect services. It does two things: - -1. Injects the referenced resource's connection information (connection string or URL) into the consuming service -2. Enables Aspire service discovery — .NET services can resolve the referenced resource by name - -```csharp -// C#: api gets the database connection string injected automatically -var db = builder.AddPostgres("pg").AddDatabase("mydb"); -var api = builder.AddCSharpApp("api", "../src/Api") - .WithReference(db); - -// C#: frontend gets service discovery URL for api -var frontend = builder.AddCSharpApp("web", "../src/Web") - .WithReference(api); -``` - -```typescript -// TypeScript equivalent -const db = await builder.addPostgres("pg").addDatabase("mydb"); -const api = await builder.addCSharpApp("api", "./src/Api") - .withReference(db); -``` - -**How services consume references**: Services receive connection info as environment variables. The naming convention is: -- Connection strings: `ConnectionStrings__` (e.g., `ConnectionStrings__mydb=Host=...`) -- Service URLs: `services______0` (e.g., `services__api__http__0=http://localhost:5123`) - -**`WithEnvironment()`** injects raw environment variables. Use this for custom config that isn't a service reference: - -```csharp -var api = builder.AddCSharpApp("api", "../src/Api") - .WithEnvironment("FEATURE_FLAG_X", "true") - .WithEnvironment("API_KEY", someParameter); -``` - -**When to use which:** -- Connecting service A to service B or a database/cache/queue → `WithReference()` -- Passing configuration values, feature flags, API keys → `WithEnvironment()` -- Never manually construct connection strings with `WithEnvironment()` when `WithReference()` would work - -### Endpoints and ports - -**Prefer HTTPS by default.** Use `WithHttpsEndpoint()` for all services and fall back to `WithHttpEndpoint()` only if HTTPS doesn't work for that resource. - -**Prefer Aspire-managed ports by default.** For most local development scenarios, let Aspire assign the port and inject it into the service. This avoids port collisions, makes multiple AppHosts easier to run side-by-side, and keeps cross-service wiring flexible. - -**Ask before pinning a fixed port.** If the repo already uses a hardcoded port, do **not** silently preserve it just because it exists. Ask whether that port is actually required. Good reasons to keep a fixed port include: - -- OAuth/callback URLs or external webhooks that expect a stable local address -- Browser extensions or desktop/mobile clients that are already hardcoded to a specific port -- Repo docs, scripts, or test tooling that explicitly depend on that exact port - -If none of those apply, steer the user toward managed ports. - -**`WithHttpsEndpoint()`** — expose an HTTPS endpoint. For services that serve traffic: - -```csharp -// Let Aspire assign a random port (recommended for most cases) -var api = builder.AddCSharpApp("api", "../src/Api") - .WithHttpsEndpoint(); - -// Use a specific port only when the user confirms it is required -var api = builder.AddCSharpApp("api", "../src/Api") - .WithHttpsEndpoint(port: 5001); - -// For services that read the port from an env var -var nodeApi = builder.AddJavaScriptApp("api", "../api", "start") - .WithHttpsDeveloperCertificate() - .WithHttpsEndpoint(env: "PORT"); // Aspire injects PORT= -``` - -**`WithHttpsDeveloperCertificate()`** — required for JavaScript and Python apps to serve HTTPS. Configures the ASP.NET Core dev cert. .NET apps handle this automatically. - -```csharp -var frontend = builder.AddViteApp("frontend", "../frontend") - .WithHttpsDeveloperCertificate(); - -var pyApi = builder.AddUvicornApp("api", "../api", "app:main") - .WithHttpsDeveloperCertificate(); -``` - -> If `WithHttpsDeveloperCertificate()` causes issues for a resource, fall back to `WithHttpEndpoint()` and leave a comment explaining why. - -**`WithHttpEndpoint()`** — fallback for HTTP when HTTPS doesn't work: - -```csharp -// Use when HTTPS causes issues with a specific integration -var legacy = builder.AddJavaScriptApp("legacy", "../legacy", "start") - .WithHttpEndpoint(env: "PORT"); // HTTP fallback -``` - -**`WithEndpoint()`** — expose a non-HTTP endpoint (gRPC, TCP, custom protocols): - -```csharp -var grpcService = builder.AddCSharpApp("grpc", "../src/GrpcService") - .WithEndpoint("grpc", endpoint => - { - endpoint.Port = 5050; - endpoint.Protocol = "grpc"; - }); -``` - -**`WithExternalHttpEndpoints()`** — mark a resource's HTTP endpoints as externally visible. Use this for user-facing frontends so the URL appears prominently in the dashboard: - -```csharp -var frontend = builder.AddViteApp("frontend", "../frontend") - .WithHttpsDeveloperCertificate() - .WithHttpsEndpoint(env: "PORT") - .WithExternalHttpEndpoints(); -``` - -**Port injection**: Many frameworks (Express, Vite, Flask) need to know which port to listen on. Use the `env:` parameter: -- `withHttpsEndpoint({ env: "PORT" })` (TypeScript) -- `.WithHttpsEndpoint(env: "PORT")` (C#) - -Aspire assigns a port and injects it as the specified environment variable. The service should read it and listen on that port. - -**Recommended ask when a repo already hardcodes ports:** - -> "I found this service pinned to port 3000 today. Unless that exact port is needed for an external callback or another hard requirement, I recommend switching it to read PORT from env and letting Aspire manage the port. That avoids collisions and makes the AppHost more portable. Should I keep 3000 or make it Aspire-managed?" - -### Cross-service environment variable wiring - -When a service expects a **specific env var name** for a dependency's URL (not the standard `services__` format from `WithReference`), use `WithEnvironment` with an endpoint reference — **never a hardcoded string**: - -```typescript -// ✅ CORRECT — endpoint reference resolves to the actual URL at runtime -const roomEndpoint = await room.getEndpoint("http"); - -const frontend = await builder - .addViteApp("frontend", "./frontend") - .withEnvironment("VITE_APP_WS_SERVER_URL", roomEndpoint) // EndpointReference, not a string - .withReference(room) // also sets up standard service discovery - .waitFor(room); - -// ❌ WRONG — hardcoded URL breaks when Aspire assigns different ports - .withEnvironment("VITE_APP_WS_SERVER_URL", "http://localhost:3002") // NEVER DO THIS -``` - -```csharp -// C# equivalent -var roomEndpoint = room.GetEndpoint("http"); -var frontend = builder.AddViteApp("frontend", "../frontend") - .WithEnvironment("VITE_APP_WS_SERVER_URL", roomEndpoint) - .WithReference(room) - .WaitFor(room); -``` - -Use `WithEnvironment(name, endpointRef)` when the consuming service reads a **specific env var name**. Use `WithReference()` when the service uses Aspire service discovery or standard connection string patterns. You can use both together. - -### URL labels and dashboard niceties - -Customize how endpoints appear in the Aspire dashboard: - -```csharp -// Named endpoints for clarity -var api = builder.AddCSharpApp("api", "../src/Api") - .WithHttpsEndpoint(name: "public", port: 8443) - .WithHttpsEndpoint(name: "internal", port: 8444); -``` - -**Cookie/session isolation with `dev.localhost`**: When multiple services share `localhost`, cookies and session storage can leak between them. Using `*.dev.localhost` subdomains gives each service its own cookie scope. URLs still have ports (e.g., `frontend.dev.localhost:5173`), but the subdomain isolation prevents cross-service collisions. - -**The right way**: Update `applicationUrl` in the `profiles` section of `aspire.config.json` — replace `localhost` with `.dev.localhost`, and use `otlp.dev.localhost` / `resources.dev.localhost` for infrastructure URLs. **Never** use `withUrlForEndpoint` to set `dev.localhost` URLs — that API is ONLY for dashboard display labels. Example: - -```json -{ - "profiles": { - "https": { - "applicationUrl": "https://myapp.dev.localhost:17042;http://myapp.dev.localhost:15042", - "environmentVariables": { - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://otlp.dev.localhost:21042", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://resources.dev.localhost:22042" - } - } - } -} -``` - -> Note: `*.dev.localhost` resolves to `127.0.0.1` on most systems without any `/etc/hosts` changes. - -### Dependency ordering: `WaitFor` and `WaitForCompletion` - -**`WaitFor()`** — delay starting a resource until another resource is healthy/ready: - -```csharp -var db = builder.AddPostgres("pg").AddDatabase("mydb"); -var api = builder.AddCSharpApp("api", "../src/Api") - .WithReference(db) - .WaitFor(db); // Don't start api until db is healthy -``` - -Always pair `WithReference()` with `WaitFor()` for infrastructure dependencies (databases, caches, queues). Services that depend on other services should generally also wait for them. - -**`WaitForCompletion()`** — wait for a resource to run to completion (exit successfully). Use for init containers, database migrations, or seed data scripts: - -```csharp -var migration = builder.AddCSharpApp("migration", "../src/MigrationRunner") - .WithReference(db) - .WaitFor(db); - -var api = builder.AddCSharpApp("api", "../src/Api") - .WithReference(db) - .WaitFor(db) - .WaitForCompletion(migration); // Don't start until migration finishes -``` - -### Container lifetimes - -By default, containers are stopped when the AppHost stops. Use **persistent lifetime** to keep containers running across restarts (useful for databases during development): - -```csharp -var db = builder.AddPostgres("pg") - .WithLifetime(ContainerLifetime.Persistent); -``` - -This prevents data loss when restarting the AppHost — the container stays running and the AppHost reconnects. - -**TypeScript equivalent:** - -```typescript -const db = await builder.addPostgres("pg") - .withLifetime("persistent"); -``` - -Recommend persistent lifetime for databases and caches during local development. - -### Explicit start (manual start) - -Some resources shouldn't auto-start with the AppHost. Mark them for explicit start: - -```csharp -var debugTool = builder.AddContainer("profiler", "myregistry/profiler") - .WithLifetime(ContainerLifetime.Persistent) - .ExcludeFromManifest() - .WithExplicitStart(); -``` - -The resource appears in the dashboard but stays stopped until the user manually starts it. Useful for debugging tools, admin UIs, or optional services. - -### Parent resources (grouping in the dashboard) - -Group related resources under a parent for a cleaner dashboard: - -```csharp -var postgres = builder.AddPostgres("pg"); -var ordersDb = postgres.AddDatabase("orders"); -var inventoryDb = postgres.AddDatabase("inventory"); -// ordersDb and inventoryDb appear nested under pg in the dashboard -``` - -This happens automatically for databases added to a server resource. For custom grouping of arbitrary resources, use `WithParentRelationship()`: - -```csharp -var backend = builder.AddResource(new ContainerResource("backend-group")); -var api = builder.AddCSharpApp("api", "../src/Api") - .WithParentRelationship(backend); -var worker = builder.AddCSharpApp("worker", "../src/Worker") - .WithParentRelationship(backend); -``` - -Use `aspire docs search "parent relationship"` to verify the current API shape. - -### Volumes and data persistence - -```csharp -// Named volume (managed by Docker, persists across container recreations) -var db = builder.AddPostgres("pg") - .WithDataVolume("pg-data"); - -// Bind mount (maps to a host directory) -var db = builder.AddPostgres("pg") - .WithBindMount("./data/pg", "/var/lib/postgresql/data"); -``` - -```typescript -const db = await builder.addPostgres("pg") - .withDataVolume("pg-data"); -``` +- For per-language OpenTelemetry setup (Node, Python, Go, Java), see [references/opentelemetry.md](references/opentelemetry.md). +- For JavaScript/TypeScript resource types, dev scripts, and TypeScript AppHost config, see [references/javascript-apps.md](references/javascript-apps.md). diff --git a/.agents/skills/aspire-init/references/apphost-wiring.md b/.agents/skills/aspire-init/references/apphost-wiring.md new file mode 100644 index 00000000000..2d3823f56ad --- /dev/null +++ b/.agents/skills/aspire-init/references/apphost-wiring.md @@ -0,0 +1,365 @@ +# AppHost wiring and API lookup reference + +Use this reference when writing Step 5 (Wire up the AppHost) or when you need to look up Aspire APIs, integration packages, or wiring patterns. + +> **⚠️ Always look up APIs before writing code.** Do not guess builder method names or parameter shapes. Use `aspire docs search ""` and `aspire docs get ""` to confirm the correct API. + +## Looking up APIs and integrations + +Before writing AppHost code for an unfamiliar resource type or integration, **always** look it up. **Do not assume APIs exist or guess their shapes** — Aspire has many resource types with specific overloads. + +### Tiered preference for modeling resources + +#### Tier 1: First-party Aspire hosting packages (always prefer) + +Packages named `Aspire.Hosting.*` — maintained by the Aspire team and ship with every release. Examples: + +| Package | Unlocks | +|---------|---------| +| `Aspire.Hosting.Python` | `AddPythonApp()`, `AddUvicornApp()` | +| `Aspire.Hosting.JavaScript` | `AddJavaScriptApp()`, `AddNodeApp()`, `AddViteApp()`, `.WithYarn()`, `.WithPnpm()` | +| `Aspire.Hosting.PostgreSQL` | `AddPostgres()`, `AddDatabase()` | +| `Aspire.Hosting.Redis` | `AddRedis()` | + +#### Tier 2: Community Toolkit packages (use when no first-party exists) + +Packages named `CommunityToolkit.Aspire.Hosting.*` — maintained by the community, documented on aspire.dev, and installable via `aspire add`. Examples: + +| Package | Unlocks | +|---------|---------| +| `CommunityToolkit.Aspire.Hosting.Golang` | `AddGolangApp()` — handles `go run .`, working dir, PORT env | +| `CommunityToolkit.Aspire.Hosting.Rust` | `AddRustApp()` | +| `CommunityToolkit.Aspire.Hosting.Java` | Java hosting support | + +These provide typed APIs with proper endpoint handling, health checks, and dashboard integration — significantly better than raw executables. + +#### Tier 3: Raw fallbacks (last resort) + +`AddExecutable()`, `AddDockerfile()`, `AddContainer()` — use only when no Tier 1 or Tier 2 package exists for the technology, or when the user's setup is too custom for a typed integration. + +### How to discover available packages + +```bash +# Search for documentation on a topic +aspire docs search "redis" +aspire docs search "golang" +aspire docs search "python uvicorn" + +# Get a specific doc page by slug (returned from search results) +aspire docs get "redis-integration" +aspire docs get "go-integration" + +# List ALL available integrations (first-party and community toolkit) +# Note: requires the Aspire MCP server to be connected. If this fails, use aspire docs search instead. +aspire list integrations +``` + +Use `aspire docs search` to find the right builder methods, configuration options, and patterns. Use `aspire docs get ` to read the full doc page. Use `aspire list integrations` to discover packages you might not have known about. + +**Don't invent APIs** — if docs search and integration list don't return it, it doesn't exist. Fall back to Tier 3 and note the limitation to the user. **API shapes differ between C# and TypeScript** — always check the correct language docs. + +### Check what integrations auto-manage + +Before modeling environment variables, passwords, ports, or volumes for a typed integration, **check the docs to see what the integration handles automatically**. Many typed integrations auto-generate passwords, manage ports dynamically, and handle volumes — duplicating this config causes errors or conflicts. + +```bash +# Check what AddPostgres manages automatically +aspire docs get "postgresql-hosting-integration" --section "Connection properties" + +# Check what AddSqlServer manages +aspire docs get "sql-server-integration" --section "Hosting integration" +``` + +Look for the **"Connection properties"** section — it lists what the integration injects into consuming services. If it lists `Password`, `Host`, `Port` — the integration manages those. Do not create `AddParameter()` for values the integration already handles. + +Common auto-managed values (do NOT model these manually): + +| Integration | Auto-managed | +|-------------|-------------| +| `AddPostgres()` | Password, host, port, connection string | +| `AddSqlServer()` | SA password, host, port, connection string | +| `AddRedis()` | Connection string, port | +| `AddMySql()` | Root password, host, port, connection string | +| `AddRabbitMQ()` | Username, password, host, port, connection string | +| `AddMongoDB()` | Connection string, port | + +To add an integration package (which unlocks typed builder methods): + +```bash +# First-party +aspire add redis +aspire add python +aspire add nodejs + +# Community Toolkit +aspire add communitytoolkit-golang +aspire add communitytoolkit-rust +``` + +After adding, run `aspire restore` (TypeScript) or `dotnet restore` (C#) to update available APIs, then check what methods are now available. + +**Always prefer a typed integration over raw `AddExecutable`/`AddContainer`.** Typed integrations handle working directories, port injection, health checks, and dashboard integration automatically. + +## Service communication: `WithReference` vs `WithEnvironment` + +**`WithReference()`** is the primary way to connect services. It does two things: + +1. Injects the referenced resource's connection information (connection string or URL) into the consuming service +2. Enables Aspire service discovery — .NET services can resolve the referenced resource by name + +```csharp +// C#: api gets the database connection string injected automatically +var db = builder.AddPostgres("pg").AddDatabase("mydb"); +var api = builder.AddCSharpApp("api", "../src/Api") + .WithReference(db); + +// C#: frontend gets service discovery URL for api +var frontend = builder.AddCSharpApp("web", "../src/Web") + .WithReference(api); +``` + +```typescript +// TypeScript equivalent +const db = await builder.addPostgres("pg").addDatabase("mydb"); +const api = await builder.addCSharpApp("api", "./src/Api") + .withReference(db); +``` + +**How services consume references**: Services receive connection info as environment variables. The naming convention is: +- Connection strings: `ConnectionStrings__` (e.g., `ConnectionStrings__mydb=Host=...`) +- Service URLs: `services______0` (e.g., `services__api__http__0=http://localhost:5123`) + +**`WithEnvironment()`** injects raw environment variables. Use this for custom config that isn't a service reference: + +```csharp +var api = builder.AddCSharpApp("api", "../src/Api") + .WithEnvironment("FEATURE_FLAG_X", "true") + .WithEnvironment("API_KEY", someParameter); +``` + +**When to use which:** +- Connecting service A to service B or a database/cache/queue → `WithReference()` +- Passing configuration values, feature flags, API keys → `WithEnvironment()` +- Never manually construct connection strings with `WithEnvironment()` when `WithReference()` would work + +## Endpoints and ports + +**Prefer HTTPS by default.** Use `WithHttpsEndpoint()` for all services and fall back to `WithHttpEndpoint()` only if HTTPS doesn't work for that resource. + +**Prefer Aspire-managed ports by default.** For most local development scenarios, let Aspire assign the port and inject it into the service. This avoids port collisions, makes multiple AppHosts easier to run side-by-side, and keeps cross-service wiring flexible. + +**Ask before pinning a fixed port.** If the repo already uses a hardcoded port, do **not** silently preserve it just because it exists. Ask whether that port is actually required. Good reasons to keep a fixed port include: + +- OAuth/callback URLs or external webhooks that expect a stable local address +- Browser extensions or desktop/mobile clients that are already hardcoded to a specific port +- Repo docs, scripts, or test tooling that explicitly depend on that exact port + +If none of those apply, steer the user toward managed ports. + +**`WithHttpsEndpoint()`** — expose an HTTPS endpoint. For services that serve traffic: + +```csharp +// Let Aspire assign a random port (recommended for most cases) +var api = builder.AddCSharpApp("api", "../src/Api") + .WithHttpsEndpoint(); + +// Use a specific port only when the user confirms it is required +var api = builder.AddCSharpApp("api", "../src/Api") + .WithHttpsEndpoint(port: 5001); + +// For services that read the port from an env var +var nodeApi = builder.AddJavaScriptApp("api", "../api", "start") + .WithHttpsDeveloperCertificate() + .WithHttpsEndpoint(env: "PORT"); // Aspire injects PORT= +``` + +**`WithHttpsDeveloperCertificate()`** — required for JavaScript and Python apps to serve HTTPS. Configures the ASP.NET Core dev cert. .NET apps handle this automatically. + +```csharp +var frontend = builder.AddViteApp("frontend", "../frontend") + .WithHttpsDeveloperCertificate(); + +var pyApi = builder.AddUvicornApp("api", "../api", "app:main") + .WithHttpsDeveloperCertificate(); +``` + +> If `WithHttpsDeveloperCertificate()` causes issues for a resource, fall back to `WithHttpEndpoint()` and leave a comment explaining why. + +**`WithHttpEndpoint()`** — fallback for HTTP when HTTPS doesn't work: + +```csharp +// Use when HTTPS causes issues with a specific integration +var legacy = builder.AddJavaScriptApp("legacy", "../legacy", "start") + .WithHttpEndpoint(env: "PORT"); // HTTP fallback +``` + +**`WithEndpoint()`** — expose a non-HTTP endpoint (gRPC, TCP, custom protocols): + +```csharp +var grpcService = builder.AddCSharpApp("grpc", "../src/GrpcService") + .WithEndpoint("grpc", endpoint => + { + endpoint.Port = 5050; + endpoint.Protocol = "grpc"; + }); +``` + +**`WithExternalHttpEndpoints()`** — mark a resource's HTTP endpoints as externally visible. Use this for user-facing frontends so the URL appears prominently in the dashboard: + +```csharp +var frontend = builder.AddViteApp("frontend", "../frontend") + .WithHttpsDeveloperCertificate() + .WithHttpsEndpoint(env: "PORT") + .WithExternalHttpEndpoints(); +``` + +**Port injection**: Many frameworks (Express, Vite, Flask) need to know which port to listen on. Use the `env:` parameter: +- `withHttpsEndpoint({ env: "PORT" })` (TypeScript) +- `.WithHttpsEndpoint(env: "PORT")` (C#) + +Aspire assigns a port and injects it as the specified environment variable. The service should read it and listen on that port. + +**Recommended ask when a repo already hardcodes ports:** + +> "I found this service pinned to port 3000 today. Unless that exact port is needed for an external callback or another hard requirement, I recommend switching it to read PORT from env and letting Aspire manage the port. That avoids collisions and makes the AppHost more portable. Should I keep 3000 or make it Aspire-managed?" + +## Cross-service environment variable wiring + +When a service expects a **specific env var name** for a dependency's URL (not the standard `services__` format from `WithReference`), use `WithEnvironment` with an endpoint reference — **never a hardcoded string**: + +```typescript +// ✅ CORRECT — endpoint reference resolves to the actual URL at runtime +const roomEndpoint = await room.getEndpoint("http"); + +const frontend = await builder + .addViteApp("frontend", "./frontend") + .withEnvironment("VITE_APP_WS_SERVER_URL", roomEndpoint) // EndpointReference, not a string + .withReference(room) // also sets up standard service discovery + .waitFor(room); + +// ❌ WRONG — hardcoded URL breaks when Aspire assigns different ports + .withEnvironment("VITE_APP_WS_SERVER_URL", "http://localhost:3002") // NEVER DO THIS +``` + +```csharp +// C# equivalent +var roomEndpoint = room.GetEndpoint("http"); +var frontend = builder.AddViteApp("frontend", "../frontend") + .WithEnvironment("VITE_APP_WS_SERVER_URL", roomEndpoint) + .WithReference(room) + .WaitFor(room); +``` + +Use `WithEnvironment(name, endpointRef)` when the consuming service reads a **specific env var name**. Use `WithReference()` when the service uses Aspire service discovery or standard connection string patterns. You can use both together. + +## URL labels and dashboard niceties + +Customize how endpoints appear in the Aspire dashboard: + +```csharp +// Named endpoints for clarity +var api = builder.AddCSharpApp("api", "../src/Api") + .WithHttpsEndpoint(name: "public", port: 8443) + .WithHttpsEndpoint(name: "internal", port: 8444); +``` + +For `dev.localhost` cookie isolation and config-based subdomain setup, see Step 9 in the main SKILL.md. + +## Dependency ordering: `WaitFor` and `WaitForCompletion` + +**`WaitFor()`** — delay starting a resource until another resource is healthy/ready: + +```csharp +var db = builder.AddPostgres("pg").AddDatabase("mydb"); +var api = builder.AddCSharpApp("api", "../src/Api") + .WithReference(db) + .WaitFor(db); // Don't start api until db is healthy +``` + +Always pair `WithReference()` with `WaitFor()` for infrastructure dependencies (databases, caches, queues). Services that depend on other services should generally also wait for them. + +**`WaitForCompletion()`** — wait for a resource to run to completion (exit successfully). Use for init containers, database migrations, or seed data scripts: + +```csharp +var migration = builder.AddCSharpApp("migration", "../src/MigrationRunner") + .WithReference(db) + .WaitFor(db); + +var api = builder.AddCSharpApp("api", "../src/Api") + .WithReference(db) + .WaitFor(db) + .WaitForCompletion(migration); // Don't start until migration finishes +``` + +## Container lifetimes + +By default, containers are stopped when the AppHost stops. Use **persistent lifetime** to keep containers running across restarts (useful for databases during development): + +```csharp +var db = builder.AddPostgres("pg") + .WithLifetime(ContainerLifetime.Persistent); +``` + +This prevents data loss when restarting the AppHost — the container stays running and the AppHost reconnects. + +**TypeScript equivalent:** + +```typescript +const db = await builder.addPostgres("pg") + .withLifetime("persistent"); +``` + +Recommend persistent lifetime for databases and caches during local development. + +## Explicit start (manual start) + +Some resources shouldn't auto-start with the AppHost. Mark them for explicit start: + +```csharp +var debugTool = builder.AddContainer("profiler", "myregistry/profiler") + .WithLifetime(ContainerLifetime.Persistent) + .ExcludeFromManifest() + .WithExplicitStart(); +``` + +The resource appears in the dashboard but stays stopped until the user manually starts it. Useful for debugging tools, admin UIs, or optional services. + +## Parent resources (grouping in the dashboard) + +Group related resources under a parent for a cleaner dashboard: + +```csharp +var postgres = builder.AddPostgres("pg"); +var ordersDb = postgres.AddDatabase("orders"); +var inventoryDb = postgres.AddDatabase("inventory"); +// ordersDb and inventoryDb appear nested under pg in the dashboard +``` + +This happens automatically for databases added to a server resource. For custom grouping of arbitrary resources, use `WithParentRelationship()`: + +```csharp +var backend = builder.AddResource(new ContainerResource("backend-group")); +var api = builder.AddCSharpApp("api", "../src/Api") + .WithParentRelationship(backend); +var worker = builder.AddCSharpApp("worker", "../src/Worker") + .WithParentRelationship(backend); +``` + +Use `aspire docs search "parent relationship"` to verify the current API shape. + +## Volumes and data persistence + +```csharp +// Named volume (managed by Docker, persists across container recreations) +var db = builder.AddPostgres("pg") + .WithDataVolume("pg-data"); + +// Bind mount (maps to a host directory) +var db = builder.AddPostgres("pg") + .WithBindMount("./data/pg", "/var/lib/postgresql/data"); +``` + +```typescript +const db = await builder.addPostgres("pg") + .withDataVolume("pg-data"); +``` diff --git a/.agents/skills/aspire-init/references/docker-compose.md b/.agents/skills/aspire-init/references/docker-compose.md index 28d96725d1c..54ae1206f2c 100644 --- a/.agents/skills/aspire-init/references/docker-compose.md +++ b/.agents/skills/aspire-init/references/docker-compose.md @@ -196,15 +196,15 @@ services: Becomes: ```csharp -var sqlPassword = builder.AddParameter("mssql-password", secret: true); -var mssql = builder.AddSqlServer("mssql", sqlPassword) +// ✅ Let Aspire auto-generate the SA password — don't model MSSQL_PASSWORD +var mssql = builder.AddSqlServer("mssql") .WithDataVolume(); var redis = builder.AddRedis("redis") .WithDataVolume(); ``` -Note: for typed integrations like `AddSqlServer()` and `AddRedis()`, you don't need to map ports — Aspire handles that. You also don't need to model `redis_data` as a named volume — `WithDataVolume()` handles persistence. +Note: for typed integrations like `AddSqlServer()` and `AddRedis()`, you don't need to map ports or passwords — Aspire handles both. You also don't need to model `redis_data` as a named volume — `WithDataVolume()` handles persistence. The `${MSSQL_PASSWORD}` from the compose file is skipped entirely because `AddSqlServer()` auto-generates a secure SA password. ## Common pitfalls diff --git a/.agents/skills/aspire-init/references/javascript-apps.md b/.agents/skills/aspire-init/references/javascript-apps.md new file mode 100644 index 00000000000..fe3331e86a8 --- /dev/null +++ b/.agents/skills/aspire-init/references/javascript-apps.md @@ -0,0 +1,121 @@ +# JavaScript and TypeScript app patterns + +Use this reference when wiring JavaScript/TypeScript services into the AppHost or configuring TypeScript AppHost dependencies (Step 5 and Step 6). + +## Choosing the right JavaScript resource type + +The `Aspire.Hosting.JavaScript` package provides three resource types. Pick the right one: + +| Signal | Use | Example | +|--------|-----|---------| +| Vite app (has `vite.config.*`) | `AddViteApp(name, dir)` | Frontend SPA, Vite + React/Vue/Svelte | +| App runs via package.json script only | `AddJavaScriptApp(name, dir, { runScriptName })` | CRA app, Next.js, monorepo root scripts | +| App has a specific Node entry file (`.js`/`.ts`) and uses a dev script like `ts-node-dev` | `AddNodeApp(name, dir, "entry.js")` + `.WithRunScript("start:dev")` | Express/Fastify API, Socket.IO server | + +**Key distinctions:** +- `AddNodeApp` is for apps that run a **specific file** with Node (e.g., an Express server at `src/index.ts`). Use `.WithRunScript("start:dev")` to override the dev-time command (e.g., `ts-node-dev`). +- `AddJavaScriptApp` runs a **package.json script** — simpler, good when the script handles everything. +- `AddViteApp` is `AddJavaScriptApp` with Vite-specific defaults (auto-HTTPS config augmentation, `dev` as default script). + +## JavaScript dev scripts + +Use `.WithRunScript()` to control which package.json script runs during development: + +```typescript +// Express API with TypeScript: uses ts-node-dev for hot reload in dev +const api = await builder + .addNodeApp("api", "./api", "src/index.ts") + .withRunScript("start:dev") // runs "yarn start:dev" (ts-node-dev) + .withYarn() + .withHttpEndpoint({ env: "PORT" }); + +// Vite frontend: default "dev" script is fine, just add yarn +const web = await builder + .addViteApp("web", "./frontend") + .withYarn(); +``` + +## Framework-specific port binding + +Not all frameworks read ports from env vars the same way: + +| Framework | Port mechanism | AppHost pattern | +|-----------|---------------|-----------------| +| Express/Fastify | `process.env.PORT` | `.withHttpEndpoint({ env: "PORT" })` | +| Vite | `--port` CLI arg or `server.port` in config | `.withHttpEndpoint({ env: "PORT" })` — Aspire's Vite integration handles this automatically | +| Next.js | `PORT` env or `--port` | `.withHttpEndpoint({ env: "PORT" })` | +| CRA | `PORT` env | `.withHttpEndpoint({ env: "PORT" })` | + +When the framework supports reading the port from an env var or Aspire already handles it, **prefer that over pinning a fixed port**. Managed ports make repeated local runs more reliable and work better when multiple services or multiple Aspire apps are running. + +**Suppress auto-browser-open:** Many dev servers (Vite, CRA, Next.js) auto-open a browser on start. Add `.withEnvironment("BROWSER", "none")` to prevent this in Aspire-managed apps. Vite also respects `server.open: false` in its config. + +## TypeScript AppHost dependency configuration (Step 6) + +### package.json + +If one exists at the root, augment it (do not overwrite). Add/merge these scripts that delegate to the Aspire CLI: + +```json +{ + "type": "module", + "scripts": { + "dev": "aspire run", + "build": "tsc", + "watch": "tsc --watch" + } +} +``` + +If no root `package.json` exists, create a minimal one matching the canonical Aspire template: + +```json +{ + "name": "", + "private": true, + "type": "module", + "scripts": { + "dev": "aspire run", + "build": "tsc", + "watch": "tsc --watch" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } +} +``` + +**Important**: Scripts should point to `aspire run`/`aspire start` — the Aspire CLI handles TypeScript compilation internally. Do not use `npx tsc && node apphost.js` patterns. + +Never overwrite existing `scripts`, `dependencies`, or `devDependencies` — merge only. Do not manually add Aspire SDK packages — `aspire restore` handles those. + +Run `aspire restore` to generate the `.modules/` directory with TypeScript SDK bindings, then install dependencies with the repo's package manager (`npm install`, `pnpm install`, or `yarn`). + +### tsconfig.json + +Augment if it exists: + +- Ensure `".modules/**/*.ts"` and `"apphost.ts"` are in `include` +- Ensure `"module"` is `"nodenext"` or `"node16"` (ESM required) +- Ensure `"moduleResolution"` matches + +If no `tsconfig.json` exists and `aspire restore` didn't create one, create a minimal one: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "strict": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": ["apphost.ts", ".modules/**/*.ts"] +} +``` + +### ESLint + +Only augment if config already exists. If it uses `parserOptions.project` or `parserOptions.projectService`, ensure the AppHost tsconfig is discoverable. Do not create ESLint configuration from scratch. diff --git a/.agents/skills/aspire-init/references/opentelemetry.md b/.agents/skills/aspire-init/references/opentelemetry.md new file mode 100644 index 00000000000..382fe8f79c7 --- /dev/null +++ b/.agents/skills/aspire-init/references/opentelemetry.md @@ -0,0 +1,112 @@ +# OpenTelemetry setup for non-.NET services + +Use this reference when the user opts in to adding OpenTelemetry instrumentation to non-.NET services (Step 8). Aspire automatically injects `OTEL_EXPORTER_OTLP_ENDPOINT` into all managed resources — the services just need to read it. + +## Node.js/TypeScript services + +```bash +# Use the repo's package manager (npm/pnpm/yarn) +npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-otlp-grpc +# or: pnpm add ... +# or: yarn add ... +``` + +Create an instrumentation file (e.g., `instrumentation.ts` or `instrumentation.js`): + +```typescript +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-otlp-grpc'; +import { OTLPMetricExporter } from '@opentelemetry/exporter-otlp-grpc'; +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; + +const sdk = new NodeSDK({ + traceExporter: new OTLPTraceExporter(), + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + }), + instrumentations: [getNodeAutoInstrumentations()], + serviceName: process.env.OTEL_SERVICE_NAME, +}); + +sdk.start(); +``` + +Then ensure the service loads it early — either via `--require`/`--import` in the start script or by importing it as the first line of the entry point. + +## Python services + +```bash +pip install opentelemetry-distro opentelemetry-exporter-otlp +opentelemetry-bootstrap -a install # auto-detect and install framework instrumentations +``` + +Add to the service's startup (e.g., top of `main.py` or as a separate `instrumentation.py`): + +```python +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter +from opentelemetry import trace, metrics +import os + +resource = Resource.create({"service.name": os.environ.get("OTEL_SERVICE_NAME", "unknown")}) + +# Traces +trace.set_tracer_provider(TracerProvider(resource=resource)) +trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + +# Metrics +metrics.set_meter_provider(MeterProvider( + resource=resource, + metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())], +)) +``` + +Or more simply, run with the auto-instrumentation wrapper: + +```bash +opentelemetry-instrument uvicorn main:app --host 0.0.0.0 +``` + +## Go services + +```bash +go get go.opentelemetry.io/otel +go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc +go get go.opentelemetry.io/otel/sdk/trace +go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp +``` + +Add initialization in `main()`: + +```go +import ( + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +func initTracer() func() { + exporter, _ := otlptracegrpc.New(context.Background()) + tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter)) + otel.SetTracerProvider(tp) + return func() { tp.Shutdown(context.Background()) } +} +``` + +Wrap HTTP handlers with `otelhttp.NewHandler()` for automatic HTTP span creation. + +## Java services + +Point the user to the [OpenTelemetry Java Agent](https://opentelemetry.io/docs/zero-code/java/agent/) — it's the easiest approach: + +```bash +java -javaagent:opentelemetry-javaagent.jar -jar myapp.jar +``` + +The agent auto-instruments common frameworks. Aspire injects `OTEL_EXPORTER_OTLP_ENDPOINT` automatically. From 1e04e6e11dfefe80f15b11b659fe6590bd850548 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Fri, 10 Apr 2026 14:07:44 -0400 Subject: [PATCH 47/48] Fix bad merge: remove stale code and restore fallback defaults - Remove SemVersion code incorrectly spliced into InitCommand JSON block - Remove unused usings from main merge (Semver package was removed) - Restore GuestAppHostProject fallback defaults for dashboard URLs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/InitCommand.cs | 8 -------- .../Projects/GuestAppHostProject.cs | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 3fe9c467b1d..ce8c4f8a37a 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -13,9 +13,6 @@ using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; -using Microsoft.Extensions.Configuration; -using NuGetPackage = Aspire.Shared.NuGetPackageCli; -using Spectre.Console; namespace Aspire.Cli.Commands; @@ -374,11 +371,6 @@ private void DropAspireConfig(DirectoryInfo directory, string appHostPath, strin ["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = $"http://localhost:{otlpHttpPort}", ["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = $"http://localhost:{resourceHttpPort}", ["ASPIRE_ALLOW_UNSECURED_TRANSPORT"] = "true" - if (highestVersion is null || version.IsNewerThan(highestVersion)) - { - highestVersion = version; - highestTfm = tfm; - } } } }; diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index f9754b59ac9..f22df5b9352 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -596,6 +596,25 @@ private static Dictionary GetServerEnvironmentVariables(IDiction { var envVars = new Dictionary(); MergeLaunchProfileEnvironmentVariables(launchProfileEnvironmentVariables, envVars, defaultEnvironment: "Development"); + + // Provide default dashboard/server URLs when no profile specifies them. + // The .NET apphost path (DotNetAppHostProject) hardcodes these defaults; + // guest apphosts need the same fallback so the dashboard can start. + if (!envVars.ContainsKey("ASPNETCORE_URLS")) + { + envVars["ASPNETCORE_URLS"] = "https://localhost:17193;http://localhost:15069"; + } + + if (!envVars.ContainsKey("ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL")) + { + envVars["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:21293"; + } + + if (!envVars.ContainsKey("ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL")) + { + envVars["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = "https://localhost:22086"; + } + return envVars; } From 31c864e2798c49d5eac1141c5508f956b5d70613 Mon Sep 17 00:00:00 2001 From: Maddy Montaquila Date: Fri, 10 Apr 2026 15:09:49 -0400 Subject: [PATCH 48/48] Warn about stale persistent volumes causing auth failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-init/references/apphost-wiring.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.agents/skills/aspire-init/references/apphost-wiring.md b/.agents/skills/aspire-init/references/apphost-wiring.md index 2d3823f56ad..cf25f5f5266 100644 --- a/.agents/skills/aspire-init/references/apphost-wiring.md +++ b/.agents/skills/aspire-init/references/apphost-wiring.md @@ -311,6 +311,10 @@ const db = await builder.addPostgres("pg") Recommend persistent lifetime for databases and caches during local development. +**⚠️ Stale persistent volumes can cause auth failures.** Typed integrations like `AddSqlServer()`, `AddPostgres()`, `AddRedis()`, and `AddMySql()` auto-generate passwords on first run. Those passwords are stored inside the container's data volume. If the AppHost is recreated or its user-secrets are reset, Aspire generates a *new* password — but the persistent volume still has the *old* one. The symptom is repeated `Login failed` or `password authentication failed` errors in the container logs. + +To fix: stop the AppHost, remove the stale container and its volume (`docker rm -f ; docker volume rm `), then restart. Aspire will recreate both with a matching password. Mention this to the user if they see auth failures on persistent infrastructure containers after recreating the AppHost. + ## Explicit start (manual start) Some resources shouldn't auto-start with the AppHost. Mark them for explicit start: