From c36f16a50fc07ba66bbbfa8e148024d97d44bef3 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Feb 2026 19:55:43 +1100 Subject: [PATCH 01/28] Add git-based template system spec Comprehensive spec for a git-native, federated template system for the Aspire CLI. Templates are working Aspire apps personalized via string substitution, hosted in git repositories with federated discovery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/git-templates.md | 1242 +++++++++++++++++++++++++++++++++++ 1 file changed, 1242 insertions(+) create mode 100644 docs/specs/git-templates.md diff --git a/docs/specs/git-templates.md b/docs/specs/git-templates.md new file mode 100644 index 00000000000..290a1059697 --- /dev/null +++ b/docs/specs/git-templates.md @@ -0,0 +1,1242 @@ +# Git-Based Template System for Aspire + +**Status:** Draft +**Authors:** Aspire CLI Team +**Last Updated:** 2026-02-27 + +## 1. Overview + +This spec defines a git-based templating system for the Aspire CLI's `aspire new` command. This is a new capability layered on top of the existing template infrastructure — it does not replace `dotnet new`. The `dotnet new` mechanism continues to serve the broader .NET developer ecosystem and reflects Aspire's heritage before the Aspire CLI existed. Both systems coexist: `dotnet new` for developers who prefer the standard .NET workflow, and `aspire new` for a richer, polyglot-friendly, community-oriented experience with git-based template discovery. + +### Motivation: Templates Should Be Effortless + +The best template ecosystems share a common trait: the distance between "I built something useful" and "anyone can use this as a starting point" is nearly zero. Today, creating an Aspire template requires packaging it as a NuGet package with `.template.config/template.json`, understanding the dotnet templating engine's symbol system, and publishing to a feed. This friction means that most useful Aspire applications never become templates, even when their authors would happily share them. + +By making templates git repositories, we eliminate this friction entirely. An Aspire developer's natural workflow — build an app, push it to GitHub — becomes the template authoring workflow. Adding a single `aspire-template.json` file to the repo root is all it takes to make the project available as a template to anyone in the world. + +This has a compounding community effect: + +- **Every public Aspire app is a potential template.** Developers who build interesting Aspire applications can share them with a single file addition. There's no separate "template authoring" skill to learn. +- **Organizations can standardize organically.** Teams push their company-standard Aspire setup to `github.com/{org}/aspire-templates` and every developer in the org automatically sees it in `aspire new`. +- **The ecosystem self-curates.** Popular templates get GitHub stars, forks, and community contributions. The best templates rise naturally through the same mechanisms that make open source work. +- **Polyglot templates are first-class citizens.** A TypeScript Aspire app and a C# Aspire app are both just directories of files. The same template system works for both without any language-specific plumbing. + +### Design Principles + +1. **Templates are real apps.** A template is a working Aspire AppHost project. Template authors develop, run, and test their templates as normal Aspire applications. The template engine personalizes the app via string substitution. +2. **Git-native distribution.** Templates are hosted in git repositories (GitHub initially). No NuGet packaging, no custom registries. If you can push to git, you can publish a template. +3. **Discoverable by default.** The CLI automatically discovers templates from official, personal, and organizational sources. Users don't need to configure anything to find useful templates. +4. **Polyglot from day one.** Templates work for any language Aspire supports — C#, TypeScript, Python, Go, Java, Rust — because they're just real projects with variable substitution. +5. **Secure by design.** Templates are static file trees. No arbitrary code execution during template application. What you see in the repo is what you get. +6. **Zero-friction authoring.** Adding a single `aspire-template.json` file to any Aspire app repo makes it a template. No packaging, no special tooling, no separate publishing step. + +### Goals + +- Enable community-contributed templates without requiring access to the Aspire repo +- Support templates in any Aspire-supported language +- Provide federated template discovery across official, personal, and org sources +- Make template authoring as simple as "build an Aspire app, add a manifest" +- Maintain security guarantees — no supply chain risk from template application + +### Non-Goals + +- Deprecating or replacing `dotnet new` — that infrastructure serves the .NET ecosystem and will continue to exist alongside this system +- Building a template marketplace with ratings, reviews, or social features (out of scope for v1) +- Supporting non-git template hosting (e.g., OCI registries, NuGet packages) in the initial release +- Adding git-based template discovery to `dotnet new` — this is an Aspire CLI (`aspire new`) capability + +## 2. Concepts + +### Template + +A **template** is a directory within a git repository that contains: + +1. A working Aspire application (AppHost + service projects) +2. An `aspire-template.json` manifest describing the template's metadata, variables, and substitution rules + +Because templates are real Aspire applications, template authors develop them using the normal Aspire workflow: `dotnet run`, `aspire run`, etc. The template engine's only job is to copy the files and apply string replacements to personalize the output. + +### Template Index + +A **template index** (`aspire-template-index.json`) is a JSON file at the root of a git repository that catalogs available templates. An index can: + +- List templates contained within the same repository +- Reference templates in external repositories +- Link to other template indexes (federation) + +The CLI walks the graph of indexes to build a unified template catalog. + +### Single-Template Repositories + +A repository doesn't need an index file if it contains a single template. If a repo has an `aspire-template.json` at its root but no `aspire-template-index.json`, the CLI treats it as an implicit index of one — the template IS the repo. + +This is the most common case for community templates. A developer builds an Aspire app, adds `aspire-template.json` to the root, and their repo is immediately usable with `aspire new --template-repo`: + +``` +my-cool-aspire-app/ +├── aspire-template.json # Makes this repo a template +├── MyCoolApp.sln +├── MyCoolApp.AppHost/ +│ ├── Program.cs +│ └── MyCoolApp.AppHost.csproj +└── MyCoolApp.Web/ + └── ... +``` + +If both `aspire-template-index.json` AND `aspire-template.json` exist at the root, the index file takes precedence and the root-level `aspire-template.json` is used as one of the indexed templates (with an implicit `"path": "."`). + +### Template Source + +A **template source** is a git repository that the CLI checks for templates. Sources are resolved in priority order: + +| Priority | Source | Repository Pattern | Condition | +|----------|--------|-------------------|-----------| +| 1 | Official | `github.com/dotnet/aspire` (default branch: `release/latest`) | Always checked | +| 2 | Personal | `github.com/{username}/aspire-templates` | GitHub CLI authenticated | +| 3 | Org | `github.com/{org}/aspire-templates` | GitHub CLI authenticated, user consent | +| 4 | Explicit | Any URL or local path | `--template-repo` flag | + +## 3. Schema: `aspire-template-index.json` + +The template index file lives at the root of a repository and describes the templates available from that source. + +```json +{ + "$schema": "https://aka.ms/aspire/template-index-schema/v1", + "version": 1, + "publisher": { + "name": "Microsoft", + "url": "https://github.com/microsoft", + "verified": true + }, + "templates": [ + { + "name": "aspire-starter", + "displayName": "Aspire Starter Application", + "description": "A full-featured Aspire starter with a web frontend and API backend.", + "path": "templates/aspire-starter", + "language": "csharp", + "tags": ["starter", "web", "api"], + "minAspireVersion": "9.0.0" + }, + { + "name": "aspire-ts-starter", + "displayName": "Aspire TypeScript Starter", + "description": "An Aspire application with a TypeScript AppHost.", + "path": "templates/aspire-ts-starter", + "language": "typescript", + "tags": ["starter", "typescript", "polyglot"], + "minAspireVersion": "9.2.0" + }, + { + "name": "contoso-microservices", + "displayName": "Contoso Microservices Template", + "description": "A production-grade microservices template maintained by the Contoso team.", + "repo": "https://github.com/contoso/aspire-microservices-template", + "path": ".", + "language": "csharp", + "tags": ["microservices", "production", "partner"] + } + ], + "includes": [ + { + "url": "https://github.com/azure/aspire-azure-templates", + "description": "Azure-specific Aspire templates" + }, + { + "url": "https://github.com/aws/aspire-aws-templates", + "description": "AWS-specific Aspire templates" + } + ] +} +``` + +### Field Reference + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `$schema` | string | No | JSON schema URL for validation and editor support | +| `version` | integer | Yes | Schema version. Must be `1` for this spec. | +| `publisher` | object | No | Information about the publisher of this index | +| `publisher.name` | string | Yes (if publisher) | Display name of the publisher | +| `publisher.url` | string | No | URL of the publisher | +| `publisher.verified` | boolean | No | Whether this is a verified publisher (set by official Aspire infrastructure) | +| `templates` | array | Yes | List of template entries | +| `templates[].name` | string | Yes | Unique machine-readable template identifier (kebab-case) | +| `templates[].displayName` | string | Yes | Human-readable template name | +| `templates[].description` | string | Yes | Short description of the template | +| `templates[].path` | string | Yes | Path to the template directory, relative to the repo root (or the external repo root if `repo` is specified) | +| `templates[].repo` | string | No | Git URL of an external repository containing the template. If omitted, the template is in the same repo as the index. | +| `templates[].language` | string | No | Primary language of the template (`csharp`, `typescript`, `python`, `go`, `java`, `rust`). If omitted, the template is language-agnostic. | +| `templates[].tags` | array | No | Tags for filtering and categorization | +| `templates[].minAspireVersion` | string | No | Minimum Aspire version required | +| `includes` | array | No | Other template indexes to include (federation) | +| `includes[].url` | string | Yes | Git URL of the repository containing the included index | +| `includes[].description` | string | No | Description of the included index source | + +## 4. Schema: `aspire-template.json` + +The template manifest lives inside a template directory and describes how to apply the template. + +```json +{ + "$schema": "https://aka.ms/aspire/template-schema/v1", + "version": 1, + "name": "aspire-starter", + "displayName": "Aspire Starter Application", + "description": "A full-featured Aspire starter with a web frontend and API backend.", + "language": "csharp", + "variables": { + "projectName": { + "displayName": "Project Name", + "description": "The name for your new Aspire application.", + "type": "string", + "required": true, + "defaultValue": "AspireApp", + "validation": { + "pattern": "^[A-Za-z][A-Za-z0-9_.]*$", + "message": "Project name must start with a letter and contain only letters, digits, dots, and underscores." + } + }, + "useRedisCache": { + "displayName": "Include Redis Cache", + "description": "Add a Redis cache resource to the AppHost.", + "type": "boolean", + "required": false, + "defaultValue": false + }, + "testFramework": { + "displayName": "Test Framework", + "description": "The test framework to use for the test project.", + "type": "choice", + "required": false, + "choices": [ + { "value": "xunit", "displayName": "xUnit.net", "description": "xUnit.net test framework" }, + { "value": "nunit", "displayName": "NUnit", "description": "NUnit test framework" }, + { "value": "mstest", "displayName": "MSTest", "description": "MSTest test framework" } + ], + "defaultValue": "xunit" + }, + "httpPort": { + "displayName": "HTTP Port", + "description": "The HTTP port for the web frontend.", + "type": "integer", + "required": false, + "defaultValue": 5000, + "validation": { + "min": 1024, + "max": 65535 + } + } + }, + "substitutions": { + "filenames": { + "AspireStarter": "{{projectName}}" + }, + "content": { + "AspireStarter": "{{projectName}}", + "aspirestarter": "{{projectName | lowercase}}", + "ASPIRE_STARTER": "{{projectName | uppercase}}" + } + }, + "conditionalFiles": { + "tests/": "{{testFramework}}", + "AspireStarter.AppHost/redis-config.json": "{{useRedisCache}}" + }, + "postMessages": [ + "Your Aspire application '{{projectName}}' has been created!", + "Run `cd {{projectName}} && dotnet run --project {{projectName}}.AppHost` to start the application." + ] +} +``` + +### Field Reference + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `$schema` | string | No | JSON schema URL for validation and editor support | +| `version` | integer | Yes | Schema version. Must be `1` for this spec. | +| `name` | string | Yes | Machine-readable template identifier (must match index entry) | +| `displayName` | string | Yes | Human-readable template name | +| `description` | string | Yes | Short description | +| `language` | string | No | Primary language | +| `variables` | object | Yes | Map of variable name → variable definition | +| `substitutions` | object | Yes | Substitution rules | +| `substitutions.filenames` | object | No | Map of filename patterns → replacement expressions | +| `substitutions.content` | object | No | Map of content patterns → replacement expressions | +| `conditionalFiles` | object | No | Files/directories conditionally included based on variable values | +| `postMessages` | array | No | Messages displayed to the user after template application | + +### Variable Types + +| Type | Description | Additional Properties | +|------|-------------|----------------------| +| `string` | Free-text string | `validation.pattern`, `validation.message` | +| `boolean` | True/false | — | +| `choice` | Selection from predefined options | `choices` array | +| `integer` | Numeric integer | `validation.min`, `validation.max` | + +### Substitution Expressions + +Substitution values use a lightweight expression syntax: + +| Expression | Description | Example Input | Example Output | +|------------|-------------|---------------|----------------| +| `{{variableName}}` | Direct substitution | `MyApp` | `MyApp` | +| `{{variableName \| lowercase}}` | Lowercase | `MyApp` | `myapp` | +| `{{variableName \| uppercase}}` | Uppercase | `MyApp` | `MYAPP` | +| `{{variableName \| kebabcase}}` | Kebab-case | `MyApp` | `my-app` | +| `{{variableName \| snakecase}}` | Snake_case | `MyApp` | `my_app` | +| `{{variableName \| camelcase}}` | camelCase | `MyApp` | `myApp` | +| `{{variableName \| pascalcase}}` | PascalCase | `myApp` | `MyApp` | + +### Conditional Files + +The `conditionalFiles` section controls which files are included in the output: + +- **Boolean variables:** File is included only when the variable is `true`. +- **Choice variables:** File/directory is included only when the variable has a truthy (non-empty) value. For more granular control, use the naming convention `{{variableName}}-xunit/` where the directory name encodes the choice. + +## 5. Template Directory Structure + +A template repository using this system looks like: + +``` +aspire-templates/ +├── aspire-template-index.json # Root index file +├── templates/ +│ ├── aspire-starter/ +│ │ ├── aspire-template.json # Template manifest +│ │ ├── AspireStarter.sln # Working solution (template author can dotnet run this) +│ │ ├── AspireStarter.AppHost/ +│ │ │ ├── Program.cs +│ │ │ └── AspireStarter.AppHost.csproj +│ │ ├── AspireStarter.Web/ +│ │ │ ├── Program.cs +│ │ │ └── AspireStarter.Web.csproj +│ │ └── AspireStarter.ApiService/ +│ │ ├── Program.cs +│ │ └── AspireStarter.ApiService.csproj +│ │ +│ ├── aspire-ts-starter/ +│ │ ├── aspire-template.json +│ │ ├── apphost.ts +│ │ ├── package.json +│ │ └── services/ +│ │ └── api/ +│ │ ├── index.ts +│ │ └── package.json +│ │ +│ └── aspire-empty/ +│ ├── aspire-template.json +│ ├── AspireEmpty.sln +│ └── AspireEmpty.AppHost/ +│ ├── Program.cs +│ └── AspireEmpty.AppHost.csproj +``` + +### Key Insight: The Template IS the App + +The `AspireStarter` directory is a fully functional Aspire application. The template author can: + +```bash +cd templates/aspire-starter +dotnet run --project AspireStarter.AppHost +``` + +This runs the template as a real application. The template engine's job is simply to: +1. Copy the directory +2. Replace `AspireStarter` → `MyProjectName` in filenames and content +3. Exclude/include conditional files +4. Display post-creation messages + +## 6. Template Resolution + +When a user runs `aspire new`, the CLI resolves available templates through a multi-phase process. + +### Phase 1: Index Collection + +``` + ┌──────────────────────┐ + │ Official Index │ + │ microsoft/ │ + │ aspire-templates │ + └──────┬───────────────┘ + │ + ┌────────────┼────────────────┐ + ▼ ▼ ▼ + ┌────────────┐ ┌──────────┐ ┌──────────────┐ + │ Built-in │ │ Included │ │ Included │ + │ Templates │ │ Index: │ │ Index: │ + │ │ │ azure/ │ │ aws/ │ + │ │ │ aspire- │ │ aspire- │ + │ │ │ templates│ │ templates │ + └────────────┘ └──────────┘ └──────────────┘ + + ┌──────────────────────┐ ┌──────────────────────┐ + │ Personal Index │ │ Org Indexes │ + │ {user}/ │ │ {org}/ │ + │ aspire-templates │ │ aspire-templates │ + └──────────────────────┘ └──────────────────────┘ +``` + +**Resolution order:** +1. Check local cache. If cache is valid (within TTL), use cached index. +2. Fetch `aspire-template-index.json` from official repo via shallow sparse clone. +3. Walk `includes` references, fetching each linked index (cycle detection + depth limit of 5). +4. If GitHub CLI is authenticated, check personal and org repos. +5. Merge all templates into a unified catalog, preserving source metadata. + +**Index resolution per repository:** +When the CLI checks a repository for templates, it follows this logic: + +1. Look for `aspire-template-index.json` at the repo root → use as the index. +2. If no index found, look for `aspire-template.json` at the repo root → treat as a single-template index (the template path is `.`, metadata comes from the manifest). +3. If both exist, use the index file. If the index doesn't already reference a template at `"path": "."`, the root `aspire-template.json` is implicitly included as an additional template. +4. If neither file exists, the repository is not a template source (skip silently for auto-discovered sources, warn for explicit `--template-repo`). + +### Phase 2: Template Selection + +The user selects a template through one of: + +- **Direct name:** `aspire new aspire-starter` +- **Explicit repo:** `aspire new --template-repo https://github.com/contoso/templates --template-name my-template` +- **Interactive:** `aspire new` → prompted with categorized template list +- **Language filter:** `aspire new --language typescript` → only TypeScript templates shown + +### Phase 3: Template Fetch + +Once a template is selected: + +1. If the template is in a remote repo, perform a shallow sparse clone targeting only the template's `path`. +2. Read the `aspire-template.json` manifest. +3. Prompt the user for any required variables (with defaults pre-filled). +4. Apply the template. + +## 7. Template Application + +Template application is a deterministic, side-effect-free process: + +``` +Input: Template directory + variable values +Output: New project directory +``` + +### Algorithm + +``` +1. COPY template directory → output directory + - Skip: aspire-template.json (manifest is not part of output) + - Skip: .git/, .github/ (git metadata from template repo) + - Skip: files excluded by conditionalFiles rules + +2. RENAME files and directories + - For each entry in substitutions.filenames: + Replace pattern with evaluated expression in all file/directory names + - Process deepest paths first (to avoid renaming parent before child) + +3. SUBSTITUTE content + - For each file in output directory: + For each entry in substitutions.content: + Replace all occurrences of pattern with evaluated expression + - Binary files are detected and skipped (by extension or content sniffing) + +4. DISPLAY post-creation messages + - Evaluate variable expressions in postMessages + - Print to console +``` + +### Binary File Handling + +The template engine skips content substitution for binary files. Binary detection uses: + +1. **Extension allowlist:** `.png`, `.jpg`, `.gif`, `.ico`, `.woff`, `.woff2`, `.ttf`, `.eot`, `.pdf`, `.zip`, `.dll`, `.exe`, `.so`, `.dylib` +2. **Content sniffing fallback:** Check first 8KB for null bytes + +### .gitignore Respect + +Files matched by a `.gitignore` in the template directory are excluded from the output. This allows template authors to have local build artifacts that don't get copied. + +## 8. Caching Strategy + +### Index Cache + +- **Location:** `~/.aspire/template-cache/indexes/` +- **TTL:** 1 hour for official index, 4 hours for community indexes +- **Background refresh:** When the CLI starts, if the cache is stale, fetch updated indexes in the background. The current operation uses the cached data. +- **Force refresh:** `aspire new --refresh` forces a fresh fetch. + +### Template Cache + +- **Location:** `~/.aspire/template-cache/templates/{source-hash}/{template-name}/` +- **Strategy:** Templates are cached after first fetch. Cache is keyed by repo URL + commit SHA. +- **Invalidation:** When the index is refreshed and a template's source has changed, the cached template is invalidated. + +### Cache Layout + +``` +~/.aspire/ +└── template-cache/ + ├── indexes/ + │ ├── microsoft-aspire-templates.json # Cached official index + │ ├── azure-aspire-azure-templates.json # Cached included index + │ └── cache-metadata.json # TTL tracking + └── templates/ + ├── a1b2c3d4/ # Hash of repo URL + │ ├── aspire-starter/ # Cloned template content + │ └── aspire-ts-starter/ + └── e5f6g7h8/ + └── contoso-microservices/ +``` + +## 9. CLI Integration + +### Feature Flag + +The entire git-based template system is gated behind a feature flag: + +``` +features.gitTemplatesEnabled = true|false (default: false) +``` + +When disabled, the `aspire template` command tree is hidden and `GitTemplateFactory` does not register any templates. This allows incremental development without affecting existing users. + +### Configuration Values + +Template sources are configured as named entries under the `templates.indexes` key. Each entry is an object with `repo` and optional `ref` properties. This uses the CLI's hierarchical config system — dotted keys are stored as nested JSON objects. + +**Index configuration:** + +| Key Pattern | Type | Description | +|-------------|------|-------------| +| `templates.indexes..repo` | string | Git URL or local file path for the template index source. | +| `templates.indexes..ref` | string | Git ref to use (branch, tag, or commit SHA). Optional — defaults to `templates.defaultBranch` if not set. | + +The name `default` is special — it refers to the official Aspire template index. If not configured, it defaults to `https://github.com/dotnet/aspire` at the `release/latest` branch. + +```bash +# Override the default (official) template index +aspire config set -g templates.indexes.default.repo https://github.com/mitchdenny/aspire-templates-override + +# Point default at a specific branch (e.g., testing a PR) +aspire config set -g templates.indexes.default.ref refs/pull/14500/head + +# Add a company template index +aspire config set -g templates.indexes.contoso.repo https://github.com/contoso/aspire-templates + +# Add a local template index for development/testing +aspire config set -g templates.indexes.local.repo C:\Code\mylocaltemplatetests + +# Point at a specific release tag +aspire config set -g templates.indexes.contoso.ref v2.1.0 + +# Add another team's templates pinned to main +aspire config set -g templates.indexes.platform-team.repo https://github.com/contoso/platform-aspire-templates +aspire config set -g templates.indexes.platform-team.ref main +``` + +This produces the following in `~/.aspire/globalsettings.json`: + +```json +{ + "templates": { + "indexes": { + "default": { + "repo": "https://github.com/mitchdenny/aspire-templates-override", + "ref": "refs/pull/14500/head" + }, + "contoso": { + "repo": "https://github.com/contoso/aspire-templates", + "ref": "v2.1.0" + }, + "local": { + "repo": "C:\\Code\\mylocaltemplatetests" + }, + "platform-team": { + "repo": "https://github.com/contoso/platform-aspire-templates", + "ref": "main" + } + } + } +} +``` + +The `ref` field is particularly useful for: +- **Testing PR changes:** `aspire config set -g templates.indexes.default.ref refs/pull/14500/head` +- **Pinning to a release:** `aspire config set -g templates.indexes.contoso.ref v2.1.0` +- **Tracking a branch:** `aspire config set -g templates.indexes.default.ref main` + +**Other configuration keys:** + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `features.gitTemplatesEnabled` | bool | `false` | Enable/disable the git-based template system | +| `templates.defaultBranch` | string | `release/latest` | Default branch when a specific index entry doesn't specify a `ref`. | +| `templates.cacheTtlMinutes` | int | `60` | Cache TTL for template indexes in minutes | +| `templates.enablePersonalDiscovery` | bool | `true` | Auto-discover `{username}/aspire-templates` when GitHub CLI is authenticated | +| `templates.enableOrgDiscovery` | bool | `true` | Auto-discover `{org}/aspire-templates` for the user's GitHub organizations | + +```bash +# Use main branch as the default for all indexes that don't specify a ref +aspire config set -g templates.defaultBranch main + +# Disable auto-discovery of personal templates +aspire config set -g templates.enablePersonalDiscovery false +``` + +**Resolution order for indexes:** + +1. All entries under `templates.indexes.*` (including `default` if present; otherwise the built-in `https://github.com/dotnet/aspire` at `templates.defaultBranch` is used) +2. Personal: `github.com/{username}/aspire-templates` (if `enablePersonalDiscovery` is `true` and GitHub CLI is authenticated) +3. Org: `github.com/{org}/aspire-templates` for each org (if `enableOrgDiscovery` is `true` and GitHub CLI is authenticated) +4. Explicit: `--template-repo` flag on individual commands + +To remove a configured index, use `aspire config delete -g templates.indexes..repo`. To completely disable the official index, remove the `default` entry and set `enablePersonalDiscovery` and `enableOrgDiscovery` to `false`. + +### Default Template Index Source + +The default template index lives in the main Aspire repository itself, not a separate repo. This keeps the official templates close to the codebase and versioned alongside Aspire releases: + +- **Default URL:** `https://github.com/dotnet/aspire` (the main Aspire repo) +- **Default branch:** `release/latest` (the latest release branch, ensuring stability) +- **Index path:** `templates/aspire-template-index.json` (a new directory in the Aspire repo) + +When the user overrides `templates.defaultBranch` to `main`, they get templates that track the latest development. This is useful for testing template changes before a release. + +### Command Tree: `aspire template` + +The `aspire template` command group provides template management and authoring tools. All subcommands are behind the `gitTemplatesEnabled` feature flag. + +``` +aspire template +├── list List available templates from all sources +├── search Search templates by name, description, or tags +├── refresh Force refresh the template cache +├── new [path] Scaffold a new aspire-template.json manifest +└── new-index [path] Scaffold a new aspire-template-index.json index file +``` + +#### `aspire template list` + +Lists all available templates from all configured sources, grouped by source: + +``` +$ aspire template list + +Official (dotnet/aspire @ release/9.2) + Name Language Description + aspire-starter C# Full-featured Aspire starter application + aspire-ts-starter TypeScript Aspire with TypeScript AppHost + aspire-py-starter Python Aspire with Python AppHost + +Azure (azure/aspire-azure-templates) + Name Language Description + aspire-azure-functions C# Aspire with Azure Functions + +Personal (yourname/aspire-templates) + Name Language Description + my-starter C# My company starter template + +Options: + --language Filter by language (csharp, typescript, python, etc.) + --source Filter by source (official, personal, org, or source URL) + --json Output as JSON (for automation/scripting) +``` + +#### `aspire template search ` + +Searches templates by keyword across names, descriptions, and tags: + +``` +$ aspire template search redis + +Results for "redis": + Name Source Language Description + aspire-redis-starter official C# Aspire starter with Redis cache + redis-microservices contoso/templates C# Microservices pattern with Redis + +Options: + --language Filter by language + --json Output as JSON +``` + +#### `aspire template refresh` + +Forces a refresh of all cached template indexes and invalidates cached template content: + +``` +$ aspire template refresh + +Refreshing template indexes... + ✓ dotnet/aspire @ release/9.2 (12 templates) + ✓ azure/aspire-azure-templates (3 templates) + ✓ yourname/aspire-templates (2 templates) + ✗ contoso/aspire-templates (not found) + +17 templates available from 3 sources. +``` + +#### `aspire template new [path]` + +Scaffolds a new `aspire-template.json` manifest file. This helps template authors get started: + +``` +$ aspire template new + +Creating aspire-template.json... + +? Template name (kebab-case): my-cool-template +? Display name: My Cool Template +? Description: A template for building cool things with Aspire +? Primary language: csharp +? Canonical project name to replace: MyCoolTemplate + +Created aspire-template.json with substitution rules for "MyCoolTemplate". +Next steps: + 1. Review and customize aspire-template.json + 2. Test with: aspire new --template-repo . --name TestApp + 3. Push to git and share! +``` + +If `[path]` is provided, creates the manifest at that path instead of the current directory. + +#### `aspire template new-index [path]` + +Scaffolds a new `aspire-template-index.json` index file for multi-template repositories or organizational indexes: + +``` +$ aspire template new-index + +Creating aspire-template-index.json... + +? Publisher name: Contoso +? Publisher URL (optional): https://github.com/contoso + +Created aspire-template-index.json. +Add templates to the "templates" array to make them discoverable. +``` + +### Modified Commands + +#### `aspire new` + +When `gitTemplatesEnabled` is `true`, `aspire new` shows templates from both the existing `DotNetTemplateFactory` and the new `GitTemplateFactory`. Git-sourced templates appear alongside built-in templates: + +``` +aspire new [template-name] [options] + +Arguments: + template-name Name of the template to use (optional, interactive if omitted) + +Options: + -n, --name Project name + -o, --output Output directory + --language Filter templates by language + --template-repo Use a git template from a specific repo or local path + --template-name Template name within the specified repo +``` + +When `--template-repo` is used, the CLI fetches the specified repo, reads its `aspire-template-index.json` or `aspire-template.json`, and applies the template directly — bypassing normal discovery. + +## 10. Security Model + +### Threat Model + +| Threat | Mitigation | +|--------|-----------| +| Malicious code in template files | Templates are static files. No code execution during application. Users can inspect the template repo before using it. | +| Supply chain attack via index poisoning | Official index is from `dotnet/aspire` (trusted). Community indexes are opt-in with user consent. | +| Man-in-the-middle on template fetch | Git clone uses HTTPS. Commit SHAs in cache provide integrity verification. | +| Typosquatting template names | Official templates take priority in resolution. Warnings shown for templates from non-verified sources. | +| Malicious post-generation hooks | No hooks. Templates do not support arbitrary code execution. | +| Sensitive data in templates | Templates are public git repos. No secrets should be in templates. `.gitignore` is respected. | + +### Trust Levels + +Templates are categorized by trust: + +| Level | Source | UX Treatment | +|-------|--------|-------------| +| **Verified** | `dotnet/aspire` and verified partners | No warnings, shown first | +| **Organizational** | User's GitHub org repos | Subtle note about source | +| **Personal** | User's own repos | Subtle note about source | +| **Community** | Any other repo | Warning banner on first use, user must confirm | + +### What Templates Cannot Do + +This is a critical security property. Template application is purely: +- File copy +- String substitution in filenames and content +- Conditional file inclusion/exclusion + +Templates **cannot**: +- Execute arbitrary commands +- Run scripts (pre or post generation) +- Access the network +- Read files outside the template directory +- Modify the user's system configuration +- Install packages or dependencies + +If a template needs post-creation setup (e.g., `npm install`, `dotnet restore`), the `postMessages` field can instruct the user, but the CLI does not execute these automatically. + +### Integrity & Content Verification + +> **TODO: This section requires input from the security team before finalizing.** + +Template content is fetched from git repositories over the network, which introduces integrity concerns beyond the basic threat model above. The following areas need security review: + +#### Index Integrity + +- **Question:** Should `aspire-template-index.json` files support content hashes or signatures to verify that referenced templates haven't been tampered with? +- **Question:** When an index references templates in external repos (`"repo": "https://github.com/contoso/template"`), how do we verify that the external repo is the one the index author intended? Should we pin to commit SHAs in the index? +- **Possible approach:** Index entries could include an optional `sha` field that pins a template to a specific commit. The CLI would verify the fetched content matches. + +#### Template Content Integrity + +- **Question:** Should we compute and verify checksums of template files after fetch? +- **Question:** Should we support signing of templates or template indexes (e.g., GPG signatures on commits/tags)? +- **Possible approach:** Cache entries are already keyed by repo URL + commit SHA, providing basic integrity. We may want to go further with explicit verification. + +#### Index Federation Trust Chain + +- **Question:** When the official index includes a community index via `includes`, what trust guarantees do we provide? The community index could change its content at any time. +- **Question:** Should we support depth-limited trust — e.g., the official index can include partner indexes, but partner indexes cannot transitively include further indexes? +- **Possible approach:** Trust could degrade with federation depth. Direct includes from the official index inherit "verified partner" status; deeper levels are treated as "community." + +#### Cache Poisoning + +- **Question:** If an attacker can write to the local cache directory (`~/.aspire/template-cache/`), they could substitute malicious template content. Should we sign cache entries? +- **Possible approach:** Cache entries could include a manifest with commit SHAs and checksums, verified on read. + +#### Audit Trail + +- **Question:** Should the CLI log which template was used, from which source, at which commit SHA, when a project is created? This would help with incident response if a template source is later found to be compromised. +- **Possible approach:** Write a `template-provenance.json` to the generated project recording source repo, commit SHA, template name, and timestamp. + +## 11. Polyglot Support + +Because templates are real Aspire applications, polyglot support is inherent: + +### C# Template Example + +``` +templates/aspire-starter/ +├── aspire-template.json +├── AspireStarter.sln +├── AspireStarter.AppHost/ +│ ├── Program.cs +│ └── AspireStarter.AppHost.csproj +└── AspireStarter.Web/ + ├── Program.cs + └── AspireStarter.Web.csproj +``` + +### TypeScript Template Example + +``` +templates/aspire-ts-starter/ +├── aspire-template.json +├── apphost.ts +├── package.json +├── tsconfig.json +└── services/ + └── api/ + ├── index.ts + └── package.json +``` + +### Python Template Example + +``` +templates/aspire-py-starter/ +├── aspire-template.json +├── apphost.py +├── requirements.txt +└── services/ + └── api/ + ├── app.py + └── requirements.txt +``` + +The template engine doesn't need to know anything about the language. It operates purely on files and strings. + +## 12. Template Authoring Guide + +Creating an Aspire template is designed to be trivially easy. Here's the complete workflow: + +### The 5-Minute Path: Your Repo IS the Template + +If you have a working Aspire application in a git repo, you're 90% of the way to a template: + +**Step 1:** Pick a canonical project name. This is the string that will be replaced with the user's project name. For example, if your project is called `ContosoShop`, that's your canonical name. + +**Step 2:** Create `aspire-template.json` in the repo root: + +```json +{ + "$schema": "https://aka.ms/aspire/template-schema/v1", + "version": 1, + "name": "contoso-shop", + "displayName": "Contoso Shop", + "description": "A microservices e-commerce application with Aspire.", + "language": "csharp", + "variables": { + "projectName": { + "displayName": "Project Name", + "description": "The name for your new application.", + "type": "string", + "required": true, + "defaultValue": "ContosoShop" + } + }, + "substitutions": { + "filenames": { + "ContosoShop": "{{projectName}}" + }, + "content": { + "ContosoShop": "{{projectName}}" + } + } +} +``` + +**Step 3:** Push to GitHub. That's it. + +Anyone can now use your template: + +```bash +aspire new --template-repo https://github.com/you/contoso-shop --name MyShop +``` + +### Making It Discoverable + +To make your template show up in `aspire new` without requiring `--template-repo`: + +**Personal discovery:** Name your repo `aspire-templates` (i.e., `github.com/{you}/aspire-templates`). If the user of `aspire new` has the GitHub CLI installed and is authenticated, your templates automatically appear in their template list. + +**Organizational discovery:** Create `github.com/{your-org}/aspire-templates` with an `aspire-template-index.json` that lists templates across your org's repos. Every org member sees these templates automatically. + +**Official inclusion:** Submit a PR to `dotnet/aspire` adding your repo to the `includes` section of the official index. + +### Multi-Template Repositories + +If you maintain multiple templates in one repo, add an `aspire-template-index.json`: + +```json +{ + "$schema": "https://aka.ms/aspire/template-index-schema/v1", + "version": 1, + "publisher": { + "name": "Your Team" + }, + "templates": [ + { + "name": "basic-api", + "displayName": "Basic API", + "description": "A simple API with Aspire.", + "path": "templates/basic-api", + "language": "csharp" + }, + { + "name": "full-stack", + "displayName": "Full Stack App", + "description": "React frontend + .NET API with Aspire.", + "path": "templates/full-stack", + "language": "csharp" + } + ] +} +``` + +### Testing Your Template + +Since your template is a working Aspire app, you test it by running it: + +```bash +# Test the template as an app +cd templates/basic-api +dotnet run --project BasicApi.AppHost + +# Test the template engine +aspire new --template-repo . --name TestOutput -o /tmp/test-output +cd /tmp/test-output +dotnet run --project TestOutput.AppHost +``` + +Both commands should work. The first verifies your app works, the second verifies the substitutions produce a working app. + +## 13. Open Questions + +These items need further discussion before finalizing: + +1. **Version pinning:** Should templates support version tags (git tags)? Should users be able to pin to a specific version of a template? + +2. **Template inheritance/composition:** Should templates be able to extend or compose other templates? (e.g., "start with aspire-starter, add Azure Service Bus") + +3. **Offline story:** What happens when the user has no network access and no cache? Should we ship a minimal set of embedded templates? + +4. **Template validation:** Should we provide a `aspire template validate` command for template authors? What validation rules? + +5. **Rate limiting:** GitHub API rate limits could affect template discovery for unauthenticated users. How do we handle this gracefully? + +6. **Private repos:** Should we support templates from private git repos? What authentication flows? + +7. **Template updates:** When a user has an existing project created from a template, should we support updating/diffing against newer template versions? + +8. **Monorepo templates:** For templates that contain multiple solutions/projects, should we support selective sub-template application? + +## 14. Future Considerations + +These are explicitly out of scope for v1 but worth tracking: + +- **Template marketplace:** A web UI for discovering and previewing templates +- **Template testing framework:** Automated testing for template authors to verify their templates work +- **IDE integration:** VS/VS Code extensions that surface git-based templates in the new project dialog +- **Template analytics:** Opt-in usage tracking to help template authors understand adoption +- **OCI registry support:** Distributing templates as OCI artifacts for air-gapped environments +- **Template generators:** Executable templates for advanced scenarios (with appropriate security guardrails) + +## 15. Implementation Plan + +This section outlines the incremental implementation strategy. The approach is command-first: stub out the `aspire template` command tree early, then use those commands as the primary interface for developing and testing the underlying infrastructure. + +### Phase 1: Foundation — Command Tree & Feature Flag + +**Goal:** Get `aspire template *` commands visible and responding (with stub/mock output) behind a feature flag. + +**Work items:** + +1. **Add feature flag** `gitTemplatesEnabled` to `KnownFeatures.cs` (default: `false`) +2. **Add config keys** to `KnownFeatures.cs` or equivalent: + - `templates.indexes.*` — named index sources (dictionary pattern, e.g. `templates.indexes.default`, `templates.indexes.contoso`) + - `templates.defaultBranch` + - `templates.cacheTtlMinutes` + - `templates.enablePersonalDiscovery` + - `templates.enableOrgDiscovery` +3. **Create `TemplateCommand.cs`** — parent command for the `aspire template` group (follows `ConfigCommand` pattern) +4. **Create subcommand stubs:** + - `TemplateListCommand.cs` — stub that outputs "not yet implemented" + - `TemplateSearchCommand.cs` — stub with `` argument + - `TemplateRefreshCommand.cs` — stub + - `TemplateNewCommand.cs` — stub that scaffolds `aspire-template.json` + - `TemplateNewIndexCommand.cs` — stub that scaffolds `aspire-template-index.json` +5. **Register in `Program.cs`** — add `TemplateCommand` to root command, gated on feature flag +6. **Tests** — basic command parsing tests for the new command tree + +**Key files:** +``` +src/Aspire.Cli/ +├── Commands/ +│ └── Template/ +│ ├── TemplateCommand.cs # Parent: aspire template +│ ├── TemplateListCommand.cs # aspire template list +│ ├── TemplateSearchCommand.cs # aspire template search +│ ├── TemplateRefreshCommand.cs # aspire template refresh +│ ├── TemplateNewCommand.cs # aspire template new [path] +│ └── TemplateNewIndexCommand.cs # aspire template new-index [path] +├── KnownFeatures.cs # + gitTemplatesEnabled flag +└── Configuration/ + └── (config keys added) +``` + +### Phase 2: Schema & Scaffolding + +**Goal:** `aspire template new` and `aspire template new-index` produce valid, well-formed JSON files. + +**Work items:** + +1. **Define C# models** for `aspire-template.json` and `aspire-template-index.json` schemas + - `GitTemplateManifest` — deserialized `aspire-template.json` + - `GitTemplateIndex` — deserialized `aspire-template-index.json` + - `GitTemplateVariable`, `GitTemplateSubstitutions`, etc. +2. **Implement `aspire template new`** — interactive prompts (via `IInteractionService`) to collect template name, canonical project name, etc. Writes `aspire-template.json`. +3. **Implement `aspire template new-index`** — interactive prompts for publisher info. Writes `aspire-template-index.json`. +4. **JSON schema files** — publish schemas at `https://aka.ms/aspire/template-schema/v1` and `https://aka.ms/aspire/template-index-schema/v1` (or embed for offline use). + +**Key files:** +``` +src/Aspire.Cli/ +└── Templating/ + └── Git/ + ├── GitTemplateManifest.cs # aspire-template.json model + ├── GitTemplateIndex.cs # aspire-template-index.json model + ├── GitTemplateVariable.cs # Variable definition model + └── GitTemplateSubstitutions.cs # Substitution rules model +``` + +### Phase 3: Index Resolution & Caching + +**Goal:** `aspire template list` and `aspire template search` return real data from git-hosted indexes. + +**Work items:** + +1. **`IGitTemplateIndexService`** — service that fetches, parses, and caches template indexes + - Shallow sparse clone of repo root to get index file + - Index graph walking with cycle detection (track visited repo URLs) and depth limit (5) + - Cache layer with configurable TTL + - Background refresh on CLI startup +2. **Default index resolution** — fetch from `dotnet/aspire` repo at configured branch +3. **Personal/org discovery** — use `gh` CLI to get authenticated user info and org list, check for `{name}/aspire-templates` repos +4. **Additional indexes** — parse `templates.additionalIndexes` config value +5. **Implement `aspire template list`** — render grouped template table via `IInteractionService.DisplayRenderable()` +6. **Implement `aspire template search`** — filter templates by keyword match on name, description, tags +7. **Implement `aspire template refresh`** — invalidate cache and re-fetch all indexes + +**Key files:** +``` +src/Aspire.Cli/ +└── Templating/ + └── Git/ + ├── IGitTemplateIndexService.cs # Index fetching & caching + ├── GitTemplateIndexService.cs # Implementation + ├── GitTemplateCache.cs # Local cache management + └── GitTemplateSource.cs # Represents a template source (official, personal, org, explicit) +``` + +### Phase 4: Template Application Engine + +**Goal:** `aspire new --template-repo ` creates a real project from a git-based template. + +**Work items:** + +1. **`IGitTemplateEngine`** — service that applies a template: + - Clone template content (shallow + sparse checkout of template path) + - Read `aspire-template.json` manifest + - Prompt for variables + - Copy files with exclusions (manifest, `.git/`, `.github/`, conditional files) + - Apply filename substitutions (deepest-first) + - Apply content substitutions (skip binary files) + - Display post-creation messages +2. **Variable expression evaluator** — handles `{{var}}`, `{{var | lowercase}}`, etc. +3. **Binary file detection** — extension allowlist + null-byte sniffing +4. **Implement `--template-repo` flag on `aspire new`** + +**Key files:** +``` +src/Aspire.Cli/ +└── Templating/ + └── Git/ + ├── IGitTemplateEngine.cs # Template application interface + ├── GitTemplateEngine.cs # Implementation + ├── TemplateExpressionEvaluator.cs # {{var | filter}} evaluation + └── BinaryFileDetector.cs # Binary file detection +``` + +### Phase 5: GitTemplateFactory Integration + +**Goal:** Git-based templates appear in `aspire new` alongside built-in templates. + +**Work items:** + +1. **`GitTemplateFactory : ITemplateFactory`** — returns `ITemplate` instances backed by git-hosted templates + - Uses `IGitTemplateIndexService` for discovery + - Each template is a `GitTemplate : ITemplate` that delegates to `IGitTemplateEngine` +2. **Register in `Program.cs`** via `TryAddEnumerable` (same pattern as `DotNetTemplateFactory`) +3. **Template deduplication** — when both factories provide a template with the same name, use priority (official git > dotnet new > community git) +4. **Interactive selection** — `aspire new` without arguments shows all templates grouped by source + +**Key files:** +``` +src/Aspire.Cli/ +└── Templating/ + └── Git/ + ├── GitTemplateFactory.cs # ITemplateFactory implementation + └── GitTemplate.cs # ITemplate backed by git template +``` + +### Phase 6: Polish & GA + +**Goal:** Production-ready for public use. + +**Work items:** + +1. **Error handling** — graceful degradation when git/network unavailable +2. **Progress indicators** — show clone/fetch progress via `IInteractionService` +3. **Telemetry** — template usage events (template name, source, language — no PII) +4. **Documentation** — user-facing docs for template authoring and `aspire template` commands +5. **Remove feature flag** — flip `gitTemplatesEnabled` default to `true` +6. **Publish official template index** — add `templates/aspire-template-index.json` to the Aspire repo + +## Appendix A: Research & Prior Art + +### .NET Template Engine (`dotnet new`) + +The .NET template engine already embraces the "runnable project" philosophy. From the [template.json reference](https://github.com/dotnet/templating/wiki/Reference-for-template.json): + +> A "runnable project" is a project that can be executed as a normal project can. Instead of updating your source files to be tokenized you define replacements, and other processing, mostly in an external file, the `template.json` file. + +Our git-based system builds on this proven concept. `dotnet new` continues to serve the .NET ecosystem with NuGet-packaged templates and is not replaced or deprecated by this spec. `aspire new` adds git-based distribution and federated discovery as a complementary layer for Aspire CLI users. + +**Comparison:** + +| Aspect | `dotnet new` Templates | Aspire Git Templates (`aspire new`) | +|--------|----------------------|---------------------| +| **Distribution** | NuGet packages | Git repositories | +| **Discovery** | NuGet feeds, `dotnet new search` | Federated git-based indexes | +| **Manifest** | `.template.config/template.json` | `aspire-template.json` (repo root) | +| **Substitution** | Symbol-based with generators, computed symbols, conditional preprocessing | Simple variable substitution with filters | +| **Post-actions** | Executable (restore, open IDE, run script) | Messages only (no code execution) | +| **Language scope** | .NET languages | Any language | +| **Authoring** | Create template, package as NuGet, publish to feed | Add JSON file to repo, push to git | +| **GUID handling** | Automatic GUID regeneration across formats | Not yet specified (see Open Questions) | +| **Coexistence** | Continues as-is | Additive — does not replace `dotnet new` | + +**Design rationale for divergence:** + +We intentionally keep the substitution model simpler than `dotnet new`'s full symbol/generator system. The .NET template engine supports computed symbols, derived values, and conditional preprocessing — powerful features but ones that add complexity and .NET-specific concepts. Our system favors simplicity and transparency: what you see in the repo is what you get, with straightforward name replacements. If complex project generation is needed, build a tool that generates the output directly. + +### Cookiecutter + +[Cookiecutter](https://cookiecutter.readthedocs.io/) is a popular cross-language project templating tool. Templates are directories with `{{cookiecutter.variable}}` placeholders in filenames and content, plus a `cookiecutter.json` defining variables and defaults. + +**What we borrow:** +- The concept that a template is a directory of real files with variable placeholders +- JSON manifest for variable definitions with defaults +- Git repository as distribution mechanism (`cookiecutter gh:user/repo`) + +**Where we differ:** +- Cookiecutter uses Jinja2 templating (powerful but complex); we use simple find-and-replace with filters +- Cookiecutter supports pre/post-generation hooks (Python/shell scripts); we explicitly forbid code execution for security +- Cookiecutter has no federated discovery; we have index walking + +### GitHub Template Repositories + +GitHub's [template repositories](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-template-repository) allow creating new repos from a template with one click. They're simple (just copy files) but have no variable substitution, no parameterization, and no CLI integration. + +**What we borrow:** +- The idea that a git repo IS the template +- Zero-friction publishing (just mark a repo as a template) + +**Where we differ:** +- We add variable substitution for project name personalization +- We add federated discovery across multiple sources +- We integrate with the Aspire CLI rather than GitHub's web UI + +### Yeoman + +[Yeoman](https://yeoman.io/) generators are npm packages that programmatically scaffold projects. They're extremely flexible but require JavaScript knowledge to author and npm infrastructure to distribute. + +**What we learn:** +- Yeoman's power comes at the cost of high authoring complexity — most teams never create generators +- The npm distribution model creates friction for non-JavaScript ecosystems +- Our approach inverts this: minimal authoring effort, git-native distribution + +### Rollout Plan + +The git-based template system will be introduced alongside `dotnet new`: + +1. **Phase 1 (this spec):** Implement git-based template resolution in `aspire new`. The `--template-repo` flag enables explicit use; `aspire new` also shows git-sourced templates alongside the existing built-in templates. +2. **Phase 2:** Publish official Aspire templates to `dotnet/aspire` as git-based templates. These are the same templates available via `dotnet new`, also discoverable through the git-based system. +3. **Phase 3:** Community and partner templates begin appearing in federated indexes. `aspire new` becomes the recommended way to discover Aspire templates. + +### For `dotnet new` Template Authors + +Teams currently creating `dotnet new` templates can additionally make them available as git-based templates: + +1. Take your existing template output (what `dotnet new` generates) +2. Replace parameter placeholders with the canonical project name +3. Add `aspire-template.json` with variable definitions +4. Push to a git repo +5. Optionally, register in a template index + +This is additive — the `dotnet new` template continues to work as before. From ab8184fdc9ce3efa830e70679ced59853efe140c Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Feb 2026 20:01:30 +1100 Subject: [PATCH 02/28] Add aspire template command tree (Phase 1) Stub out the 'aspire template' command group behind the gitTemplatesEnabled feature flag. Commands added: - aspire template list - aspire template search - aspire template refresh - aspire template new [path] - aspire template new-index [path] All commands output stub messages. The command tree is hidden by default and can be enabled with: aspire config set features.gitTemplatesEnabled true Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RootCommand.cs | 6 +++ .../Template/BaseTemplateSubCommand.cs | 22 ++++++++++ .../Commands/Template/TemplateCommand.cs | 44 +++++++++++++++++++ .../Commands/Template/TemplateListCommand.cs | 25 +++++++++++ .../Template/TemplateNewIndexCommand.cs | 37 ++++++++++++++++ .../Template/TemplateNewManifestCommand.cs | 37 ++++++++++++++++ .../Template/TemplateRefreshCommand.cs | 25 +++++++++++ .../Template/TemplateSearchCommand.cs | 36 +++++++++++++++ src/Aspire.Cli/KnownFeatures.cs | 8 +++- src/Aspire.Cli/Program.cs | 6 +++ 10 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 src/Aspire.Cli/Commands/Template/BaseTemplateSubCommand.cs create mode 100644 src/Aspire.Cli/Commands/Template/TemplateCommand.cs create mode 100644 src/Aspire.Cli/Commands/Template/TemplateListCommand.cs create mode 100644 src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs create mode 100644 src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs create mode 100644 src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs create mode 100644 src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 1b1148075fc..6060c296c83 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -133,6 +133,7 @@ public RootCommand( DocsCommand docsCommand, SecretCommand secretCommand, SdkCommand sdkCommand, + TemplateCommand templateCommand, SetupCommand setupCommand, #if DEBUG RenderCommand renderCommand, @@ -237,6 +238,11 @@ public RootCommand( Subcommands.Add(sdkCommand); + if (featureFlags.IsFeatureEnabled(KnownFeatures.GitTemplatesEnabled, false)) + { + Subcommands.Add(templateCommand); + } + // Replace the default --help action with grouped help output. // Add -v as a short alias for --version. foreach (var option in Options) diff --git a/src/Aspire.Cli/Commands/Template/BaseTemplateSubCommand.cs b/src/Aspire.Cli/Commands/Template/BaseTemplateSubCommand.cs new file mode 100644 index 00000000000..b5b5ee3fd7a --- /dev/null +++ b/src/Aspire.Cli/Commands/Template/BaseTemplateSubCommand.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Commands.Template; + +internal abstract class BaseTemplateSubCommand( + string name, + string description, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + IInteractionService interactionService, + AspireCliTelemetry telemetry) + : BaseCommand(name, description, features, updateNotifier, executionContext, interactionService, telemetry) +{ + protected override bool UpdateNotificationsEnabled => false; +} diff --git a/src/Aspire.Cli/Commands/Template/TemplateCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateCommand.cs new file mode 100644 index 00000000000..29ef1857abe --- /dev/null +++ b/src/Aspire.Cli/Commands/Template/TemplateCommand.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Help; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Commands.Template; + +internal sealed class TemplateCommand : BaseCommand +{ + internal override HelpGroup HelpGroup => HelpGroup.ToolsAndConfiguration; + + public TemplateCommand( + TemplateListCommand listCommand, + TemplateSearchCommand searchCommand, + TemplateRefreshCommand refreshCommand, + TemplateNewManifestCommand newManifestCommand, + TemplateNewIndexCommand newIndexCommand, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + IInteractionService interactionService, + AspireCliTelemetry telemetry) + : base("template", "Manage git-based Aspire templates", features, updateNotifier, executionContext, interactionService, telemetry) + { + Subcommands.Add(listCommand); + Subcommands.Add(searchCommand); + Subcommands.Add(refreshCommand); + Subcommands.Add(newManifestCommand); + Subcommands.Add(newIndexCommand); + } + + protected override bool UpdateNotificationsEnabled => false; + + protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + new HelpAction().Invoke(parseResult); + return Task.FromResult(ExitCodeConstants.InvalidCommand); + } +} diff --git a/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs new file mode 100644 index 00000000000..5e494269da5 --- /dev/null +++ b/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Commands.Template; + +internal sealed class TemplateListCommand( + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + IInteractionService interactionService, + AspireCliTelemetry telemetry) + : BaseTemplateSubCommand("list", "List available templates from all configured sources", features, updateNotifier, executionContext, interactionService, telemetry) +{ + protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + InteractionService.DisplayMessage("information", "Git-based template listing is not yet implemented."); + return Task.FromResult(ExitCodeConstants.Success); + } +} diff --git a/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs new file mode 100644 index 00000000000..5e022c3316a --- /dev/null +++ b/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Commands.Template; + +internal sealed class TemplateNewIndexCommand : BaseTemplateSubCommand +{ + private static readonly Argument s_pathArgument = new("path") + { + Description = "Directory to create aspire-template-index.json in (defaults to current directory)", + Arity = ArgumentArity.ZeroOrOne + }; + + public TemplateNewIndexCommand( + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + IInteractionService interactionService, + AspireCliTelemetry telemetry) + : base("new-index", "Scaffold a new aspire-template-index.json index file", features, updateNotifier, executionContext, interactionService, telemetry) + { + Arguments.Add(s_pathArgument); + } + + protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var path = parseResult.GetValue(s_pathArgument); + InteractionService.DisplayMessage("information", $"Template index scaffolding is not yet implemented.{(path is not null ? $" Path: {path}" : "")}"); + return Task.FromResult(ExitCodeConstants.Success); + } +} diff --git a/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs new file mode 100644 index 00000000000..ee64ee40672 --- /dev/null +++ b/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Commands.Template; + +internal sealed class TemplateNewManifestCommand : BaseTemplateSubCommand +{ + private static readonly Argument s_pathArgument = new("path") + { + Description = "Directory to create aspire-template.json in (defaults to current directory)", + Arity = ArgumentArity.ZeroOrOne + }; + + public TemplateNewManifestCommand( + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + IInteractionService interactionService, + AspireCliTelemetry telemetry) + : base("new", "Scaffold a new aspire-template.json manifest", features, updateNotifier, executionContext, interactionService, telemetry) + { + Arguments.Add(s_pathArgument); + } + + protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var path = parseResult.GetValue(s_pathArgument); + InteractionService.DisplayMessage("information", $"Template manifest scaffolding is not yet implemented.{(path is not null ? $" Path: {path}" : "")}"); + return Task.FromResult(ExitCodeConstants.Success); + } +} diff --git a/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs new file mode 100644 index 00000000000..41fb6cd1222 --- /dev/null +++ b/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Commands.Template; + +internal sealed class TemplateRefreshCommand( + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + IInteractionService interactionService, + AspireCliTelemetry telemetry) + : BaseTemplateSubCommand("refresh", "Force refresh the template index cache", features, updateNotifier, executionContext, interactionService, telemetry) +{ + protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + InteractionService.DisplayMessage("information", "Git-based template cache refresh is not yet implemented."); + return Task.FromResult(ExitCodeConstants.Success); + } +} diff --git a/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs new file mode 100644 index 00000000000..2952d8c2231 --- /dev/null +++ b/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Commands.Template; + +internal sealed class TemplateSearchCommand : BaseTemplateSubCommand +{ + private static readonly Argument s_keywordArgument = new("keyword") + { + Description = "Search keyword to filter templates by name, description, or tags" + }; + + public TemplateSearchCommand( + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + IInteractionService interactionService, + AspireCliTelemetry telemetry) + : base("search", "Search templates by keyword", features, updateNotifier, executionContext, interactionService, telemetry) + { + Arguments.Add(s_keywordArgument); + } + + protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var keyword = parseResult.GetValue(s_keywordArgument); + InteractionService.DisplayMessage("information", $"Git-based template search is not yet implemented. Keyword: {keyword}"); + return Task.FromResult(ExitCodeConstants.Success); + } +} diff --git a/src/Aspire.Cli/KnownFeatures.cs b/src/Aspire.Cli/KnownFeatures.cs index 0531317439b..d1a3e4728b5 100644 --- a/src/Aspire.Cli/KnownFeatures.cs +++ b/src/Aspire.Cli/KnownFeatures.cs @@ -29,6 +29,7 @@ internal static class KnownFeatures public static string ExperimentalPolyglotGo => "experimentalPolyglot:go"; public static string ExperimentalPolyglotPython => "experimentalPolyglot:python"; public static string RunningInstanceDetectionEnabled => "runningInstanceDetectionEnabled"; + public static string GitTemplatesEnabled => "gitTemplatesEnabled"; private static readonly Dictionary s_featureMetadata = new() { @@ -100,7 +101,12 @@ internal static class KnownFeatures [RunningInstanceDetectionEnabled] = new( RunningInstanceDetectionEnabled, "Enable or disable detection of already running Aspire instances to prevent conflicts", - DefaultValue: true) + DefaultValue: true), + + [GitTemplatesEnabled] = new( + GitTemplatesEnabled, + "Enable or disable git-based template discovery and the 'aspire template' command group", + DefaultValue: false) }; /// diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 80ce4b9bed0..fc3039ac57c 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -402,6 +402,12 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); #if DEBUG builder.Services.AddTransient(); From bcc9ba7058891f23363a799f55e178328e762c81 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Feb 2026 20:14:24 +1100 Subject: [PATCH 03/28] Fix TemplateCommand DI resolution ambiguity Qualify Template.TemplateCommand in RootCommand constructor to disambiguate from the existing Aspire.Cli.Commands.TemplateCommand (used for dotnet new-style template commands). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RootCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 6060c296c83..57d7fd7730c 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -133,7 +133,7 @@ public RootCommand( DocsCommand docsCommand, SecretCommand secretCommand, SdkCommand sdkCommand, - TemplateCommand templateCommand, + Template.TemplateCommand templateCommand, SetupCommand setupCommand, #if DEBUG RenderCommand renderCommand, From f4d43969b24fabad2f91c29d672940393616ae32 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Feb 2026 20:31:52 +1100 Subject: [PATCH 04/28] Add git template schema models and scaffolding commands (Phase 2) Define C# models for aspire-template.json and aspire-template-index.json: - GitTemplateManifest, GitTemplateIndex, GitTemplateVariable, GitTemplateSubstitutions, and related types - GitTemplateJsonContext for source-generated JSON serialization Implement interactive scaffolding in template commands: - 'aspire template new [path]' prompts for name, display name, description, and language, then writes aspire-template.json with starter variables and substitution rules - 'aspire template new-index [path]' prompts for publisher info and writes aspire-template-index.json with a placeholder entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Template/TemplateNewIndexCommand.cs | 63 +++++++++- .../Template/TemplateNewManifestCommand.cs | 116 +++++++++++++++++- .../Templating/Git/GitTemplateIndex.cs | 67 ++++++++++ .../Templating/Git/GitTemplateJsonContext.cs | 30 +++++ .../Templating/Git/GitTemplateManifest.cs | 33 +++++ .../Git/GitTemplateSubstitutions.cs | 20 +++ .../Templating/Git/GitTemplateVariable.cs | 99 +++++++++++++++ 7 files changed, 419 insertions(+), 9 deletions(-) create mode 100644 src/Aspire.Cli/Templating/Git/GitTemplateIndex.cs create mode 100644 src/Aspire.Cli/Templating/Git/GitTemplateJsonContext.cs create mode 100644 src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs create mode 100644 src/Aspire.Cli/Templating/Git/GitTemplateSubstitutions.cs create mode 100644 src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs diff --git a/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs index 5e022c3316a..d095325fe94 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs @@ -2,9 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.Text.Json; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.Telemetry; +using Aspire.Cli.Templating.Git; using Aspire.Cli.Utils; namespace Aspire.Cli.Commands.Template; @@ -28,10 +30,63 @@ public TemplateNewIndexCommand( Arguments.Add(s_pathArgument); } - protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - var path = parseResult.GetValue(s_pathArgument); - InteractionService.DisplayMessage("information", $"Template index scaffolding is not yet implemented.{(path is not null ? $" Path: {path}" : "")}"); - return Task.FromResult(ExitCodeConstants.Success); + var targetDir = parseResult.GetValue(s_pathArgument) ?? Directory.GetCurrentDirectory(); + targetDir = Path.GetFullPath(targetDir); + + var outputPath = Path.Combine(targetDir, "aspire-template-index.json"); + + if (File.Exists(outputPath)) + { + var overwrite = await InteractionService.ConfirmAsync( + $"aspire-template-index.json already exists in {targetDir}. Overwrite?", + defaultValue: false, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (!overwrite) + { + InteractionService.DisplayMessage("information", "Cancelled."); + return ExitCodeConstants.Success; + } + } + + var publisherName = await InteractionService.PromptForStringAsync( + "Publisher name", + required: true, + cancellationToken: cancellationToken).ConfigureAwait(false); + + var publisherUrl = await InteractionService.PromptForStringAsync( + "Publisher URL (optional)", + cancellationToken: cancellationToken).ConfigureAwait(false); + + var index = new GitTemplateIndex + { + Schema = "https://aka.ms/aspire/template-index-schema/v1", + Publisher = new GitTemplateIndexPublisher + { + Name = publisherName, + Url = string.IsNullOrWhiteSpace(publisherUrl) ? null : publisherUrl + }, + Templates = + [ + new GitTemplateIndexEntry + { + Name = "my-template", + DisplayName = "My Template", + Description = "A template created with aspire template new-index.", + Path = "templates/my-template" + } + ] + }; + + Directory.CreateDirectory(targetDir); + + var json = JsonSerializer.Serialize(index, GitTemplateJsonContext.RelaxedEscaping.GitTemplateIndex); + await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false); + + InteractionService.DisplaySuccess($"Created {outputPath}"); + InteractionService.DisplayMessage("information", "Edit the file to add your templates, then run 'aspire template new' in each template directory."); + return ExitCodeConstants.Success; } } diff --git a/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs index ee64ee40672..75223ebdc7b 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs @@ -2,14 +2,18 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.Text.Json; +using System.Text.RegularExpressions; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.Telemetry; +using Aspire.Cli.Templating.Git; using Aspire.Cli.Utils; +using Spectre.Console; namespace Aspire.Cli.Commands.Template; -internal sealed class TemplateNewManifestCommand : BaseTemplateSubCommand +internal sealed partial class TemplateNewManifestCommand : BaseTemplateSubCommand { private static readonly Argument s_pathArgument = new("path") { @@ -28,10 +32,112 @@ public TemplateNewManifestCommand( Arguments.Add(s_pathArgument); } - protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - var path = parseResult.GetValue(s_pathArgument); - InteractionService.DisplayMessage("information", $"Template manifest scaffolding is not yet implemented.{(path is not null ? $" Path: {path}" : "")}"); - return Task.FromResult(ExitCodeConstants.Success); + var targetDir = parseResult.GetValue(s_pathArgument) ?? Directory.GetCurrentDirectory(); + targetDir = Path.GetFullPath(targetDir); + + var outputPath = Path.Combine(targetDir, "aspire-template.json"); + + if (File.Exists(outputPath)) + { + var overwrite = await InteractionService.ConfirmAsync( + $"aspire-template.json already exists in {targetDir}. Overwrite?", + defaultValue: false, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (!overwrite) + { + InteractionService.DisplayMessage("information", "Cancelled."); + return ExitCodeConstants.Success; + } + } + + var name = await InteractionService.PromptForStringAsync( + "Template name (kebab-case identifier)", + defaultValue: Path.GetFileName(targetDir)?.ToLowerInvariant(), + validator: value => KebabCasePattern().IsMatch(value) + ? ValidationResult.Success() + : ValidationResult.Error("Must be lowercase kebab-case (e.g. my-template)"), + required: true, + cancellationToken: cancellationToken).ConfigureAwait(false); + + var displayName = await InteractionService.PromptForStringAsync( + "Display name", + required: true, + cancellationToken: cancellationToken).ConfigureAwait(false); + + var description = await InteractionService.PromptForStringAsync( + "Description", + required: true, + cancellationToken: cancellationToken).ConfigureAwait(false); + + var languages = new[] { "csharp", "typescript", "python", "go", "java", "rust" }; + var language = await InteractionService.PromptForSelectionAsync( + "Primary language", + languages, + l => l, + cancellationToken: cancellationToken).ConfigureAwait(false); + + var canonicalName = ToPascalCase(name); + + var manifest = new GitTemplateManifest + { + Schema = "https://aka.ms/aspire/template-schema/v1", + Name = name, + DisplayName = displayName, + Description = description, + Language = language, + Variables = new Dictionary + { + ["projectName"] = new() + { + DisplayName = "Project Name", + Description = "The name for your new Aspire application.", + Type = "string", + Required = true, + DefaultValue = canonicalName, + Validation = new GitTemplateVariableValidation + { + Pattern = "^[A-Za-z][A-Za-z0-9_.]*$", + Message = "Project name must start with a letter and contain only letters, digits, dots, and underscores." + } + } + }, + Substitutions = new GitTemplateSubstitutions + { + Filenames = new Dictionary + { + [canonicalName] = "{{projectName}}" + }, + Content = new Dictionary + { + [canonicalName] = "{{projectName}}", + [canonicalName.ToLowerInvariant()] = "{{projectName | lowercase}}" + } + }, + PostMessages = + [ + $"Your Aspire application '{{{{{nameof(GitTemplateManifest.Name)}}}}}' has been created!", + "Run `cd {{projectName}} && dotnet run --project {{projectName}}.AppHost` to start the application." + ] + }; + + Directory.CreateDirectory(targetDir); + + var json = JsonSerializer.Serialize(manifest, GitTemplateJsonContext.RelaxedEscaping.GitTemplateManifest); + await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false); + + InteractionService.DisplaySuccess($"Created {outputPath}"); + return ExitCodeConstants.Success; } + + private static string ToPascalCase(string kebab) + { + return string.Concat(kebab.Split('-').Select(part => + part.Length == 0 ? "" : char.ToUpperInvariant(part[0]) + part[1..])); + } + + [GeneratedRegex("^[a-z][a-z0-9]*(-[a-z0-9]+)*$")] + private static partial Regex KebabCasePattern(); } diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateIndex.cs b/src/Aspire.Cli/Templating/Git/GitTemplateIndex.cs new file mode 100644 index 00000000000..fe8952672b3 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateIndex.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Aspire.Cli.Templating.Git; + +/// +/// Represents the deserialized content of an aspire-template-index.json file. +/// +internal sealed class GitTemplateIndex +{ + [JsonPropertyName("$schema")] + public string? Schema { get; set; } + + public int Version { get; set; } = 1; + + public GitTemplateIndexPublisher? Publisher { get; set; } + + public List Templates { get; set; } = []; + + public List? Includes { get; set; } +} + +/// +/// Publisher information for a template index. +/// +internal sealed class GitTemplateIndexPublisher +{ + public required string Name { get; set; } + + public string? Url { get; set; } + + public bool? Verified { get; set; } +} + +/// +/// An entry in a template index pointing to a template. +/// +internal sealed class GitTemplateIndexEntry +{ + public required string Name { get; set; } + + public required string DisplayName { get; set; } + + public required string Description { get; set; } + + public required string Path { get; set; } + + public string? Repo { get; set; } + + public string? Language { get; set; } + + public List? Tags { get; set; } + + public string? MinAspireVersion { get; set; } +} + +/// +/// A reference to another template index (federation). +/// +internal sealed class GitTemplateIndexInclude +{ + public required string Url { get; set; } + + public string? Description { get; set; } +} diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateJsonContext.cs b/src/Aspire.Cli/Templating/Git/GitTemplateJsonContext.cs new file mode 100644 index 00000000000..542249b0130 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateJsonContext.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Aspire.Cli.Templating.Git; + +[JsonSourceGenerationOptions( + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(GitTemplateManifest))] +[JsonSerializable(typeof(GitTemplateIndex))] +internal sealed partial class GitTemplateJsonContext : JsonSerializerContext +{ + private static GitTemplateJsonContext? s_relaxedEscaping; + + /// + /// Gets a context configured with relaxed JSON escaping for user-facing output. + /// + public static GitTemplateJsonContext RelaxedEscaping => s_relaxedEscaping ??= new(new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); +} diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs b/src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs new file mode 100644 index 00000000000..8933e96e787 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Aspire.Cli.Templating.Git; + +/// +/// Represents the deserialized content of an aspire-template.json file. +/// +internal sealed class GitTemplateManifest +{ + [JsonPropertyName("$schema")] + public string? Schema { get; set; } + + public int Version { get; set; } = 1; + + public required string Name { get; set; } + + public required string DisplayName { get; set; } + + public required string Description { get; set; } + + public string? Language { get; set; } + + public Dictionary? Variables { get; set; } + + public GitTemplateSubstitutions? Substitutions { get; set; } + + public Dictionary? ConditionalFiles { get; set; } + + public List? PostMessages { get; set; } +} diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateSubstitutions.cs b/src/Aspire.Cli/Templating/Git/GitTemplateSubstitutions.cs new file mode 100644 index 00000000000..b71ae9fef29 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateSubstitutions.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Templating.Git; + +/// +/// Substitution rules applied during template instantiation. +/// +internal sealed class GitTemplateSubstitutions +{ + /// + /// Gets or sets filename patterns mapped to replacement expressions. + /// + public Dictionary? Filenames { get; set; } + + /// + /// Gets or sets file content patterns mapped to replacement expressions. + /// + public Dictionary? Content { get; set; } +} diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs b/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs new file mode 100644 index 00000000000..6f1df434395 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs @@ -0,0 +1,99 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Aspire.Cli.Templating.Git; + +/// +/// Defines a variable that the user can set when applying a template. +/// +internal sealed class GitTemplateVariable +{ + public required string DisplayName { get; set; } + + public string? Description { get; set; } + + public required string Type { get; set; } + + public bool? Required { get; set; } + + [JsonConverter(typeof(JsonObjectConverter))] + public object? DefaultValue { get; set; } + + public GitTemplateVariableValidation? Validation { get; set; } + + public List? Choices { get; set; } +} + +/// +/// Validation rules for a template variable. +/// +internal sealed class GitTemplateVariableValidation +{ + public string? Pattern { get; set; } + + public string? Message { get; set; } + + public int? Min { get; set; } + + public int? Max { get; set; } +} + +/// +/// A choice option for a choice-type variable. +/// +internal sealed class GitTemplateVariableChoice +{ + public required string Value { get; set; } + + public required string DisplayName { get; set; } + + public string? Description { get; set; } +} + +/// +/// Handles deserializing default values that can be strings, booleans, or integers. +/// +internal sealed class JsonObjectConverter : JsonConverter +{ + public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.String => reader.GetString(), + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.Number when reader.TryGetInt32(out var intVal) => intVal, + JsonTokenType.Number => reader.GetDouble(), + JsonTokenType.Null => null, + _ => throw new JsonException($"Unexpected token type: {reader.TokenType}") + }; + } + + public override void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) + { + switch (value) + { + case null: + writer.WriteNullValue(); + break; + case string s: + writer.WriteStringValue(s); + break; + case bool b: + writer.WriteBooleanValue(b); + break; + case int i: + writer.WriteNumberValue(i); + break; + case double d: + writer.WriteNumberValue(d); + break; + default: + writer.WriteStringValue(value.ToString()); + break; + } + } +} From 2e8dde7e4f813770c64e2be1bb9450446ab4515b Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Feb 2026 20:54:58 +1100 Subject: [PATCH 05/28] Simplify new-index: remove publisher prompts The publisher URL is the GitHub URL the index was fetched from, so there's no need to prompt for it. The command now writes a minimal index file with a placeholder template entry and no interactive prompts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/Template/TemplateNewIndexCommand.cs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs index d095325fe94..186e1556173 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs @@ -51,23 +51,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } } - var publisherName = await InteractionService.PromptForStringAsync( - "Publisher name", - required: true, - cancellationToken: cancellationToken).ConfigureAwait(false); - - var publisherUrl = await InteractionService.PromptForStringAsync( - "Publisher URL (optional)", - cancellationToken: cancellationToken).ConfigureAwait(false); - var index = new GitTemplateIndex { Schema = "https://aka.ms/aspire/template-index-schema/v1", - Publisher = new GitTemplateIndexPublisher - { - Name = publisherName, - Url = string.IsNullOrWhiteSpace(publisherUrl) ? null : publisherUrl - }, Templates = [ new GitTemplateIndexEntry From a52d270226c9bf44ded76d724c7e8950f12f352d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Feb 2026 20:59:13 +1100 Subject: [PATCH 06/28] Remove displayName/description from models for now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip displayName, description, and minAspireVersion from the schema models and scaffolding commands. Keep the models minimal — these can be added back incrementally when needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Template/TemplateNewIndexCommand.cs | 2 -- .../Template/TemplateNewManifestCommand.cs | 30 +------------------ .../Templating/Git/GitTemplateIndex.cs | 8 +---- .../Templating/Git/GitTemplateManifest.cs | 4 +-- .../Templating/Git/GitTemplateVariable.cs | 8 ----- 5 files changed, 3 insertions(+), 49 deletions(-) diff --git a/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs index 186e1556173..dae15d961eb 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs @@ -59,8 +59,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell new GitTemplateIndexEntry { Name = "my-template", - DisplayName = "My Template", - Description = "A template created with aspire template new-index.", Path = "templates/my-template" } ] diff --git a/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs index 75223ebdc7b..fe42248dbbf 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs @@ -62,45 +62,22 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell required: true, cancellationToken: cancellationToken).ConfigureAwait(false); - var displayName = await InteractionService.PromptForStringAsync( - "Display name", - required: true, - cancellationToken: cancellationToken).ConfigureAwait(false); - - var description = await InteractionService.PromptForStringAsync( - "Description", - required: true, - cancellationToken: cancellationToken).ConfigureAwait(false); - - var languages = new[] { "csharp", "typescript", "python", "go", "java", "rust" }; - var language = await InteractionService.PromptForSelectionAsync( - "Primary language", - languages, - l => l, - cancellationToken: cancellationToken).ConfigureAwait(false); - var canonicalName = ToPascalCase(name); var manifest = new GitTemplateManifest { Schema = "https://aka.ms/aspire/template-schema/v1", Name = name, - DisplayName = displayName, - Description = description, - Language = language, Variables = new Dictionary { ["projectName"] = new() { - DisplayName = "Project Name", - Description = "The name for your new Aspire application.", Type = "string", Required = true, DefaultValue = canonicalName, Validation = new GitTemplateVariableValidation { Pattern = "^[A-Za-z][A-Za-z0-9_.]*$", - Message = "Project name must start with a letter and contain only letters, digits, dots, and underscores." } } }, @@ -115,12 +92,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell [canonicalName] = "{{projectName}}", [canonicalName.ToLowerInvariant()] = "{{projectName | lowercase}}" } - }, - PostMessages = - [ - $"Your Aspire application '{{{{{nameof(GitTemplateManifest.Name)}}}}}' has been created!", - "Run `cd {{projectName}} && dotnet run --project {{projectName}}.AppHost` to start the application." - ] + } }; Directory.CreateDirectory(targetDir); diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateIndex.cs b/src/Aspire.Cli/Templating/Git/GitTemplateIndex.cs index fe8952672b3..cccf0e4319e 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplateIndex.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplateIndex.cs @@ -41,9 +41,7 @@ internal sealed class GitTemplateIndexEntry { public required string Name { get; set; } - public required string DisplayName { get; set; } - - public required string Description { get; set; } + public string? Description { get; set; } public required string Path { get; set; } @@ -52,8 +50,6 @@ internal sealed class GitTemplateIndexEntry public string? Language { get; set; } public List? Tags { get; set; } - - public string? MinAspireVersion { get; set; } } /// @@ -62,6 +58,4 @@ internal sealed class GitTemplateIndexEntry internal sealed class GitTemplateIndexInclude { public required string Url { get; set; } - - public string? Description { get; set; } } diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs b/src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs index 8933e96e787..b72a8d50183 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs @@ -17,9 +17,7 @@ internal sealed class GitTemplateManifest public required string Name { get; set; } - public required string DisplayName { get; set; } - - public required string Description { get; set; } + public string? Description { get; set; } public string? Language { get; set; } diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs b/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs index 6f1df434395..a823b36fea9 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs @@ -11,10 +11,6 @@ namespace Aspire.Cli.Templating.Git; /// internal sealed class GitTemplateVariable { - public required string DisplayName { get; set; } - - public string? Description { get; set; } - public required string Type { get; set; } public bool? Required { get; set; } @@ -47,10 +43,6 @@ internal sealed class GitTemplateVariableValidation internal sealed class GitTemplateVariableChoice { public required string Value { get; set; } - - public required string DisplayName { get; set; } - - public string? Description { get; set; } } /// From f6f1297f6f31fc143f600ef26d5a01391eebc23b Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Feb 2026 21:12:52 +1100 Subject: [PATCH 07/28] Add index resolution, caching, and live commands (Phase 3) Implement the template index resolution pipeline: - GitTemplateSource: identifies an index source (repo URL + ref) - GitTemplateCache: local file cache with TTL in ~/.aspire/cache/git-templates/ - IGitTemplateIndexService / GitTemplateIndexService: fetches indexes from GitHub via raw.githubusercontent.com, walks include graph with cycle detection and depth limit (5), caches results Wire up commands to the index service: - 'aspire template list' fetches and displays all templates in a table - 'aspire template search ' filters by name/tags - 'aspire template refresh' clears cache and re-fetches Sources are configured via: - templates.indexes.default.repo (defaults to dotnet/aspire) - templates.indexes.default.ref (defaults to release/latest) - templates.indexes..repo for additional sources Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/Template/TemplateListCommand.cs | 35 ++- .../Template/TemplateRefreshCommand.cs | 16 +- .../Template/TemplateSearchCommand.cs | 60 ++++- src/Aspire.Cli/Program.cs | 9 + .../Templating/Git/GitTemplateCache.cs | 94 ++++++++ .../Templating/Git/GitTemplateIndexService.cs | 219 ++++++++++++++++++ .../Templating/Git/GitTemplateSource.cs | 39 ++++ .../Git/IGitTemplateIndexService.cs | 35 +++ 8 files changed, 496 insertions(+), 11 deletions(-) create mode 100644 src/Aspire.Cli/Templating/Git/GitTemplateCache.cs create mode 100644 src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs create mode 100644 src/Aspire.Cli/Templating/Git/GitTemplateSource.cs create mode 100644 src/Aspire.Cli/Templating/Git/IGitTemplateIndexService.cs diff --git a/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs index 5e494269da5..d9ba59cb8c9 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs @@ -5,11 +5,14 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.Telemetry; +using Aspire.Cli.Templating.Git; using Aspire.Cli.Utils; +using Spectre.Console; namespace Aspire.Cli.Commands.Template; internal sealed class TemplateListCommand( + IGitTemplateIndexService indexService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, @@ -17,9 +20,35 @@ internal sealed class TemplateListCommand( AspireCliTelemetry telemetry) : BaseTemplateSubCommand("list", "List available templates from all configured sources", features, updateNotifier, executionContext, interactionService, telemetry) { - protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - InteractionService.DisplayMessage("information", "Git-based template listing is not yet implemented."); - return Task.FromResult(ExitCodeConstants.Success); + var templates = await InteractionService.ShowStatusAsync( + ":magnifying_glass_tilted_right: Fetching templates...", + () => indexService.GetTemplatesAsync(cancellationToken: cancellationToken)); + + if (templates.Count == 0) + { + InteractionService.DisplayMessage("information", "No templates found. Try 'aspire template refresh' to update the index cache."); + return ExitCodeConstants.Success; + } + + var table = new Table(); + table.Border(TableBorder.Rounded); + table.AddColumn(new TableColumn("[bold]Name[/]").NoWrap()); + table.AddColumn(new TableColumn("[bold]Source[/]")); + table.AddColumn(new TableColumn("[bold]Language[/]")); + table.AddColumn(new TableColumn("[bold]Tags[/]")); + + foreach (var t in templates) + { + table.AddRow( + t.Entry.Name.EscapeMarkup(), + t.Source.Name.EscapeMarkup(), + (t.Entry.Language ?? "").EscapeMarkup(), + t.Entry.Tags is { Count: > 0 } ? string.Join(", ", t.Entry.Tags).EscapeMarkup() : ""); + } + + AnsiConsole.Write(table); + return ExitCodeConstants.Success; } } diff --git a/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs index 41fb6cd1222..143a1605f9b 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs @@ -5,11 +5,13 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.Telemetry; +using Aspire.Cli.Templating.Git; using Aspire.Cli.Utils; namespace Aspire.Cli.Commands.Template; internal sealed class TemplateRefreshCommand( + IGitTemplateIndexService indexService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, @@ -17,9 +19,17 @@ internal sealed class TemplateRefreshCommand( AspireCliTelemetry telemetry) : BaseTemplateSubCommand("refresh", "Force refresh the template index cache", features, updateNotifier, executionContext, interactionService, telemetry) { - protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - InteractionService.DisplayMessage("information", "Git-based template cache refresh is not yet implemented."); - return Task.FromResult(ExitCodeConstants.Success); + await InteractionService.ShowStatusAsync( + ":counterclockwise_arrows_button: Refreshing template index cache...", + async () => + { + await indexService.RefreshAsync(cancellationToken); + return 0; + }); + + InteractionService.DisplaySuccess("Template index cache refreshed."); + return ExitCodeConstants.Success; } } diff --git a/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs index 2952d8c2231..0d4714dba0e 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs @@ -5,7 +5,9 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.Telemetry; +using Aspire.Cli.Templating.Git; using Aspire.Cli.Utils; +using Spectre.Console; namespace Aspire.Cli.Commands.Template; @@ -13,10 +15,13 @@ internal sealed class TemplateSearchCommand : BaseTemplateSubCommand { private static readonly Argument s_keywordArgument = new("keyword") { - Description = "Search keyword to filter templates by name, description, or tags" + Description = "Search keyword to filter templates by name or tags" }; + private readonly IGitTemplateIndexService _indexService; + public TemplateSearchCommand( + IGitTemplateIndexService indexService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, @@ -24,13 +29,58 @@ public TemplateSearchCommand( AspireCliTelemetry telemetry) : base("search", "Search templates by keyword", features, updateNotifier, executionContext, interactionService, telemetry) { + _indexService = indexService; Arguments.Add(s_keywordArgument); } - protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var keyword = parseResult.GetValue(s_keywordArgument)!; + + var allTemplates = await InteractionService.ShowStatusAsync( + ":magnifying_glass_tilted_right: Searching templates...", + () => _indexService.GetTemplatesAsync(cancellationToken: cancellationToken)); + + var matches = allTemplates.Where(t => Matches(t, keyword)).ToList(); + + if (matches.Count == 0) + { + InteractionService.DisplayMessage("information", $"No templates matching '{keyword}'."); + return ExitCodeConstants.Success; + } + + var table = new Table(); + table.Border(TableBorder.Rounded); + table.AddColumn(new TableColumn("[bold]Name[/]").NoWrap()); + table.AddColumn(new TableColumn("[bold]Source[/]")); + table.AddColumn(new TableColumn("[bold]Language[/]")); + table.AddColumn(new TableColumn("[bold]Tags[/]")); + + foreach (var t in matches) + { + table.AddRow( + t.Entry.Name.EscapeMarkup(), + t.Source.Name.EscapeMarkup(), + (t.Entry.Language ?? "").EscapeMarkup(), + t.Entry.Tags is { Count: > 0 } ? string.Join(", ", t.Entry.Tags).EscapeMarkup() : ""); + } + + AnsiConsole.Write(table); + return ExitCodeConstants.Success; + } + + private static bool Matches(ResolvedTemplate template, string keyword) { - var keyword = parseResult.GetValue(s_keywordArgument); - InteractionService.DisplayMessage("information", $"Git-based template search is not yet implemented. Keyword: {keyword}"); - return Task.FromResult(ExitCodeConstants.Success); + if (template.Entry.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (template.Entry.Tags is { Count: > 0 }) + { + return template.Entry.Tags.Any(tag => tag.Contains(keyword, StringComparison.OrdinalIgnoreCase)); + } + + return false; } } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index fc3039ac57c..cd619e2487b 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -408,6 +408,15 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + + // Git template index services + builder.Services.AddSingleton(sp => + { + var cacheDir = Path.Combine(GetCacheDirectory().FullName, "git-templates"); + return new Templating.Git.GitTemplateCache(cacheDir); + }); + builder.Services.AddSingleton(); + builder.Services.AddHttpClient("git-templates"); builder.Services.AddTransient(); #if DEBUG builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateCache.cs b/src/Aspire.Cli/Templating/Git/GitTemplateCache.cs new file mode 100644 index 00000000000..31ed4ccfef4 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateCache.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Aspire.Cli.Templating.Git; + +/// +/// Local file cache for resolved template indexes. +/// +internal sealed class GitTemplateCache +{ + private readonly string _cacheDir; + + public GitTemplateCache(string cacheDir) + { + _cacheDir = cacheDir; + Directory.CreateDirectory(_cacheDir); + } + + /// + /// Gets a cached index if it exists and is within the TTL. + /// + public GitTemplateIndex? Get(string cacheKey, TimeSpan ttl) + { + var path = GetPath(cacheKey); + if (!File.Exists(path)) + { + return null; + } + + var info = new FileInfo(path); + if (DateTime.UtcNow - info.LastWriteTimeUtc > ttl) + { + return null; + } + + try + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, GitTemplateJsonContext.Default.GitTemplateIndex); + } + catch + { + // Corrupted cache entry — delete and return null. + TryDelete(path); + return null; + } + } + + /// + /// Stores an index in the cache. + /// + public void Set(string cacheKey, GitTemplateIndex index) + { + var path = GetPath(cacheKey); + var json = JsonSerializer.Serialize(index, GitTemplateJsonContext.RelaxedEscaping.GitTemplateIndex); + File.WriteAllText(path, json); + } + + /// + /// Removes all cached entries. + /// + public void Clear() + { + if (Directory.Exists(_cacheDir)) + { + foreach (var file in Directory.GetFiles(_cacheDir, "*.json")) + { + TryDelete(file); + } + } + } + + private string GetPath(string cacheKey) + { + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(cacheKey))).ToLowerInvariant(); + return Path.Combine(_cacheDir, $"{hash}.json"); + } + + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch + { + // Best-effort cleanup. + } + } +} diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs b/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs new file mode 100644 index 00000000000..09564defb49 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs @@ -0,0 +1,219 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.Configuration; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Templating.Git; + +/// +/// Fetches, parses, and caches template indexes from git-hosted sources. +/// +internal sealed class GitTemplateIndexService : IGitTemplateIndexService +{ + private const string DefaultRepo = "https://github.com/dotnet/aspire"; + private const string DefaultRef = "release/latest"; + private const string IndexFileName = "aspire-template-index.json"; + private const int MaxIncludeDepth = 5; + + private static readonly TimeSpan s_defaultCacheTtl = TimeSpan.FromHours(1); + + private readonly GitTemplateCache _cache; + private readonly IConfigurationService _configService; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public GitTemplateIndexService( + GitTemplateCache cache, + IConfigurationService configService, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _cache = cache; + _configService = configService; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async Task> GetTemplatesAsync(bool forceRefresh = false, CancellationToken cancellationToken = default) + { + var sources = await GetSourcesAsync(cancellationToken).ConfigureAwait(false); + var result = new List(); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var source in sources) + { + await ResolveIndexAsync(source, result, visited, depth: 0, forceRefresh, cancellationToken).ConfigureAwait(false); + } + + return result; + } + + public async Task RefreshAsync(CancellationToken cancellationToken = default) + { + _cache.Clear(); + await GetTemplatesAsync(forceRefresh: true, cancellationToken).ConfigureAwait(false); + } + + private async Task ResolveIndexAsync( + GitTemplateSource source, + List result, + HashSet visited, + int depth, + bool forceRefresh, + CancellationToken cancellationToken) + { + if (depth > MaxIncludeDepth) + { + _logger.LogWarning("Max include depth ({Depth}) reached for {Repo}, skipping.", MaxIncludeDepth, source.Repo); + return; + } + + if (!visited.Add(source.CacheKey)) + { + _logger.LogDebug("Cycle detected for {CacheKey}, skipping.", source.CacheKey); + return; + } + + var index = forceRefresh ? null : _cache.Get(source.CacheKey, s_defaultCacheTtl); + + if (index is null) + { + index = await FetchIndexAsync(source, cancellationToken).ConfigureAwait(false); + + if (index is null) + { + _logger.LogDebug("No index found at {Repo}@{Ref}.", source.Repo, source.Ref ?? "HEAD"); + return; + } + + _cache.Set(source.CacheKey, index); + } + + foreach (var entry in index.Templates) + { + result.Add(new ResolvedTemplate { Entry = entry, Source = source }); + } + + if (index.Includes is { Count: > 0 }) + { + foreach (var include in index.Includes) + { + var includeSource = new GitTemplateSource + { + Name = include.Url, + Repo = include.Url, + Kind = GitTemplateSourceKind.Configured + }; + + await ResolveIndexAsync(includeSource, result, visited, depth + 1, forceRefresh, cancellationToken).ConfigureAwait(false); + } + } + } + + private async Task FetchIndexAsync(GitTemplateSource source, CancellationToken cancellationToken) + { + var rawUrl = BuildRawUrl(source.Repo, source.Ref ?? DefaultRef, IndexFileName); + + if (rawUrl is null) + { + _logger.LogWarning("Cannot build raw URL for {Repo}. Only GitHub URLs are supported.", source.Repo); + return null; + } + + try + { + var client = _httpClientFactory.CreateClient("git-templates"); + var response = await client.GetAsync(rawUrl, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + _logger.LogDebug("HTTP {StatusCode} fetching index from {Url}.", response.StatusCode, rawUrl); + return null; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize(json, GitTemplateJsonContext.Default.GitTemplateIndex); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) + { + _logger.LogDebug(ex, "Failed to fetch index from {Url}.", rawUrl); + return null; + } + } + + private async Task> GetSourcesAsync(CancellationToken cancellationToken) + { + var sources = new List(); + + // 1. Default official source + var disableDefault = await _configService.GetConfigurationAsync("templates.indexes.default.disabled", cancellationToken).ConfigureAwait(false); + if (!string.Equals(disableDefault, "true", StringComparison.OrdinalIgnoreCase)) + { + var defaultRepo = await _configService.GetConfigurationAsync("templates.indexes.default.repo", cancellationToken).ConfigureAwait(false); + var defaultRef = await _configService.GetConfigurationAsync("templates.indexes.default.ref", cancellationToken).ConfigureAwait(false); + + sources.Add(new GitTemplateSource + { + Name = "default", + Repo = defaultRepo ?? DefaultRepo, + Ref = defaultRef ?? DefaultRef, + Kind = GitTemplateSourceKind.Official + }); + } + + // 2. Additional configured sources + // Config keys: templates.indexes..repo and templates.indexes..ref + var allConfig = await _configService.GetAllConfigurationAsync(cancellationToken).ConfigureAwait(false); + var indexNames = allConfig.Keys + .Where(k => k.StartsWith("templates:indexes:", StringComparison.OrdinalIgnoreCase) && k.EndsWith(":repo", StringComparison.OrdinalIgnoreCase)) + .Select(k => + { + // Extract name from "templates:indexes::repo" + var parts = k.Split(':'); + return parts.Length >= 4 ? parts[2] : null; + }) + .Where(n => n is not null && !string.Equals(n, "default", StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + foreach (var indexName in indexNames) + { + allConfig.TryGetValue($"templates:indexes:{indexName}:repo", out var repo); + allConfig.TryGetValue($"templates:indexes:{indexName}:ref", out var gitRef); + + if (repo is not null) + { + sources.Add(new GitTemplateSource + { + Name = indexName!, + Repo = repo, + Ref = gitRef, + Kind = GitTemplateSourceKind.Configured + }); + } + } + + return sources; + } + + /// + /// Converts a GitHub repo URL to a raw content URL for a file. + /// + internal static string? BuildRawUrl(string repoUrl, string gitRef, string filePath) + { + // Handle https://github.com/owner/repo or https://github.com/owner/repo.git + if (repoUrl.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase)) + { + var path = repoUrl["https://github.com/".Length..].TrimEnd('/'); + if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + { + path = path[..^4]; + } + + return $"https://raw.githubusercontent.com/{path}/{gitRef}/{filePath}"; + } + + return null; + } +} diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateSource.cs b/src/Aspire.Cli/Templating/Git/GitTemplateSource.cs new file mode 100644 index 00000000000..1e50349ef96 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateSource.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Templating.Git; + +/// +/// Represents the origin of a template index. +/// +internal enum GitTemplateSourceKind +{ + /// + /// The official default index from the dotnet/aspire repo. + /// + Official, + + /// + /// An index added via user configuration. + /// + Configured +} + +/// +/// Identifies a template index source by its repo URL and optional git ref. +/// +internal sealed class GitTemplateSource +{ + public required string Name { get; init; } + + public required string Repo { get; init; } + + public string? Ref { get; init; } + + public GitTemplateSourceKind Kind { get; init; } + + /// + /// Gets a stable key for caching based on repo and ref. + /// + public string CacheKey => $"{Repo}@{Ref ?? "HEAD"}"; +} diff --git a/src/Aspire.Cli/Templating/Git/IGitTemplateIndexService.cs b/src/Aspire.Cli/Templating/Git/IGitTemplateIndexService.cs new file mode 100644 index 00000000000..90e3ab0a12c --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/IGitTemplateIndexService.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Templating.Git; + +/// +/// Resolves and caches template indexes from configured sources. +/// +internal interface IGitTemplateIndexService +{ + /// + /// Gets all resolved template entries across all configured sources. + /// + Task> GetTemplatesAsync(bool forceRefresh = false, CancellationToken cancellationToken = default); + + /// + /// Invalidates the cache and re-fetches all indexes. + /// + Task RefreshAsync(CancellationToken cancellationToken = default); +} + +/// +/// A template entry with its source information attached. +/// +internal sealed class ResolvedTemplate +{ + public required GitTemplateIndexEntry Entry { get; init; } + + public required GitTemplateSource Source { get; init; } + + /// + /// Gets the effective repo URL — either the entry's explicit repo or the source's repo. + /// + public string EffectiveRepo => Entry.Repo ?? Source.Repo; +} From 852c189555997244d0999fccd566eb05cfad0a23 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Feb 2026 21:14:45 +1100 Subject: [PATCH 08/28] Add template application engine (Phase 4) Implement the core template application pipeline: - TemplateExpressionEvaluator: evaluates {{var}} and {{var | filter}} expressions with filters: lowercase, uppercase, kebabcase, snakecase, camelcase, pascalcase - IGitTemplateEngine / GitTemplateEngine: applies a template by copying files from a template directory to an output directory with filename and content substitutions, conditional file exclusion, binary file detection (extension allowlist + null-byte sniffing), and post-creation message display Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Program.cs | 1 + .../Templating/Git/GitTemplateEngine.cs | 234 ++++++++++++++++++ .../Templating/Git/IGitTemplateEngine.cs | 19 ++ .../Git/TemplateExpressionEvaluator.cs | 84 +++++++ 4 files changed, 338 insertions(+) create mode 100644 src/Aspire.Cli/Templating/Git/GitTemplateEngine.cs create mode 100644 src/Aspire.Cli/Templating/Git/IGitTemplateEngine.cs create mode 100644 src/Aspire.Cli/Templating/Git/TemplateExpressionEvaluator.cs diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index cd619e2487b..2db03c76c8a 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -416,6 +416,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar return new Templating.Git.GitTemplateCache(cacheDir); }); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddHttpClient("git-templates"); builder.Services.AddTransient(); #if DEBUG diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateEngine.cs b/src/Aspire.Cli/Templating/Git/GitTemplateEngine.cs new file mode 100644 index 00000000000..bfd53fb9fe9 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateEngine.cs @@ -0,0 +1,234 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Templating.Git; + +/// +/// Applies a git-based template by copying files with substitutions. +/// +internal sealed class GitTemplateEngine : IGitTemplateEngine +{ + private static readonly HashSet s_binaryExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".gif", ".ico", ".bmp", ".webp", ".svg", + ".woff", ".woff2", ".ttf", ".eot", ".otf", + ".zip", ".gz", ".tar", ".7z", ".rar", + ".dll", ".exe", ".so", ".dylib", + ".pdf", ".doc", ".docx", ".xls", ".xlsx", + ".db", ".sqlite", ".mdb", + ".pfx", ".p12", ".cer", ".pem" + }; + + private static readonly HashSet s_excludedFiles = new(StringComparer.OrdinalIgnoreCase) + { + "aspire-template.json" + }; + + private static readonly HashSet s_excludedDirs = new(StringComparer.OrdinalIgnoreCase) + { + ".git", ".github" + }; + + private readonly ILogger _logger; + + public GitTemplateEngine(ILogger logger) + { + _logger = logger; + } + + public async Task ApplyAsync( + string templateDir, + string outputDir, + IReadOnlyDictionary variables, + CancellationToken cancellationToken = default) + { + var manifestPath = Path.Combine(templateDir, "aspire-template.json"); + GitTemplateManifest? manifest = null; + + if (File.Exists(manifestPath)) + { + var json = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false); + manifest = JsonSerializer.Deserialize(json, GitTemplateJsonContext.Default.GitTemplateManifest); + } + + // Build substitution maps from manifest + variables + var filenameSubstitutions = manifest?.Substitutions?.Filenames ?? new Dictionary(); + var contentSubstitutions = manifest?.Substitutions?.Content ?? new Dictionary(); + var conditionalFiles = manifest?.ConditionalFiles ?? new Dictionary(); + + // Resolve substitution patterns → actual replacement values + var resolvedFilenameMap = ResolveSubstitutions(filenameSubstitutions, variables); + var resolvedContentMap = ResolveSubstitutions(contentSubstitutions, variables); + + Directory.CreateDirectory(outputDir); + + // Copy and transform files + await CopyDirectoryAsync( + templateDir, outputDir, + resolvedFilenameMap, resolvedContentMap, + conditionalFiles, variables, + cancellationToken).ConfigureAwait(false); + + // Display post-creation messages + if (manifest?.PostMessages is { Count: > 0 }) + { + foreach (var message in manifest.PostMessages) + { + var evaluated = TemplateExpressionEvaluator.Evaluate(message, variables); + _logger.LogInformation("{Message}", evaluated); + } + } + } + + private static async Task CopyDirectoryAsync( + string sourceDir, + string destDir, + Dictionary filenameMap, + Dictionary contentMap, + Dictionary conditionalFiles, + IReadOnlyDictionary variables, + CancellationToken cancellationToken) + { + foreach (var dir in Directory.GetDirectories(sourceDir)) + { + var dirName = Path.GetFileName(dir); + + if (s_excludedDirs.Contains(dirName)) + { + continue; + } + + var relativePath = Path.GetRelativePath(sourceDir, dir); + if (IsExcludedByCondition(relativePath + "/", conditionalFiles, variables)) + { + continue; + } + + var destDirName = ApplyFilenameSubstitutions(dirName, filenameMap); + var destSubDir = Path.Combine(destDir, destDirName); + Directory.CreateDirectory(destSubDir); + + await CopyDirectoryAsync(dir, destSubDir, filenameMap, contentMap, conditionalFiles, variables, cancellationToken).ConfigureAwait(false); + } + + foreach (var file in Directory.GetFiles(sourceDir)) + { + var fileName = Path.GetFileName(file); + + if (s_excludedFiles.Contains(fileName)) + { + continue; + } + + var relativePath = Path.GetRelativePath(sourceDir, file); + if (IsExcludedByCondition(relativePath, conditionalFiles, variables)) + { + continue; + } + + var destFileName = ApplyFilenameSubstitutions(fileName, filenameMap); + var destPath = Path.Combine(destDir, destFileName); + + if (IsBinaryFile(file)) + { + File.Copy(file, destPath, overwrite: true); + } + else + { + var content = await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false); + + foreach (var (pattern, replacement) in contentMap) + { + content = content.Replace(pattern, replacement, StringComparison.Ordinal); + } + + await File.WriteAllTextAsync(destPath, content, cancellationToken).ConfigureAwait(false); + } + } + } + + private static Dictionary ResolveSubstitutions( + Dictionary substitutionMap, + IReadOnlyDictionary variables) + { + var resolved = new Dictionary(); + + foreach (var (pattern, expression) in substitutionMap) + { + resolved[pattern] = TemplateExpressionEvaluator.Evaluate(expression, variables); + } + + return resolved; + } + + private static string ApplyFilenameSubstitutions(string name, Dictionary filenameMap) + { + foreach (var (pattern, replacement) in filenameMap) + { + name = name.Replace(pattern, replacement, StringComparison.Ordinal); + } + + return name; + } + + private static bool IsExcludedByCondition( + string relativePath, + Dictionary conditionalFiles, + IReadOnlyDictionary variables) + { + foreach (var (pathPattern, expression) in conditionalFiles) + { + if (!relativePath.StartsWith(pathPattern, StringComparison.OrdinalIgnoreCase) && + !string.Equals(relativePath, pathPattern, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var value = TemplateExpressionEvaluator.Evaluate(expression, variables); + + // Exclude if the condition evaluates to false/empty + if (string.IsNullOrEmpty(value) || + string.Equals(value, "false", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static bool IsBinaryFile(string filePath) + { + var ext = Path.GetExtension(filePath); + if (s_binaryExtensions.Contains(ext)) + { + return true; + } + + // Null-byte sniff for unknown extensions + try + { + var buffer = new byte[8192]; + using var stream = File.OpenRead(filePath); + var bytesRead = stream.Read(buffer, 0, buffer.Length); + + for (var i = 0; i < bytesRead; i++) + { + if (buffer[i] == 0) + { + return true; + } + } + } + catch + { + // If we can't read it, treat as binary to be safe. + return true; + } + + return false; + } +} diff --git a/src/Aspire.Cli/Templating/Git/IGitTemplateEngine.cs b/src/Aspire.Cli/Templating/Git/IGitTemplateEngine.cs new file mode 100644 index 00000000000..426b1aac739 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/IGitTemplateEngine.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Templating.Git; + +/// +/// Applies a git-based template to create a new project. +/// +internal interface IGitTemplateEngine +{ + /// + /// Applies the template from a local directory to the output directory. + /// + /// Directory containing the template files and aspire-template.json. + /// Output directory for the new project. + /// Variable values to use for substitution. + /// Cancellation token. + Task ApplyAsync(string templateDir, string outputDir, IReadOnlyDictionary variables, CancellationToken cancellationToken = default); +} diff --git a/src/Aspire.Cli/Templating/Git/TemplateExpressionEvaluator.cs b/src/Aspire.Cli/Templating/Git/TemplateExpressionEvaluator.cs new file mode 100644 index 00000000000..7b738b21597 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/TemplateExpressionEvaluator.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; + +namespace Aspire.Cli.Templating.Git; + +/// +/// Evaluates template expressions like {{variableName}} and {{variableName | filter}}. +/// +internal static partial class TemplateExpressionEvaluator +{ + /// + /// Replaces all {{...}} expressions in the input string using the provided variable values. + /// + public static string Evaluate(string input, IReadOnlyDictionary variables) + { + return ExpressionPattern().Replace(input, match => + { + var expression = match.Groups[1].Value.Trim(); + var parts = expression.Split('|', 2, StringSplitOptions.TrimEntries); + var variableName = parts[0]; + var filter = parts.Length > 1 ? parts[1] : null; + + if (!variables.TryGetValue(variableName, out var value)) + { + // Leave unresolved expressions as-is. + return match.Value; + } + + return filter is not null ? ApplyFilter(value, filter) : value; + }); + } + + private static string ApplyFilter(string value, string filter) => + filter.ToLowerInvariant() switch + { + "lowercase" => value.ToLowerInvariant(), + "uppercase" => value.ToUpperInvariant(), + "kebabcase" => ToKebabCase(value), + "snakecase" => ToSnakeCase(value), + "camelcase" => ToCamelCase(value), + "pascalcase" => ToPascalCase(value), + _ => value + }; + + private static string ToKebabCase(string value) + { + return string.Concat(SplitWords(value).Select((w, i) => + (i > 0 ? "-" : "") + w.ToLowerInvariant())); + } + + private static string ToSnakeCase(string value) + { + return string.Concat(SplitWords(value).Select((w, i) => + (i > 0 ? "_" : "") + w.ToLowerInvariant())); + } + + private static string ToCamelCase(string value) + { + return string.Concat(SplitWords(value).Select((w, i) => + i == 0 ? w.ToLowerInvariant() : char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant())); + } + + private static string ToPascalCase(string value) + { + return string.Concat(SplitWords(value).Select(w => + char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant())); + } + + private static IEnumerable SplitWords(string value) + { + // Split on transitions: lowercase→uppercase, non-letter→letter, or explicit separators + return WordSplitPattern().Matches(value) + .Select(m => m.Value) + .Where(w => w.Length > 0); + } + + [GeneratedRegex(@"\{\{(.+?)\}\}")] + private static partial Regex ExpressionPattern(); + + [GeneratedRegex(@"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\d|\b)|[A-Z]|\d+")] + private static partial Regex WordSplitPattern(); +} From 091f3b3db8cf6981021d640da663afae7b11b328 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Feb 2026 21:16:52 +1100 Subject: [PATCH 09/28] Integrate GitTemplateFactory into aspire new (Phase 5) Add GitTemplate and GitTemplateFactory to make git-hosted templates appear alongside dotnet new templates in 'aspire new': - GitTemplate: ITemplate that clones a template repo (sparse checkout), prompts for variables, and applies via GitTemplateEngine - GitTemplateFactory: ITemplateFactory that yields GitTemplate instances from the resolved index - Registered via TryAddEnumerable alongside DotNetTemplateFactory Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Program.cs | 1 + src/Aspire.Cli/Templating/Git/GitTemplate.cs | 249 ++++++++++++++++++ .../Templating/Git/GitTemplateFactory.cs | 56 ++++ 3 files changed, 306 insertions(+) create mode 100644 src/Aspire.Cli/Templating/Git/GitTemplate.cs create mode 100644 src/Aspire.Cli/Templating/Git/GitTemplateFactory.cs diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 2db03c76c8a..8bd26f0c6c8 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -329,6 +329,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddSingleton(); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); // Language discovery for polyglot support. builder.Services.AddSingleton(); diff --git a/src/Aspire.Cli/Templating/Git/GitTemplate.cs b/src/Aspire.Cli/Templating/Git/GitTemplate.cs new file mode 100644 index 00000000000..c3ec335fcf2 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplate.cs @@ -0,0 +1,249 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Diagnostics; +using System.Text.Json; +using Aspire.Cli.Interaction; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Templating.Git; + +/// +/// An backed by a git-hosted template. +/// +internal sealed class GitTemplate : ITemplate +{ + private readonly ResolvedTemplate _resolved; + private readonly IGitTemplateEngine _engine; + private readonly IInteractionService _interactionService; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public GitTemplate( + ResolvedTemplate resolved, + IGitTemplateEngine engine, + IInteractionService interactionService, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _resolved = resolved; + _engine = engine; + _interactionService = interactionService; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public string Name => _resolved.Entry.Name; + + public string Description => _resolved.Entry.Description ?? $"Git template from {_resolved.Source.Name}"; + + public Func PathDeriver => name => name; + + public void ApplyOptions(Commands.TemplateCommand command) + { + // Git templates don't add CLI options — variables are prompted interactively. + } + + public async Task ApplyTemplateAsync( + TemplateInputs inputs, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var projectName = inputs.Name ?? Path.GetFileName(Directory.GetCurrentDirectory()); + var outputDir = inputs.Output ?? Path.Combine(Directory.GetCurrentDirectory(), projectName); + outputDir = Path.GetFullPath(outputDir); + + // Fetch the template content to a temp directory + var tempDir = Path.Combine(Path.GetTempPath(), "aspire-git-templates", Guid.NewGuid().ToString("N")); + + try + { + var fetched = await _interactionService.ShowStatusAsync( + $":package: Fetching template '{Name}'...", + () => FetchTemplateAsync(tempDir, cancellationToken)); + + if (!fetched) + { + return new TemplateResult(1); + } + + // Read manifest to discover variables + var manifestPath = Path.Combine(tempDir, "aspire-template.json"); + GitTemplateManifest? manifest = null; + + if (File.Exists(manifestPath)) + { + var json = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false); + manifest = JsonSerializer.Deserialize(json, GitTemplateJsonContext.Default.GitTemplateManifest); + } + + // Collect variable values + var variables = new Dictionary + { + ["projectName"] = projectName + }; + + if (manifest?.Variables is not null) + { + foreach (var (varName, varDef) in manifest.Variables) + { + if (variables.ContainsKey(varName)) + { + continue; + } + + var defaultValue = varDef.DefaultValue?.ToString(); + var value = await _interactionService.PromptForStringAsync( + varName, + defaultValue: defaultValue, + cancellationToken: cancellationToken).ConfigureAwait(false); + + variables[varName] = value; + } + } + + // Apply the template + await _engine.ApplyAsync(tempDir, outputDir, variables, cancellationToken).ConfigureAwait(false); + + _interactionService.DisplaySuccess($"Created project at {outputDir}"); + return new TemplateResult(0, outputDir); + } + finally + { + // Clean up temp directory + try + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + catch + { + // Best-effort cleanup. + } + } + } + + private async Task FetchTemplateAsync(string targetDir, CancellationToken cancellationToken) + { + var repo = _resolved.EffectiveRepo; + var gitRef = _resolved.Source.Ref ?? "HEAD"; + var templatePath = _resolved.Entry.Path; + + // Use git clone with sparse checkout for the template path + Directory.CreateDirectory(targetDir); + + try + { + var cloneResult = await RunGitAsync( + targetDir, + ["clone", "--depth", "1", "--branch", gitRef, "--sparse", "--filter=blob:none", repo, "."], + cancellationToken).ConfigureAwait(false); + + if (cloneResult != 0) + { + // Fall back to cloning without --branch (for refs like refs/pull/123/head) + cloneResult = await RunGitAsync( + targetDir, + ["clone", "--depth", "1", "--sparse", "--filter=blob:none", repo, "."], + cancellationToken).ConfigureAwait(false); + + if (cloneResult != 0) + { + _logger.LogError("Failed to clone repository {Repo}.", repo); + return false; + } + + // Fetch the specific ref + await RunGitAsync(targetDir, ["fetch", "origin", gitRef], cancellationToken).ConfigureAwait(false); + await RunGitAsync(targetDir, ["checkout", "FETCH_HEAD"], cancellationToken).ConfigureAwait(false); + } + + if (templatePath is not "." and not "") + { + await RunGitAsync( + targetDir, + ["sparse-checkout", "set", templatePath], + cancellationToken).ConfigureAwait(false); + } + + // Move template files from subdirectory to target root if needed + var templateSubDir = Path.Combine(targetDir, templatePath); + if (templatePath is not "." and not "" && Directory.Exists(templateSubDir)) + { + var tempMove = targetDir + "_move"; + Directory.Move(templateSubDir, tempMove); + + // Clear the original dir (except .git) + foreach (var entry in Directory.GetFileSystemEntries(targetDir)) + { + var name = Path.GetFileName(entry); + if (name == ".git" || entry == tempMove) + { + continue; + } + + if (Directory.Exists(entry)) + { + Directory.Delete(entry, recursive: true); + } + else + { + File.Delete(entry); + } + } + + // Move template files back to root + foreach (var entry in Directory.GetFileSystemEntries(tempMove)) + { + var dest = Path.Combine(targetDir, Path.GetFileName(entry)); + if (Directory.Exists(entry)) + { + Directory.Move(entry, dest); + } + else + { + File.Move(entry, dest); + } + } + + Directory.Delete(tempMove, recursive: true); + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch template from {Repo}.", repo); + return false; + } + } + + private static async Task RunGitAsync(string workingDir, string[] args, CancellationToken cancellationToken) + { + var psi = new ProcessStartInfo("git") + { + WorkingDirectory = workingDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + foreach (var arg in args) + { + psi.ArgumentList.Add(arg); + } + + using var process = Process.Start(psi); + if (process is null) + { + return -1; + } + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + return process.ExitCode; + } +} diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateFactory.cs b/src/Aspire.Cli/Templating/Git/GitTemplateFactory.cs new file mode 100644 index 00000000000..fff1bff26bb --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateFactory.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Interaction; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Templating.Git; + +/// +/// An that provides templates from git-hosted indexes. +/// +internal sealed class GitTemplateFactory : ITemplateFactory +{ + private readonly IGitTemplateIndexService _indexService; + private readonly IGitTemplateEngine _engine; + private readonly IInteractionService _interactionService; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _templateLogger; + + public GitTemplateFactory( + IGitTemplateIndexService indexService, + IGitTemplateEngine engine, + IInteractionService interactionService, + IHttpClientFactory httpClientFactory, + ILogger templateLogger) + { + _indexService = indexService; + _engine = engine; + _interactionService = interactionService; + _httpClientFactory = httpClientFactory; + _templateLogger = templateLogger; + } + + public IEnumerable GetTemplates() + { + // Synchronously get cached templates (no network calls on the hot path). + // Templates are populated after 'aspire template refresh' or on first list/search. + var templates = _indexService.GetTemplatesAsync().GetAwaiter().GetResult(); + + foreach (var resolved in templates) + { + yield return new GitTemplate( + resolved, + _engine, + _interactionService, + _httpClientFactory, + _templateLogger); + } + } + + public IEnumerable GetInitTemplates() + { + // Git templates are not used for 'aspire init' — only for 'aspire new'. + return []; + } +} From 900f85135233d4583a223467c8eaf1345726de3b Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Feb 2026 21:23:24 +1100 Subject: [PATCH 10/28] Support local paths in template index sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local paths (absolute, relative, or Windows drive paths) are now supported as index sources. This enables template development workflows: aspire config set templates.indexes.dev.repo /path/to/my-templates Local indexes are never cached — they're always read fresh from disk so changes are reflected immediately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Templating/Git/GitTemplateIndexService.cs | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs b/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs index 09564defb49..18ee37ad893 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs @@ -76,7 +76,8 @@ private async Task ResolveIndexAsync( return; } - var index = forceRefresh ? null : _cache.Get(source.CacheKey, s_defaultCacheTtl); + var isLocal = IsLocalPath(source.Repo); + var index = (forceRefresh || isLocal) ? null : _cache.Get(source.CacheKey, s_defaultCacheTtl); if (index is null) { @@ -88,7 +89,10 @@ private async Task ResolveIndexAsync( return; } - _cache.Set(source.CacheKey, index); + if (!isLocal) + { + _cache.Set(source.CacheKey, index); + } } foreach (var entry in index.Templates) @@ -114,11 +118,17 @@ private async Task ResolveIndexAsync( private async Task FetchIndexAsync(GitTemplateSource source, CancellationToken cancellationToken) { + // Local path support — for template development and testing. + if (IsLocalPath(source.Repo)) + { + return await FetchLocalIndexAsync(source.Repo, cancellationToken).ConfigureAwait(false); + } + var rawUrl = BuildRawUrl(source.Repo, source.Ref ?? DefaultRef, IndexFileName); if (rawUrl is null) { - _logger.LogWarning("Cannot build raw URL for {Repo}. Only GitHub URLs are supported.", source.Repo); + _logger.LogWarning("Cannot build raw URL for {Repo}. Only GitHub URLs and local paths are supported.", source.Repo); return null; } @@ -143,6 +153,35 @@ private async Task ResolveIndexAsync( } } + private async Task FetchLocalIndexAsync(string localPath, CancellationToken cancellationToken) + { + var indexPath = Path.Combine(localPath, IndexFileName); + + if (!File.Exists(indexPath)) + { + _logger.LogDebug("No index file at {Path}.", indexPath); + return null; + } + + try + { + var json = await File.ReadAllTextAsync(indexPath, cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize(json, GitTemplateJsonContext.Default.GitTemplateIndex); + } + catch (Exception ex) when (ex is IOException or JsonException) + { + _logger.LogDebug(ex, "Failed to read local index at {Path}.", indexPath); + return null; + } + } + + private static bool IsLocalPath(string repo) + { + return repo.StartsWith('/') || + repo.StartsWith('.') || + (repo.Length >= 3 && repo[1] == ':' && (repo[2] == '/' || repo[2] == '\\')); + } + private async Task> GetSourcesAsync(CancellationToken cancellationToken) { var sources = new List(); From fda67d90c0d6d0967ef3ced244b23f3d24e5dff0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Feb 2026 21:30:50 +1100 Subject: [PATCH 11/28] Fix config key format in index source discovery GetAllConfigurationAsync returns dot-separated keys (e.g. templates.indexes.dev.repo) but the code was filtering for colon-separated keys (templates:indexes:dev:repo). Fixed to use dot notation consistently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Templating/Git/GitTemplateIndexService.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs b/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs index 18ee37ad893..633d376c691 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs @@ -206,11 +206,11 @@ private async Task> GetSourcesAsync(CancellationToken ca // Config keys: templates.indexes..repo and templates.indexes..ref var allConfig = await _configService.GetAllConfigurationAsync(cancellationToken).ConfigureAwait(false); var indexNames = allConfig.Keys - .Where(k => k.StartsWith("templates:indexes:", StringComparison.OrdinalIgnoreCase) && k.EndsWith(":repo", StringComparison.OrdinalIgnoreCase)) + .Where(k => k.StartsWith("templates.indexes.", StringComparison.OrdinalIgnoreCase) && k.EndsWith(".repo", StringComparison.OrdinalIgnoreCase)) .Select(k => { - // Extract name from "templates:indexes::repo" - var parts = k.Split(':'); + // Extract name from "templates.indexes..repo" + var parts = k.Split('.'); return parts.Length >= 4 ? parts[2] : null; }) .Where(n => n is not null && !string.Equals(n, "default", StringComparison.OrdinalIgnoreCase)) @@ -218,8 +218,8 @@ private async Task> GetSourcesAsync(CancellationToken ca foreach (var indexName in indexNames) { - allConfig.TryGetValue($"templates:indexes:{indexName}:repo", out var repo); - allConfig.TryGetValue($"templates:indexes:{indexName}:ref", out var gitRef); + allConfig.TryGetValue($"templates.indexes.{indexName}.repo", out var repo); + allConfig.TryGetValue($"templates.indexes.{indexName}.ref", out var gitRef); if (repo is not null) { From 06fa7201138044b2bd7decec9993bed4e08a0cb0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Feb 2026 21:48:00 +1100 Subject: [PATCH 12/28] Phase 6: Add telemetry, error handling, and sample template index - Add Telemetry.StartDiagnosticActivity() to list, search, refresh commands - Add try/catch with user-friendly error messages for network failures - Add sample aspire-template-index.json at repo root for default index - Track template counts and search keywords in telemetry tags Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- aspire-template-index.json | 12 +++++++++ .../Commands/Template/TemplateListCommand.cs | 20 ++++++++++++--- .../Template/TemplateRefreshCommand.cs | 25 +++++++++++++------ .../Template/TemplateSearchCommand.cs | 19 +++++++++++--- 4 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 aspire-template-index.json diff --git a/aspire-template-index.json b/aspire-template-index.json new file mode 100644 index 00000000000..7c472286ecc --- /dev/null +++ b/aspire-template-index.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://aka.ms/aspire/template-index-schema/v1", + "version": 1, + "templates": [ + { + "name": "aspire-starter", + "path": "templates/aspire-starter", + "language": "csharp", + "tags": ["starter", "web", "api"] + } + ] +} diff --git a/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs index d9ba59cb8c9..46509c9cc8c 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs @@ -22,9 +22,23 @@ internal sealed class TemplateListCommand( { protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - var templates = await InteractionService.ShowStatusAsync( - ":magnifying_glass_tilted_right: Fetching templates...", - () => indexService.GetTemplatesAsync(cancellationToken: cancellationToken)); + using var activity = Telemetry.StartDiagnosticActivity("template-list"); + + IReadOnlyList templates; + try + { + templates = await InteractionService.ShowStatusAsync( + ":magnifying_glass_tilted_right: Fetching templates...", + () => indexService.GetTemplatesAsync(cancellationToken: cancellationToken)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Telemetry.RecordError("Failed to fetch template list", ex); + InteractionService.DisplayError("Failed to fetch templates. Check your network connection and try again."); + return 1; + } + + activity?.AddTag("template.count", templates.Count); if (templates.Count == 0) { diff --git a/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs index 143a1605f9b..0cccd633471 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs @@ -21,13 +21,24 @@ internal sealed class TemplateRefreshCommand( { protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - await InteractionService.ShowStatusAsync( - ":counterclockwise_arrows_button: Refreshing template index cache...", - async () => - { - await indexService.RefreshAsync(cancellationToken); - return 0; - }); + using var activity = Telemetry.StartDiagnosticActivity("template-refresh"); + + try + { + await InteractionService.ShowStatusAsync( + ":counterclockwise_arrows_button: Refreshing template index cache...", + async () => + { + await indexService.RefreshAsync(cancellationToken); + return 0; + }); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Telemetry.RecordError("Failed to refresh template cache", ex); + InteractionService.DisplayError("Failed to refresh template cache. Check your network connection and try again."); + return 1; + } InteractionService.DisplaySuccess("Template index cache refreshed."); return ExitCodeConstants.Success; diff --git a/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs index 0d4714dba0e..7fb7d512d42 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs @@ -36,12 +36,25 @@ public TemplateSearchCommand( protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { var keyword = parseResult.GetValue(s_keywordArgument)!; + using var activity = Telemetry.StartDiagnosticActivity("template-search"); + activity?.AddTag("template.keyword", keyword); - var allTemplates = await InteractionService.ShowStatusAsync( - ":magnifying_glass_tilted_right: Searching templates...", - () => _indexService.GetTemplatesAsync(cancellationToken: cancellationToken)); + IReadOnlyList allTemplates; + try + { + allTemplates = await InteractionService.ShowStatusAsync( + ":magnifying_glass_tilted_right: Searching templates...", + () => _indexService.GetTemplatesAsync(cancellationToken: cancellationToken)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Telemetry.RecordError("Failed to search templates", ex); + InteractionService.DisplayError("Failed to fetch templates. Check your network connection and try again."); + return 1; + } var matches = allTemplates.Where(t => Matches(t, keyword)).ToList(); + activity?.AddTag("template.matchCount", matches.Count); if (matches.Count == 0) { From 90e0c87787851f2c5fab70f77b15c6825eba5a31 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Feb 2026 21:54:09 +1100 Subject: [PATCH 13/28] Fix local template application: copy files directly instead of git clone For local template sources, FetchTemplateAsync was incorrectly using git clone with sparse checkout, which doesn't work for local directory-based templates. Now directly copies files from the resolved local path, skipping .git directories. Also adds IsLocalPath and CopyDirectoryRecursive helpers to GitTemplate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Templating/Git/GitTemplate.cs | 61 +++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Templating/Git/GitTemplate.cs b/src/Aspire.Cli/Templating/Git/GitTemplate.cs index c3ec335fcf2..322b760ea94 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplate.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplate.cs @@ -129,9 +129,16 @@ public async Task ApplyTemplateAsync( private async Task FetchTemplateAsync(string targetDir, CancellationToken cancellationToken) { var repo = _resolved.EffectiveRepo; - var gitRef = _resolved.Source.Ref ?? "HEAD"; var templatePath = _resolved.Entry.Path; + // For local sources, copy files directly instead of git clone. + if (IsLocalPath(repo)) + { + return CopyLocalTemplate(repo, templatePath, targetDir); + } + + var gitRef = _resolved.Source.Ref ?? "HEAD"; + // Use git clone with sparse checkout for the template path Directory.CreateDirectory(targetDir); @@ -221,6 +228,58 @@ await RunGitAsync( } } + private bool CopyLocalTemplate(string repoPath, string templatePath, string targetDir) + { + try + { + // Resolve the template path relative to the repo (index) directory + var sourceDir = Path.GetFullPath(Path.Combine(repoPath, templatePath)); + + if (!Directory.Exists(sourceDir)) + { + _logger.LogError("Local template directory not found: {Path}.", sourceDir); + return false; + } + + CopyDirectoryRecursive(sourceDir, targetDir); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to copy local template from {Repo}/{Path}.", repoPath, templatePath); + return false; + } + } + + private static void CopyDirectoryRecursive(string sourceDir, string destDir) + { + Directory.CreateDirectory(destDir); + + foreach (var file in Directory.GetFiles(sourceDir)) + { + var destFile = Path.Combine(destDir, Path.GetFileName(file)); + File.Copy(file, destFile, overwrite: true); + } + + foreach (var dir in Directory.GetDirectories(sourceDir)) + { + var dirName = Path.GetFileName(dir); + if (string.Equals(dirName, ".git", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + CopyDirectoryRecursive(dir, Path.Combine(destDir, dirName)); + } + } + + private static bool IsLocalPath(string path) + { + return path.StartsWith('/') || + path.StartsWith('.') || + (path.Length >= 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\')); + } + private static async Task RunGitAsync(string workingDir, string[] args, CancellationToken cancellationToken) { var psi = new ProcessStartInfo("git") From c2d76dcc65833f76b07d6e89a90d47c482e44c78 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Feb 2026 22:43:05 +1100 Subject: [PATCH 14/28] Add GitHub CLI auto-discovery of aspire-templates repos Implements IGitHubCliRunner interface wrapping the 'gh' CLI to: - Check if gh is installed and authenticated - Get the authenticated user's username - List the user's GitHub organizations - Probe whether owner/aspire-templates repos exist GitTemplateIndexService now auto-discovers template sources by probing {username}/aspire-templates and {org}/aspire-templates repos in parallel. Controlled via templates.enablePersonalDiscovery and templates.enableOrgDiscovery config values (both default to true). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/GitHub/GitHubCliRunner.cs | 118 ++++++++++++++++++ src/Aspire.Cli/GitHub/IGitHubCliRunner.cs | 41 ++++++ src/Aspire.Cli/Program.cs | 1 + .../Templating/Git/GitTemplateIndexService.cs | 102 +++++++++++++++ .../Templating/Git/GitTemplateSource.cs | 12 +- 5 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 src/Aspire.Cli/GitHub/GitHubCliRunner.cs create mode 100644 src/Aspire.Cli/GitHub/IGitHubCliRunner.cs diff --git a/src/Aspire.Cli/GitHub/GitHubCliRunner.cs b/src/Aspire.Cli/GitHub/GitHubCliRunner.cs new file mode 100644 index 00000000000..1ceb0f6ee71 --- /dev/null +++ b/src/Aspire.Cli/GitHub/GitHubCliRunner.cs @@ -0,0 +1,118 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.GitHub; + +/// +/// Runs GitHub CLI (gh) commands by invoking the gh executable. +/// +internal sealed class GitHubCliRunner(ILogger logger) : IGitHubCliRunner +{ + public async Task IsInstalledAsync(CancellationToken cancellationToken) + { + var path = PathLookupHelper.FindFullPathFromPath("gh"); + if (path is null) + { + logger.LogDebug("GitHub CLI (gh) is not installed or not found in PATH."); + return false; + } + + return await Task.FromResult(true).ConfigureAwait(false); + } + + public async Task IsAuthenticatedAsync(CancellationToken cancellationToken) + { + // Use 'auth token' instead of 'auth status' because 'auth status' returns non-zero + // if any account (including inactive ones) has an invalid token. + var (exitCode, _) = await RunAsync(["auth", "token"], cancellationToken).ConfigureAwait(false); + return exitCode == 0; + } + + public async Task GetUsernameAsync(CancellationToken cancellationToken) + { + var (exitCode, output) = await RunAsync(["api", "user", "--jq", ".login"], cancellationToken).ConfigureAwait(false); + + if (exitCode != 0 || string.IsNullOrWhiteSpace(output)) + { + logger.LogDebug("Failed to get GitHub username (exit code {ExitCode}).", exitCode); + return null; + } + + return output.Trim(); + } + + public async Task> GetOrganizationsAsync(CancellationToken cancellationToken) + { + var (exitCode, output) = await RunAsync( + ["api", "user/orgs", "--jq", ".[].login"], + cancellationToken).ConfigureAwait(false); + + if (exitCode != 0 || string.IsNullOrWhiteSpace(output)) + { + logger.LogDebug("Failed to get GitHub organizations (exit code {ExitCode}).", exitCode); + return []; + } + + return output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + public async Task RepoExistsAsync(string owner, string repo, CancellationToken cancellationToken) + { + var (exitCode, _) = await RunAsync( + ["repo", "view", $"{owner}/{repo}", "--json", "name"], + cancellationToken).ConfigureAwait(false); + + return exitCode == 0; + } + + private async Task<(int ExitCode, string Output)> RunAsync(string[] args, CancellationToken cancellationToken) + { + var executablePath = PathLookupHelper.FindFullPathFromPath("gh"); + if (executablePath is null) + { + return (-1, string.Empty); + } + + try + { + var startInfo = new ProcessStartInfo(executablePath) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + var errorOutput = await errorTask.ConfigureAwait(false); + logger.LogDebug("gh {Args} returned exit code {ExitCode}: {Error}", + string.Join(' ', args), process.ExitCode, errorOutput.Trim()); + } + + var output = await outputTask.ConfigureAwait(false); + return (process.ExitCode, output); + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + logger.LogDebug(ex, "Failed to run gh {Args}.", string.Join(' ', args)); + return (-1, string.Empty); + } + } +} diff --git a/src/Aspire.Cli/GitHub/IGitHubCliRunner.cs b/src/Aspire.Cli/GitHub/IGitHubCliRunner.cs new file mode 100644 index 00000000000..f73667217eb --- /dev/null +++ b/src/Aspire.Cli/GitHub/IGitHubCliRunner.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.GitHub; + +/// +/// Runs GitHub CLI (gh) commands. +/// +internal interface IGitHubCliRunner +{ + /// + /// Gets whether the GitHub CLI is installed and available on PATH. + /// + Task IsInstalledAsync(CancellationToken cancellationToken); + + /// + /// Gets whether the user is authenticated with the GitHub CLI. + /// + Task IsAuthenticatedAsync(CancellationToken cancellationToken); + + /// + /// Gets the authenticated user's GitHub username. + /// + /// The username, or null if not authenticated or an error occurs. + Task GetUsernameAsync(CancellationToken cancellationToken); + + /// + /// Gets the list of GitHub organizations the authenticated user belongs to. + /// + /// A list of organization logins, or an empty list if not authenticated or an error occurs. + Task> GetOrganizationsAsync(CancellationToken cancellationToken); + + /// + /// Checks whether a GitHub repository exists and is accessible. + /// + /// The repository owner (user or org). + /// The repository name. + /// A cancellation token. + /// true if the repository exists and is accessible; otherwise false. + Task RepoExistsAsync(string owner, string repo, CancellationToken cancellationToken); +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 8bd26f0c6c8..1961cec5410 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -418,6 +418,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddHttpClient("git-templates"); builder.Services.AddTransient(); #if DEBUG diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs b/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs index 633d376c691..9aa249a71d3 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Aspire.Cli.Configuration; +using Aspire.Cli.GitHub; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Templating.Git; @@ -17,21 +18,26 @@ internal sealed class GitTemplateIndexService : IGitTemplateIndexService private const string IndexFileName = "aspire-template-index.json"; private const int MaxIncludeDepth = 5; + private const string TemplateRepoName = "aspire-templates"; + private static readonly TimeSpan s_defaultCacheTtl = TimeSpan.FromHours(1); private readonly GitTemplateCache _cache; private readonly IConfigurationService _configService; + private readonly IGitHubCliRunner _gitHubCli; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; public GitTemplateIndexService( GitTemplateCache cache, IConfigurationService configService, + IGitHubCliRunner gitHubCli, IHttpClientFactory httpClientFactory, ILogger logger) { _cache = cache; _configService = configService; + _gitHubCli = gitHubCli; _httpClientFactory = httpClientFactory; _logger = logger; } @@ -233,9 +239,105 @@ private async Task> GetSourcesAsync(CancellationToken ca } } + // 3. Auto-discover personal and org aspire-templates repos via GitHub CLI + await DiscoverGitHubSourcesAsync(sources, cancellationToken).ConfigureAwait(false); + return sources; } + private async Task DiscoverGitHubSourcesAsync(List sources, CancellationToken cancellationToken) + { + // Check if discovery is disabled + var disablePersonal = await _configService.GetConfigurationAsync("templates.enablePersonalDiscovery", cancellationToken).ConfigureAwait(false); + var disableOrg = await _configService.GetConfigurationAsync("templates.enableOrgDiscovery", cancellationToken).ConfigureAwait(false); + + var personalEnabled = !string.Equals(disablePersonal, "false", StringComparison.OrdinalIgnoreCase); + var orgEnabled = !string.Equals(disableOrg, "false", StringComparison.OrdinalIgnoreCase); + + if (!personalEnabled && !orgEnabled) + { + return; + } + + if (!await _gitHubCli.IsInstalledAsync(cancellationToken).ConfigureAwait(false)) + { + _logger.LogDebug("GitHub CLI not installed, skipping template auto-discovery."); + return; + } + + if (!await _gitHubCli.IsAuthenticatedAsync(cancellationToken).ConfigureAwait(false)) + { + _logger.LogDebug("GitHub CLI not authenticated, skipping template auto-discovery."); + return; + } + + var existingRepos = new HashSet( + sources.Select(s => s.Repo), + StringComparer.OrdinalIgnoreCase); + + // Kick off personal + org discovery in parallel + var probes = new List>(); + + if (personalEnabled) + { + var username = await _gitHubCli.GetUsernameAsync(cancellationToken).ConfigureAwait(false); + if (username is not null) + { + var repoUrl = $"https://github.com/{username}/{TemplateRepoName}"; + if (!existingRepos.Contains(repoUrl)) + { + probes.Add(ProbeRepoAsync(username, repoUrl, GitTemplateSourceKind.Personal, cancellationToken)); + existingRepos.Add(repoUrl); + } + } + } + + if (orgEnabled) + { + var orgs = await _gitHubCli.GetOrganizationsAsync(cancellationToken).ConfigureAwait(false); + foreach (var org in orgs) + { + var repoUrl = $"https://github.com/{org}/{TemplateRepoName}"; + if (!existingRepos.Contains(repoUrl)) + { + probes.Add(ProbeRepoAsync(org, repoUrl, GitTemplateSourceKind.Organization, cancellationToken)); + existingRepos.Add(repoUrl); + } + } + } + + var results = await Task.WhenAll(probes).ConfigureAwait(false); + + foreach (var source in results) + { + if (source is not null) + { + sources.Add(source); + } + } + } + + private async Task ProbeRepoAsync( + string owner, + string repoUrl, + GitTemplateSourceKind kind, + CancellationToken cancellationToken) + { + if (await _gitHubCli.RepoExistsAsync(owner, TemplateRepoName, cancellationToken).ConfigureAwait(false)) + { + _logger.LogDebug("Discovered template repo: {Repo}.", repoUrl); + return new GitTemplateSource + { + Name = owner, + Repo = repoUrl, + Kind = kind + }; + } + + _logger.LogDebug("No {Repo} repo found for {Owner}.", TemplateRepoName, owner); + return null; + } + /// /// Converts a GitHub repo URL to a raw content URL for a file. /// diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateSource.cs b/src/Aspire.Cli/Templating/Git/GitTemplateSource.cs index 1e50349ef96..1e9b325143b 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplateSource.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplateSource.cs @@ -16,7 +16,17 @@ internal enum GitTemplateSourceKind /// /// An index added via user configuration. /// - Configured + Configured, + + /// + /// Auto-discovered from the authenticated user's personal aspire-templates repo. + /// + Personal, + + /// + /// Auto-discovered from a GitHub organization's aspire-templates repo. + /// + Organization } /// From 15c2bb20bce3e43b6f02d87d47d921a0576012bd Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Feb 2026 22:48:28 +1100 Subject: [PATCH 15/28] Fix default ref for discovered template repos Discovered (personal/org) repos were using the official 'release/latest' default ref, which doesn't exist on community repos. Now uses 'HEAD' (resolves to repo's default branch) for non-official sources. Only the official dotnet/aspire source uses 'release/latest'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Templating/Git/GitTemplateIndexService.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs b/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs index 9aa249a71d3..856917f3f40 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs @@ -15,6 +15,7 @@ internal sealed class GitTemplateIndexService : IGitTemplateIndexService { private const string DefaultRepo = "https://github.com/dotnet/aspire"; private const string DefaultRef = "release/latest"; + private const string CommunityDefaultRef = "HEAD"; private const string IndexFileName = "aspire-template-index.json"; private const int MaxIncludeDepth = 5; @@ -130,7 +131,7 @@ private async Task ResolveIndexAsync( return await FetchLocalIndexAsync(source.Repo, cancellationToken).ConfigureAwait(false); } - var rawUrl = BuildRawUrl(source.Repo, source.Ref ?? DefaultRef, IndexFileName); + var rawUrl = BuildRawUrl(source.Repo, source.Ref ?? DefaultRefForSource(source), IndexFileName); if (rawUrl is null) { @@ -357,4 +358,13 @@ private async Task DiscoverGitHubSourcesAsync(List sources, C return null; } + + /// + /// Gets the default git ref for a source based on its kind. + /// The official dotnet/aspire repo uses release/latest; all others use HEAD. + /// + private static string DefaultRefForSource(GitTemplateSource source) + { + return source.Kind == GitTemplateSourceKind.Official ? DefaultRef : CommunityDefaultRef; + } } From 8fc0d6e68561c1f860fa8fda99d4ca3bfa91d153 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 2 Mar 2026 12:21:49 +1100 Subject: [PATCH 16/28] Fix markdown lint: add language to fenced code blocks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/git-templates.md | 44 ++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/specs/git-templates.md b/docs/specs/git-templates.md index 290a1059697..8b58e4cc71f 100644 --- a/docs/specs/git-templates.md +++ b/docs/specs/git-templates.md @@ -72,7 +72,7 @@ A repository doesn't need an index file if it contains a single template. If a r This is the most common case for community templates. A developer builds an Aspire app, adds `aspire-template.json` to the root, and their repo is immediately usable with `aspire new --template-repo`: -``` +```text my-cool-aspire-app/ ├── aspire-template.json # Makes this repo a template ├── MyCoolApp.sln @@ -301,7 +301,7 @@ The `conditionalFiles` section controls which files are included in the output: A template repository using this system looks like: -``` +```text aspire-templates/ ├── aspire-template-index.json # Root index file ├── templates/ @@ -356,7 +356,7 @@ When a user runs `aspire new`, the CLI resolves available templates through a mu ### Phase 1: Index Collection -``` +```text ┌──────────────────────┐ │ Official Index │ │ microsoft/ │ @@ -417,14 +417,14 @@ Once a template is selected: Template application is a deterministic, side-effect-free process: -``` +```text Input: Template directory + variable values Output: New project directory ``` ### Algorithm -``` +```text 1. COPY template directory → output directory - Skip: aspire-template.json (manifest is not part of output) - Skip: .git/, .github/ (git metadata from template repo) @@ -474,7 +474,7 @@ Files matched by a `.gitignore` in the template directory are excluded from the ### Cache Layout -``` +```text ~/.aspire/ └── template-cache/ ├── indexes/ @@ -495,7 +495,7 @@ Files matched by a `.gitignore` in the template directory are excluded from the The entire git-based template system is gated behind a feature flag: -``` +```text features.gitTemplatesEnabled = true|false (default: false) ``` @@ -607,7 +607,7 @@ When the user overrides `templates.defaultBranch` to `main`, they get templates The `aspire template` command group provides template management and authoring tools. All subcommands are behind the `gitTemplatesEnabled` feature flag. -``` +```text aspire template ├── list List available templates from all sources ├── search Search templates by name, description, or tags @@ -620,7 +620,7 @@ aspire template Lists all available templates from all configured sources, grouped by source: -``` +```text $ aspire template list Official (dotnet/aspire @ release/9.2) @@ -647,7 +647,7 @@ Options: Searches templates by keyword across names, descriptions, and tags: -``` +```text $ aspire template search redis Results for "redis": @@ -664,7 +664,7 @@ Options: Forces a refresh of all cached template indexes and invalidates cached template content: -``` +```text $ aspire template refresh Refreshing template indexes... @@ -680,7 +680,7 @@ Refreshing template indexes... Scaffolds a new `aspire-template.json` manifest file. This helps template authors get started: -``` +```text $ aspire template new Creating aspire-template.json... @@ -704,7 +704,7 @@ If `[path]` is provided, creates the manifest at that path instead of the curren Scaffolds a new `aspire-template-index.json` index file for multi-template repositories or organizational indexes: -``` +```text $ aspire template new-index Creating aspire-template-index.json... @@ -722,7 +722,7 @@ Add templates to the "templates" array to make them discoverable. When `gitTemplatesEnabled` is `true`, `aspire new` shows templates from both the existing `DotNetTemplateFactory` and the new `GitTemplateFactory`. Git-sourced templates appear alongside built-in templates: -``` +```text aspire new [template-name] [options] Arguments: @@ -819,7 +819,7 @@ Because templates are real Aspire applications, polyglot support is inherent: ### C# Template Example -``` +```text templates/aspire-starter/ ├── aspire-template.json ├── AspireStarter.sln @@ -833,7 +833,7 @@ templates/aspire-starter/ ### TypeScript Template Example -``` +```text templates/aspire-ts-starter/ ├── aspire-template.json ├── apphost.ts @@ -847,7 +847,7 @@ templates/aspire-ts-starter/ ### Python Template Example -``` +```text templates/aspire-py-starter/ ├── aspire-template.json ├── apphost.py @@ -1024,7 +1024,7 @@ This section outlines the incremental implementation strategy. The approach is c 6. **Tests** — basic command parsing tests for the new command tree **Key files:** -``` +```text src/Aspire.Cli/ ├── Commands/ │ └── Template/ @@ -1054,7 +1054,7 @@ src/Aspire.Cli/ 4. **JSON schema files** — publish schemas at `https://aka.ms/aspire/template-schema/v1` and `https://aka.ms/aspire/template-index-schema/v1` (or embed for offline use). **Key files:** -``` +```text src/Aspire.Cli/ └── Templating/ └── Git/ @@ -1083,7 +1083,7 @@ src/Aspire.Cli/ 7. **Implement `aspire template refresh`** — invalidate cache and re-fetch all indexes **Key files:** -``` +```text src/Aspire.Cli/ └── Templating/ └── Git/ @@ -1112,7 +1112,7 @@ src/Aspire.Cli/ 4. **Implement `--template-repo` flag on `aspire new`** **Key files:** -``` +```text src/Aspire.Cli/ └── Templating/ └── Git/ @@ -1136,7 +1136,7 @@ src/Aspire.Cli/ 4. **Interactive selection** — `aspire new` without arguments shows all templates grouped by source **Key files:** -``` +```text src/Aspire.Cli/ └── Templating/ └── Git/ From d8ff1a49481b5de90ac27fec258a1bf8e5ad8788 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 2 Mar 2026 13:08:34 +1100 Subject: [PATCH 17/28] Add type-aware variable prompting, template scope, and localization support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LocalizableString type for inline culture-specific translations (string or object with culture keys in aspire-template.json) - Add DisplayName/Description to GitTemplateVariable and GitTemplateVariableChoice - Add DisplayName, Scope to GitTemplateManifest - Add Scope to GitTemplateIndexEntry - Implement type-aware prompting in GitTemplate: string→text prompt with regex, boolean→confirm, choice→selection, integer→min/max - Update GitTemplateFactory to filter by scope for GetTemplates/GetInitTemplates - Fix GitTemplate to implement full ITemplate interface (Runtime, SupportsLanguage, etc.) - Fix KnownEmoji usage in template commands after rebase - Update spec with scope and localization documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/git-templates.md | 57 +++++++- .../Commands/Template/TemplateListCommand.cs | 2 +- .../Template/TemplateNewIndexCommand.cs | 4 +- .../Template/TemplateNewManifestCommand.cs | 2 +- .../Template/TemplateSearchCommand.cs | 2 +- src/Aspire.Cli/Templating/Git/GitTemplate.cs | 89 +++++++++++- .../Templating/Git/GitTemplateFactory.cs | 26 ++-- .../Templating/Git/GitTemplateIndex.cs | 2 + .../Templating/Git/GitTemplateManifest.cs | 6 +- .../Templating/Git/GitTemplateVariable.cs | 8 ++ .../Templating/Git/LocalizableString.cs | 134 ++++++++++++++++++ 11 files changed, 308 insertions(+), 24 deletions(-) create mode 100644 src/Aspire.Cli/Templating/Git/LocalizableString.cs diff --git a/docs/specs/git-templates.md b/docs/specs/git-templates.md index 8b58e4cc71f..bb84a8e8b92 100644 --- a/docs/specs/git-templates.md +++ b/docs/specs/git-templates.md @@ -186,6 +186,7 @@ The template manifest lives inside a template directory and describes how to app "displayName": "Aspire Starter Application", "description": "A full-featured Aspire starter with a web frontend and API backend.", "language": "csharp", + "scope": ["new"], "variables": { "projectName": { "displayName": "Project Name", @@ -257,9 +258,10 @@ The template manifest lives inside a template directory and describes how to app | `$schema` | string | No | JSON schema URL for validation and editor support | | `version` | integer | Yes | Schema version. Must be `1` for this spec. | | `name` | string | Yes | Machine-readable template identifier (must match index entry) | -| `displayName` | string | Yes | Human-readable template name | -| `description` | string | Yes | Short description | +| `displayName` | string \| object | Yes | Human-readable template name (see [Localization](#localization)) | +| `description` | string \| object | Yes | Short description (see [Localization](#localization)) | | `language` | string | No | Primary language | +| `scope` | array | No | Where the template appears: `["new"]`, `["init"]`, or `["new", "init"]`. Default: `["new"]` | | `variables` | object | Yes | Map of variable name → variable definition | | `substitutions` | object | Yes | Substitution rules | | `substitutions.filenames` | object | No | Map of filename patterns → replacement expressions | @@ -297,6 +299,57 @@ The `conditionalFiles` section controls which files are included in the output: - **Boolean variables:** File is included only when the variable is `true`. - **Choice variables:** File/directory is included only when the variable has a truthy (non-empty) value. For more granular control, use the naming convention `{{variableName}}-xunit/` where the directory name encodes the choice. +### Template Scope + +The `scope` field controls where the template appears in the CLI: + +| Scope Value | Description | +|-------------|-------------| +| `"new"` | Template appears in `aspire new` (creates a new project) | +| `"init"` | Template appears in `aspire init` (initializes an existing solution) | + +The field is an array, so a template can appear in both contexts: + +```json +"scope": ["new", "init"] +``` + +If omitted, scope defaults to `["new"]` for backward compatibility. + +### Localization + +String fields that are displayed to the user (`displayName`, `description`) support optional localization. Each such field accepts either a plain string or an object with culture-specific translations: + +**Plain string (no localization):** + +```json +"displayName": "Project Name" +``` + +**Localized string (culture keys):** + +```json +"displayName": { + "en": "Project Name", + "de": "Projektname", + "ja": "プロジェクト名" +} +``` + +The CLI resolves the best match using the current UI culture: + +1. Try exact match (e.g., `en-US`) +2. Try parent culture (e.g., `en`) +3. Fall back to the first entry in the object + +Localizable fields: + +- `aspire-template.json`: `displayName`, `description` +- Variables: `displayName`, `description` +- Choices: `displayName`, `description` + +Localization is optional — templates that use plain strings work unchanged. This design keeps templates self-contained in a single `aspire-template.json` file, avoiding the need for sidecar localization files (unlike the .NET template engine's `localize/templatestrings.{culture}.json` approach). + ## 5. Template Directory Structure A template repository using this system looks like: diff --git a/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs index 46509c9cc8c..9196df0af61 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs @@ -42,7 +42,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (templates.Count == 0) { - InteractionService.DisplayMessage("information", "No templates found. Try 'aspire template refresh' to update the index cache."); + InteractionService.DisplayMessage(KnownEmojis.Information, "No templates found. Try 'aspire template refresh' to update the index cache."); return ExitCodeConstants.Success; } diff --git a/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs index dae15d961eb..3260997ab8f 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs @@ -46,7 +46,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (!overwrite) { - InteractionService.DisplayMessage("information", "Cancelled."); + InteractionService.DisplayMessage(KnownEmojis.Information, "Cancelled."); return ExitCodeConstants.Success; } } @@ -70,7 +70,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false); InteractionService.DisplaySuccess($"Created {outputPath}"); - InteractionService.DisplayMessage("information", "Edit the file to add your templates, then run 'aspire template new' in each template directory."); + InteractionService.DisplayMessage(KnownEmojis.Information, "Edit the file to add your templates, then run 'aspire template new' in each template directory."); return ExitCodeConstants.Success; } } diff --git a/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs index fe42248dbbf..4d70428991b 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs @@ -48,7 +48,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (!overwrite) { - InteractionService.DisplayMessage("information", "Cancelled."); + InteractionService.DisplayMessage(KnownEmojis.Information, "Cancelled."); return ExitCodeConstants.Success; } } diff --git a/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs index 7fb7d512d42..5e7c76577b7 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs @@ -58,7 +58,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (matches.Count == 0) { - InteractionService.DisplayMessage("information", $"No templates matching '{keyword}'."); + InteractionService.DisplayMessage(KnownEmojis.Information, $"No templates matching '{keyword}'."); return ExitCodeConstants.Success; } diff --git a/src/Aspire.Cli/Templating/Git/GitTemplate.cs b/src/Aspire.Cli/Templating/Git/GitTemplate.cs index 322b760ea94..78be632e78b 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplate.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplate.cs @@ -3,9 +3,12 @@ using System.CommandLine; using System.Diagnostics; +using System.Globalization; using System.Text.Json; +using System.Text.RegularExpressions; using Aspire.Cli.Interaction; using Microsoft.Extensions.Logging; +using Spectre.Console; namespace Aspire.Cli.Templating.Git; @@ -38,9 +41,15 @@ public GitTemplate( public string Description => _resolved.Entry.Description ?? $"Git template from {_resolved.Source.Name}"; + public TemplateRuntime Runtime => TemplateRuntime.Cli; + public Func PathDeriver => name => name; - public void ApplyOptions(Commands.TemplateCommand command) + public bool SupportsLanguage(string languageId) => true; + + public IReadOnlyList SelectableAppHostLanguages => []; + + public void ApplyOptions(Command command) { // Git templates don't add CLI options — variables are prompted interactively. } @@ -93,12 +102,8 @@ public async Task ApplyTemplateAsync( continue; } - var defaultValue = varDef.DefaultValue?.ToString(); - var value = await _interactionService.PromptForStringAsync( - varName, - defaultValue: defaultValue, - cancellationToken: cancellationToken).ConfigureAwait(false); - + var promptText = varDef.DisplayName?.Resolve() ?? varName; + var value = await PromptForVariableAsync(promptText, varDef, cancellationToken).ConfigureAwait(false); variables[varName] = value; } } @@ -126,6 +131,76 @@ public async Task ApplyTemplateAsync( } } + private async Task PromptForVariableAsync(string promptText, GitTemplateVariable varDef, CancellationToken cancellationToken) + { + switch (varDef.Type.ToLowerInvariant()) + { + case "boolean": + var boolDefault = varDef.DefaultValue is true; + var boolResult = await _interactionService.ConfirmAsync(promptText, boolDefault, cancellationToken).ConfigureAwait(false); + return boolResult.ToString().ToLowerInvariant(); + + case "choice" when varDef.Choices is { Count: > 0 }: + var selected = await _interactionService.PromptForSelectionAsync( + promptText, + varDef.Choices, + choice => choice.DisplayName?.Resolve() ?? choice.Value, + cancellationToken).ConfigureAwait(false); + return selected.Value; + + case "integer": + var intDefault = varDef.DefaultValue?.ToString(); + var intMin = varDef.Validation?.Min; + var intMax = varDef.Validation?.Max; + return await _interactionService.PromptForStringAsync( + promptText, + defaultValue: intDefault, + validator: input => + { + if (!int.TryParse(input, CultureInfo.InvariantCulture, out var parsed)) + { + return ValidationResult.Error("Value must be an integer."); + } + if (intMin.HasValue && parsed < intMin.Value) + { + return ValidationResult.Error($"Value must be at least {intMin.Value}."); + } + if (intMax.HasValue && parsed > intMax.Value) + { + return ValidationResult.Error($"Value must be at most {intMax.Value}."); + } + return ValidationResult.Success(); + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + + default: // "string" or unknown types + var strDefault = varDef.DefaultValue?.ToString(); + var pattern = varDef.Validation?.Pattern; + var validationMessage = varDef.Validation?.Message; + Func? validator = null; + + if (pattern is not null) + { + var regex = new Regex(pattern, RegexOptions.None, TimeSpan.FromSeconds(1)); + validator = input => + { + if (!regex.IsMatch(input)) + { + return ValidationResult.Error(validationMessage ?? $"Value must match pattern: {pattern}"); + } + return ValidationResult.Success(); + }; + } + + return await _interactionService.PromptForStringAsync( + promptText, + defaultValue: strDefault, + validator: validator, + required: varDef.Required == true, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + private async Task FetchTemplateAsync(string targetDir, CancellationToken cancellationToken) { var repo = _resolved.EffectiveRepo; diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateFactory.cs b/src/Aspire.Cli/Templating/Git/GitTemplateFactory.cs index fff1bff26bb..9b44d74ce73 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplateFactory.cs @@ -31,14 +31,28 @@ public GitTemplateFactory( _templateLogger = templateLogger; } - public IEnumerable GetTemplates() + public Task> GetTemplatesAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(GetTemplatesForScope("new")); + } + + public Task> GetInitTemplatesAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(GetTemplatesForScope("init")); + } + + private IEnumerable GetTemplatesForScope(string scope) { - // Synchronously get cached templates (no network calls on the hot path). - // Templates are populated after 'aspire template refresh' or on first list/search. var templates = _indexService.GetTemplatesAsync().GetAwaiter().GetResult(); foreach (var resolved in templates) { + var entryScope = resolved.Entry.Scope ?? ["new"]; + if (!entryScope.Contains(scope, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + yield return new GitTemplate( resolved, _engine, @@ -47,10 +61,4 @@ public IEnumerable GetTemplates() _templateLogger); } } - - public IEnumerable GetInitTemplates() - { - // Git templates are not used for 'aspire init' — only for 'aspire new'. - return []; - } } diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateIndex.cs b/src/Aspire.Cli/Templating/Git/GitTemplateIndex.cs index cccf0e4319e..589f4b67004 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplateIndex.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplateIndex.cs @@ -50,6 +50,8 @@ internal sealed class GitTemplateIndexEntry public string? Language { get; set; } public List? Tags { get; set; } + + public List? Scope { get; set; } } /// diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs b/src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs index b72a8d50183..d3e7bce2dd0 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs @@ -17,10 +17,14 @@ internal sealed class GitTemplateManifest public required string Name { get; set; } - public string? Description { get; set; } + public LocalizableString? DisplayName { get; set; } + + public LocalizableString? Description { get; set; } public string? Language { get; set; } + public List? Scope { get; set; } + public Dictionary? Variables { get; set; } public GitTemplateSubstitutions? Substitutions { get; set; } diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs b/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs index a823b36fea9..142df7f7977 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs @@ -13,6 +13,10 @@ internal sealed class GitTemplateVariable { public required string Type { get; set; } + public LocalizableString? DisplayName { get; set; } + + public LocalizableString? Description { get; set; } + public bool? Required { get; set; } [JsonConverter(typeof(JsonObjectConverter))] @@ -43,6 +47,10 @@ internal sealed class GitTemplateVariableValidation internal sealed class GitTemplateVariableChoice { public required string Value { get; set; } + + public LocalizableString? DisplayName { get; set; } + + public LocalizableString? Description { get; set; } } /// diff --git a/src/Aspire.Cli/Templating/Git/LocalizableString.cs b/src/Aspire.Cli/Templating/Git/LocalizableString.cs new file mode 100644 index 00000000000..9c759c33843 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/LocalizableString.cs @@ -0,0 +1,134 @@ +// 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; +using System.Text.Json.Serialization; + +namespace Aspire.Cli.Templating.Git; + +/// +/// A string value that can optionally carry culture-specific translations. +/// Deserializes from either a plain JSON string or an object with culture keys. +/// +[JsonConverter(typeof(LocalizableStringJsonConverter))] +internal sealed class LocalizableString +{ + private readonly string? _value; + private readonly Dictionary? _localizations; + + private LocalizableString(string value) + { + _value = value; + } + + private LocalizableString(Dictionary localizations) + { + _localizations = localizations; + } + + /// + /// Creates a from a plain string. + /// + public static LocalizableString FromString(string value) => new(value); + + /// + /// Creates a from culture-keyed translations. + /// + public static LocalizableString FromLocalizations(Dictionary localizations) => new(localizations); + + /// + /// Resolves the best string for the current UI culture. + /// + public string Resolve() + { + if (_value is not null) + { + return _value; + } + + if (_localizations is null or { Count: 0 }) + { + return string.Empty; + } + + var culture = CultureInfo.CurrentUICulture; + + // Try exact match (e.g., "en-US"). + if (_localizations.TryGetValue(culture.Name, out var exact)) + { + return exact; + } + + // Try parent culture (e.g., "en"). + if (!string.IsNullOrEmpty(culture.Parent?.Name) && + _localizations.TryGetValue(culture.Parent.Name, out var parent)) + { + return parent; + } + + // Fall back to first entry. + foreach (var kvp in _localizations) + { + return kvp.Value; + } + + return string.Empty; + } + + public override string ToString() => Resolve(); + + public static implicit operator LocalizableString(string value) => FromString(value); +} + +/// +/// Handles deserializing a from a JSON string or object. +/// +internal sealed class LocalizableStringJsonConverter : JsonConverter +{ + public override LocalizableString? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return LocalizableString.FromString(reader.GetString() ?? string.Empty); + } + + if (reader.TokenType == JsonTokenType.StartObject) + { + var localizations = new Dictionary(StringComparer.OrdinalIgnoreCase); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected property name in localizable string object."); + } + + var key = reader.GetString()!; + reader.Read(); + var value = reader.GetString() ?? string.Empty; + localizations[key] = value; + } + + return LocalizableString.FromLocalizations(localizations); + } + + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + throw new JsonException($"Unexpected token type {reader.TokenType} for LocalizableString."); + } + + public override void Write(Utf8JsonWriter writer, LocalizableString value, JsonSerializerOptions options) + { + // Serialize as a plain string for simplicity. + writer.WriteStringValue(value.Resolve()); + } +} From 899bccbcea158f932df41322d9087fcde316abf9 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 2 Mar 2026 14:02:15 +1100 Subject: [PATCH 18/28] Fix CI: add missing DI registrations for template commands and git template services The test helper was missing registrations for Commands.Template.*, IGitTemplateIndexService, IGitTemplateEngine, IGitHubCliRunner, and GitTemplateCache that were added to Program.cs by the git templates branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 305d4ff4781..aa474745d4a 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -209,6 +209,16 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddSingleton(new Aspire.Cli.Templating.Git.GitTemplateCache(Path.Combine(Path.GetTempPath(), "aspire-cli-tests", "git-templates"))); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); #if DEBUG services.AddTransient(); #endif From b7256ec4ae95c7ce64f2d768a72882dfaaf49898 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 2 Mar 2026 15:22:15 +1100 Subject: [PATCH 19/28] Enable CLI variable binding for git template variables Git template variables can now be provided as --key value pairs on the command line (e.g., aspire new my-template --useRedis true --dbProvider postgres). Variables not provided on the CLI are still prompted interactively. - ApplyOptions sets TreatUnmatchedTokensAsErrors = false to accept passthrough tokens - ParseUnmatchedTokens extracts --key value pairs, bare --flag as true - TryGetCliValue matches both camelCase and kebab-case naming - ValidateCliValue applies type/regex/min-max/choice validation - Updated spec with CLI variable binding documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/git-templates.md | 27 ++++ src/Aspire.Cli/Templating/Git/GitTemplate.cs | 153 ++++++++++++++++++- 2 files changed, 179 insertions(+), 1 deletion(-) diff --git a/docs/specs/git-templates.md b/docs/specs/git-templates.md index bb84a8e8b92..5c52fc34f47 100644 --- a/docs/specs/git-templates.md +++ b/docs/specs/git-templates.md @@ -791,6 +791,33 @@ Options: When `--template-repo` is used, the CLI fetches the specified repo, reads its `aspire-template-index.json` or `aspire-template.json`, and applies the template directly — bypassing normal discovery. +##### CLI Variable Binding + +Git template variables can be provided on the command line as `--variableName value` pairs, allowing non-interactive template application. Variables not provided on the CLI are prompted interactively as usual. + +```text +# Fully interactive (all variables prompted) +aspire new my-template + +# Partially non-interactive (provided values skip prompts) +aspire new my-template --useRedis true --dbProvider postgres + +# Fully non-interactive (all variables provided) +aspire new my-template --name MyProject --useRedis true --dbProvider postgres --port 5432 +``` + +**Naming**: Both camelCase (`--useRedis`) and kebab-case (`--use-redis`) are accepted and matched against manifest variable names. + +**Boolean variables**: A bare flag (`--useRedis`) is treated as `true`. Explicit values (`--useRedis false`) are also supported. + +**Choice variables**: The raw `value` from the choice definition is used on the CLI (e.g., `--dbProvider postgres`), not the localized `displayName`. Invalid values produce an error listing the valid choices: + +```text +Error: Invalid value 'pg' for variable 'dbProvider'. Valid choices are: postgres, sqlserver, mysql +``` + +**Validation**: CLI-provided values go through the same validation rules (regex patterns, min/max bounds, choice membership) as interactively prompted values. + ## 10. Security Model ### Threat Model diff --git a/src/Aspire.Cli/Templating/Git/GitTemplate.cs b/src/Aspire.Cli/Templating/Git/GitTemplate.cs index 78be632e78b..fb335dd9548 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplate.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplate.cs @@ -51,7 +51,10 @@ public GitTemplate( public void ApplyOptions(Command command) { - // Git templates don't add CLI options — variables are prompted interactively. + // Git template variables are discovered at runtime from the manifest, not at command + // construction time. We allow unmatched tokens so users can pass --varName value pairs + // that will be matched against manifest variables during template application. + command.TreatUnmatchedTokensAsErrors = false; } public async Task ApplyTemplateAsync( @@ -63,6 +66,9 @@ public async Task ApplyTemplateAsync( var outputDir = inputs.Output ?? Path.Combine(Directory.GetCurrentDirectory(), projectName); outputDir = Path.GetFullPath(outputDir); + // Parse CLI-provided variable values from unmatched tokens (e.g., --useRedis true --port 5432) + var cliValues = ParseUnmatchedTokens(parseResult); + // Fetch the template content to a temp directory var tempDir = Path.Combine(Path.GetTempPath(), "aspire-git-templates", Guid.NewGuid().ToString("N")); @@ -102,6 +108,19 @@ public async Task ApplyTemplateAsync( continue; } + // Check if the variable was provided on the CLI + if (TryGetCliValue(cliValues, varName, out var cliValue)) + { + var validationError = ValidateCliValue(varName, cliValue, varDef); + if (validationError is not null) + { + _interactionService.DisplayError(validationError); + return new TemplateResult(1); + } + variables[varName] = cliValue; + continue; + } + var promptText = varDef.DisplayName?.Resolve() ?? varName; var value = await PromptForVariableAsync(promptText, varDef, cancellationToken).ConfigureAwait(false); variables[varName] = value; @@ -131,6 +150,138 @@ public async Task ApplyTemplateAsync( } } + /// + /// Parses unmatched CLI tokens into a dictionary of key-value pairs. + /// Supports --key value and bare --flag (treated as boolean true). + /// + private static Dictionary ParseUnmatchedTokens(ParseResult parseResult) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var tokens = parseResult.UnmatchedTokens; + + for (var i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + if (!token.StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + + var key = token[2..]; // Strip leading -- + if (i + 1 < tokens.Count && !tokens[i + 1].StartsWith("--", StringComparison.Ordinal)) + { + result[key] = tokens[i + 1]; + i++; // Skip the value token + } + else + { + // Bare flag (e.g., --useRedis with no value) → treat as boolean true + result[key] = "true"; + } + } + + return result; + } + + /// + /// Attempts to find a CLI-provided value for a manifest variable, supporting both + /// camelCase (e.g., --useRedis) and kebab-case (e.g., --use-redis) naming. + /// + private static bool TryGetCliValue(Dictionary cliValues, string varName, out string value) + { + // Try exact match first (camelCase) + if (cliValues.TryGetValue(varName, out value!)) + { + return true; + } + + // Try kebab-case conversion: "useRedisCache" → "use-redis-cache" + var kebab = ToKebabCase(varName); + if (cliValues.TryGetValue(kebab, out value!)) + { + return true; + } + + value = string.Empty; + return false; + } + + /// + /// Converts a camelCase string to kebab-case. + /// + private static string ToKebabCase(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + var result = new System.Text.StringBuilder(); + for (var i = 0; i < input.Length; i++) + { + var c = input[i]; + if (char.IsUpper(c) && i > 0) + { + result.Append('-'); + } + result.Append(char.ToLowerInvariant(c)); + } + return result.ToString(); + } + + /// + /// Validates a CLI-provided value against the variable definition. + /// Returns an error message if invalid, or null if valid. + /// + private static string? ValidateCliValue(string varName, string value, GitTemplateVariable varDef) + { + switch (varDef.Type.ToLowerInvariant()) + { + case "boolean": + if (!bool.TryParse(value, out _)) + { + return $"Invalid value '{value}' for variable '{varName}'. Expected 'true' or 'false'."; + } + break; + + case "choice" when varDef.Choices is { Count: > 0 }: + var validValues = varDef.Choices.Select(c => c.Value).ToList(); + if (!validValues.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + return $"Invalid value '{value}' for variable '{varName}'. Valid choices are: {string.Join(", ", validValues)}"; + } + break; + + case "integer": + if (!int.TryParse(value, CultureInfo.InvariantCulture, out var parsed)) + { + return $"Invalid value '{value}' for variable '{varName}'. Expected an integer."; + } + if (varDef.Validation?.Min is { } min && parsed < min) + { + return $"Value '{value}' for variable '{varName}' must be at least {min}."; + } + if (varDef.Validation?.Max is { } max && parsed > max) + { + return $"Value '{value}' for variable '{varName}' must be at most {max}."; + } + break; + + default: // "string" or unknown + if (varDef.Validation?.Pattern is { } pattern) + { + var regex = new Regex(pattern, RegexOptions.None, TimeSpan.FromSeconds(1)); + if (!regex.IsMatch(value)) + { + return varDef.Validation.Message ?? $"Value '{value}' for variable '{varName}' must match pattern: {pattern}"; + } + } + break; + } + + return null; + } + private async Task PromptForVariableAsync(string promptText, GitTemplateVariable varDef, CancellationToken cancellationToken) { switch (varDef.Type.ToLowerInvariant()) From fd8335654253a32724f61ecaeb2dafc6ecee549e Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 2 Mar 2026 16:43:05 +1100 Subject: [PATCH 20/28] Prompt for project name and output directory when not provided Previously GitTemplate silently used the current directory name as the project name and derived the output path. Now it interactively prompts for both values (with sensible defaults) when --name and --output are not provided on the command line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Templating/Git/GitTemplate.cs | 25 ++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Templating/Git/GitTemplate.cs b/src/Aspire.Cli/Templating/Git/GitTemplate.cs index fb335dd9548..89eb1e6fc8f 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplate.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplate.cs @@ -62,8 +62,29 @@ public async Task ApplyTemplateAsync( ParseResult parseResult, CancellationToken cancellationToken) { - var projectName = inputs.Name ?? Path.GetFileName(Directory.GetCurrentDirectory()); - var outputDir = inputs.Output ?? Path.Combine(Directory.GetCurrentDirectory(), projectName); + // Prompt for project name if not provided via --name + var projectName = inputs.Name; + if (string.IsNullOrWhiteSpace(projectName)) + { + var defaultName = Path.GetFileName(Directory.GetCurrentDirectory()); + projectName = await _interactionService.PromptForStringAsync( + "Project name", + defaultValue: defaultName, + required: true, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + // Prompt for output directory if not provided via --output + var outputDir = inputs.Output; + if (string.IsNullOrWhiteSpace(outputDir)) + { + var defaultOutput = Path.Combine(Directory.GetCurrentDirectory(), projectName); + outputDir = await _interactionService.PromptForStringAsync( + "Output directory", + defaultValue: defaultOutput, + required: true, + cancellationToken: cancellationToken).ConfigureAwait(false); + } outputDir = Path.GetFullPath(outputDir); // Parse CLI-provided variable values from unmatched tokens (e.g., --useRedis true --port 5432) From 0ca2103626da71d13534e709243d9ae95fc16dc3 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 2 Mar 2026 18:17:12 +1100 Subject: [PATCH 21/28] Add postInstructions rendering for git templates Templates can define postInstructions blocks that are displayed after template application. Each block has: - heading: slug line displayed with # prefix styling - priority: 'primary' (rocket emoji, bold) or 'secondary' (info emoji, dim) - lines: instruction text with {{variable}} placeholder substitution - condition: optional 'var == value' or 'var != value' expression Primary instructions are rendered first, then secondary. Blocks whose condition evaluates to false are omitted entirely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Templating/Git/GitTemplate.cs | 128 ++++++++++++++++++ .../Templating/Git/GitTemplateManifest.cs | 32 +++++ 2 files changed, 160 insertions(+) diff --git a/src/Aspire.Cli/Templating/Git/GitTemplate.cs b/src/Aspire.Cli/Templating/Git/GitTemplate.cs index 89eb1e6fc8f..84ce0a91073 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplate.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplate.cs @@ -152,6 +152,13 @@ public async Task ApplyTemplateAsync( await _engine.ApplyAsync(tempDir, outputDir, variables, cancellationToken).ConfigureAwait(false); _interactionService.DisplaySuccess($"Created project at {outputDir}"); + + // Render post-application instructions + if (manifest?.PostInstructions is { Count: > 0 }) + { + RenderPostInstructions(manifest.PostInstructions, variables); + } + return new TemplateResult(0, outputDir); } finally @@ -373,6 +380,127 @@ private async Task PromptForVariableAsync(string promptText, GitTemplate } } + /// + /// Renders post-application instruction blocks, substituting variable placeholders + /// and evaluating conditions to determine which instructions to show. + /// + private void RenderPostInstructions(List instructions, Dictionary variables) + { + // Partition into primary and secondary, filtering by condition + var primary = new List(); + var secondary = new List(); + + foreach (var instruction in instructions) + { + if (!EvaluateCondition(instruction.Condition, variables)) + { + continue; + } + + if (string.Equals(instruction.Priority, "primary", StringComparison.OrdinalIgnoreCase)) + { + primary.Add(instruction); + } + else + { + secondary.Add(instruction); + } + } + + if (primary.Count == 0 && secondary.Count == 0) + { + return; + } + + _interactionService.DisplayPlainText(""); + + foreach (var instruction in primary) + { + RenderInstructionBlock(instruction, variables, isPrimary: true); + } + + foreach (var instruction in secondary) + { + RenderInstructionBlock(instruction, variables, isPrimary: false); + } + } + + private void RenderInstructionBlock(GitTemplatePostInstruction instruction, Dictionary variables, bool isPrimary) + { + var heading = SubstituteVariables(instruction.Heading.Resolve() ?? "", variables); + + if (isPrimary) + { + _interactionService.DisplayMessage(KnownEmojis.Rocket, $"[bold]{heading.EscapeMarkup()}[/]", allowMarkup: true); + } + else + { + _interactionService.DisplayMessage(KnownEmojis.Information, $"[dim]{heading.EscapeMarkup()}[/]", allowMarkup: true); + } + + foreach (var line in instruction.Lines) + { + var rendered = SubstituteVariables(line, variables); + _interactionService.DisplayPlainText($" {rendered}"); + } + + _interactionService.DisplayPlainText(""); + } + + /// + /// Evaluates a simple condition expression against variable values. + /// Supports "varName == value", "varName != value", "varName" (truthy check). + /// Returns true when the condition is null (unconditional). + /// + private static bool EvaluateCondition(string? condition, Dictionary variables) + { + if (string.IsNullOrWhiteSpace(condition)) + { + return true; + } + + // Handle "varName != value" + var neqIndex = condition.IndexOf("!=", StringComparison.Ordinal); + if (neqIndex >= 0) + { + var varName = condition[..neqIndex].Trim(); + var expected = condition[(neqIndex + 2)..].Trim(); + variables.TryGetValue(varName, out var actual); + return !string.Equals(actual ?? "", expected, StringComparison.OrdinalIgnoreCase); + } + + // Handle "varName == value" + var eqIndex = condition.IndexOf("==", StringComparison.Ordinal); + if (eqIndex >= 0) + { + var varName = condition[..eqIndex].Trim(); + var expected = condition[(eqIndex + 2)..].Trim(); + variables.TryGetValue(varName, out var actual); + return string.Equals(actual ?? "", expected, StringComparison.OrdinalIgnoreCase); + } + + // Bare variable name — truthy check (non-empty and not "false") + var bareVar = condition.Trim(); + if (variables.TryGetValue(bareVar, out var val)) + { + return !string.IsNullOrEmpty(val) && !string.Equals(val, "false", StringComparison.OrdinalIgnoreCase); + } + + return false; + } + + /// + /// Replaces {{variableName}} placeholders in a string with their values. + /// + private static string SubstituteVariables(string text, Dictionary variables) + { + foreach (var (key, value) in variables) + { + text = text.Replace($"{{{{{key}}}}}", value, StringComparison.OrdinalIgnoreCase); + } + return text; + } + private async Task FetchTemplateAsync(string targetDir, CancellationToken cancellationToken) { var repo = _resolved.EffectiveRepo; diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs b/src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs index d3e7bce2dd0..4aae727ed9e 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplateManifest.cs @@ -32,4 +32,36 @@ internal sealed class GitTemplateManifest public Dictionary? ConditionalFiles { get; set; } public List? PostMessages { get; set; } + + public List? PostInstructions { get; set; } +} + +/// +/// A post-application instruction block shown to the user after template creation. +/// +internal sealed class GitTemplatePostInstruction +{ + /// + /// Gets or sets the heading displayed above the instruction lines (rendered as a slug line). + /// + public required LocalizableString Heading { get; set; } + + /// + /// Gets or sets whether this is a primary or secondary instruction group. + /// Primary instructions are visually highlighted. Defaults to "secondary". + /// + public string Priority { get; set; } = "secondary"; + + /// + /// Gets or sets the instruction lines to display. + /// Lines may contain {{variable}} placeholders that are substituted with variable values. + /// + public required List Lines { get; set; } + + /// + /// Gets or sets an optional condition expression that controls whether this instruction is shown. + /// Format: "variableName == value" or "variableName != value". + /// When omitted, the instruction is always shown. + /// + public string? Condition { get; set; } } From f21805d68ebfda9c34b62e978665d8f0076151cc Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 2 Mar 2026 18:50:23 +1100 Subject: [PATCH 22/28] Add aspire template test command for matrix testing Introduces the 'aspire template test' command that generates the full cartesian product of all variable combinations for a template and applies each one, reporting pass/fail results. Features: - Template resolution from local path, index, or cwd - testValues property on variables for explicit test value control - Automatic fallback: boolean=true/false, choice=all values, etc. - --dry-run to preview combinations without applying - --json for machine-readable output - Descriptive output subdirectories per combination Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/Template/TemplateCommand.cs | 2 + .../Commands/Template/TemplateTestCommand.cs | 470 ++++++++++++++++++ src/Aspire.Cli/Program.cs | 1 + .../Templating/Git/GitTemplateVariable.cs | 55 ++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 1 + 5 files changed, 529 insertions(+) create mode 100644 src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs diff --git a/src/Aspire.Cli/Commands/Template/TemplateCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateCommand.cs index 29ef1857abe..5388b5c5be5 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateCommand.cs @@ -20,6 +20,7 @@ public TemplateCommand( TemplateRefreshCommand refreshCommand, TemplateNewManifestCommand newManifestCommand, TemplateNewIndexCommand newIndexCommand, + TemplateTestCommand testCommand, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, @@ -32,6 +33,7 @@ public TemplateCommand( Subcommands.Add(refreshCommand); Subcommands.Add(newManifestCommand); Subcommands.Add(newIndexCommand); + Subcommands.Add(testCommand); } protected override bool UpdateNotificationsEnabled => false; diff --git a/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs new file mode 100644 index 00000000000..9c1b5631f95 --- /dev/null +++ b/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs @@ -0,0 +1,470 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Templating.Git; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Commands.Template; + +internal sealed class TemplateTestCommand : BaseTemplateSubCommand +{ + private static readonly Argument s_pathArgument = new("path") + { + Description = "Path to a template directory containing aspire-template.json (defaults to current directory)", + Arity = ArgumentArity.ZeroOrOne + }; + + private static readonly Option s_outputOption = new("--output", "-o") + { + Description = "Base directory for generated variant projects (required)", + Required = true + }; + + private static readonly Option s_nameOption = new("--name") + { + Description = "Template name when path contains an aspire-template-index.json with multiple templates" + }; + + private static readonly Option s_dryRunOption = new("--dry-run") + { + Description = "List all combinations without applying the template" + }; + + private static readonly Option s_jsonOption = new("--json") + { + Description = "Output results as JSON" + }; + + private readonly IGitTemplateEngine _engine; + + public TemplateTestCommand( + IGitTemplateEngine engine, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + IInteractionService interactionService, + AspireCliTelemetry telemetry) + : base("test", "Test a template by generating all variable combinations", features, updateNotifier, executionContext, interactionService, telemetry) + { + _engine = engine; + Arguments.Add(s_pathArgument); + Options.Add(s_outputOption); + Options.Add(s_nameOption); + Options.Add(s_dryRunOption); + Options.Add(s_jsonOption); + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var inputPath = parseResult.GetValue(s_pathArgument) ?? Directory.GetCurrentDirectory(); + inputPath = Path.GetFullPath(inputPath); + var outputBase = Path.GetFullPath(parseResult.GetValue(s_outputOption)!); + var templateName = parseResult.GetValue(s_nameOption); + var dryRun = parseResult.GetValue(s_dryRunOption); + var jsonOutput = parseResult.GetValue(s_jsonOption); + + // Resolve the template directory and manifest + var (templateDir, manifest) = await ResolveTemplateAsync(inputPath, templateName, cancellationToken); + if (manifest is null) + { + return ExitCodeConstants.InvalidCommand; + } + + // Generate the variable matrix + var (matrixVars, matrix) = GenerateMatrix(manifest); + + if (!jsonOutput) + { + var emoji = dryRun ? KnownEmojis.MagnifyingGlassTiltedLeft : KnownEmojis.Microscope; + var action = dryRun ? "Previewing" : "Testing"; + InteractionService.DisplayMessage(emoji, $"{action} template '{manifest.Name}' ({matrix.Count} combinations)"); + InteractionService.DisplayPlainText(""); + } + + if (dryRun) + { + return RenderDryRun(matrixVars, matrix, jsonOutput); + } + + // Create output directory + Directory.CreateDirectory(outputBase); + + // Execute each combination + var results = new List(); + for (var i = 0; i < matrix.Count; i++) + { + var combo = matrix[i]; + var index = i + 1; + var dirName = BuildDirectoryName(index, matrixVars, combo); + var outputDir = Path.Combine(outputBase, dirName); + var projectName = $"TestProject{index:D3}"; + + var variables = new Dictionary { ["projectName"] = projectName }; + for (var v = 0; v < matrixVars.Count; v++) + { + variables[matrixVars[v]] = combo[v]; + } + + string? error = null; + try + { + await _engine.ApplyAsync(templateDir, outputDir, variables, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + error = ex.Message; + } + + results.Add(new TestResult(index, variables, outputDir, error)); + + if (!jsonOutput) + { + RenderResultLine(index, matrixVars, combo, outputDir, error); + } + } + + // Summary + var passed = results.Count(r => r.Error is null); + var failed = results.Count - passed; + + if (jsonOutput) + { + RenderJsonOutput(manifest.Name, results, passed, failed); + } + else + { + InteractionService.DisplayPlainText(""); + if (failed == 0) + { + InteractionService.DisplaySuccess($"All {passed} combinations passed"); + } + else + { + InteractionService.DisplayError($"{failed} of {results.Count} combinations failed"); + } + InteractionService.DisplayPlainText($"Output: {outputBase}"); + } + + return failed > 0 ? 1 : 0; + } + + private async Task<(string templateDir, GitTemplateManifest? manifest)> ResolveTemplateAsync( + string inputPath, string? templateName, CancellationToken cancellationToken) + { + // Check for direct aspire-template.json + var manifestPath = Path.Combine(inputPath, "aspire-template.json"); + if (File.Exists(manifestPath)) + { + var json = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false); + var manifest = JsonSerializer.Deserialize(json, GitTemplateJsonContext.Default.GitTemplateManifest); + if (manifest is null) + { + InteractionService.DisplayError("Failed to parse aspire-template.json"); + return (inputPath, null); + } + return (inputPath, manifest); + } + + // Check for aspire-template-index.json + var indexPath = Path.Combine(inputPath, "aspire-template-index.json"); + if (File.Exists(indexPath)) + { + var indexJson = await File.ReadAllTextAsync(indexPath, cancellationToken).ConfigureAwait(false); + var index = JsonSerializer.Deserialize(indexJson, GitTemplateJsonContext.Default.GitTemplateIndex); + if (index?.Templates is null or { Count: 0 }) + { + InteractionService.DisplayError("No templates found in aspire-template-index.json"); + return (inputPath, null); + } + + GitTemplateIndexEntry? entry; + if (templateName is not null) + { + entry = index.Templates.FirstOrDefault(t => string.Equals(t.Name, templateName, StringComparison.OrdinalIgnoreCase)); + if (entry is null) + { + InteractionService.DisplayError($"Template '{templateName}' not found in index. Available: {string.Join(", ", index.Templates.Select(t => t.Name))}"); + return (inputPath, null); + } + } + else if (index.Templates.Count == 1) + { + entry = index.Templates[0]; + } + else + { + entry = await InteractionService.PromptForSelectionAsync( + "Select a template to test", + index.Templates, + t => t.Name, + cancellationToken); + } + + var templateDir = Path.GetFullPath(Path.Combine(inputPath, entry.Path)); + var templateManifestPath = Path.Combine(templateDir, "aspire-template.json"); + if (!File.Exists(templateManifestPath)) + { + InteractionService.DisplayError($"aspire-template.json not found at {templateDir}"); + return (templateDir, null); + } + + var tmplJson = await File.ReadAllTextAsync(templateManifestPath, cancellationToken).ConfigureAwait(false); + var tmplManifest = JsonSerializer.Deserialize(tmplJson, GitTemplateJsonContext.Default.GitTemplateManifest); + return (templateDir, tmplManifest); + } + + InteractionService.DisplayError($"No aspire-template.json or aspire-template-index.json found in {inputPath}"); + return (inputPath, null); + } + + private static (List variableNames, List matrix) GenerateMatrix(GitTemplateManifest manifest) + { + var varNames = new List(); + var valueSets = new List>(); + + if (manifest.Variables is null) + { + return (varNames, [[]]); + } + + foreach (var (name, varDef) in manifest.Variables) + { + // Skip projectName — it gets a unique generated value per combination + if (string.Equals(name, "projectName", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var testValues = GetTestValues(varDef); + if (testValues.Count > 0) + { + varNames.Add(name); + valueSets.Add(testValues); + } + } + + // Compute cartesian product + var matrix = CartesianProduct(valueSets); + return (varNames, matrix); + } + + private static List GetTestValues(GitTemplateVariable varDef) + { + // Use explicit testValues if provided + if (varDef.TestValues is { Count: > 0 }) + { + return varDef.TestValues.Select(v => v?.ToString() ?? "").ToList(); + } + + // Infer from type + return varDef.Type.ToLowerInvariant() switch + { + "boolean" => ["true", "false"], + "choice" when varDef.Choices is { Count: > 0 } => varDef.Choices.Select(c => c.Value).ToList(), + "integer" => GetIntegerTestValues(varDef), + _ => [varDef.DefaultValue?.ToString() ?? "TestValue"] + }; + } + + private static List GetIntegerTestValues(GitTemplateVariable varDef) + { + var values = new HashSet(); + + if (varDef.DefaultValue is not null) + { + values.Add(varDef.DefaultValue.ToString()!); + } + if (varDef.Validation?.Min is { } min) + { + values.Add(min.ToString(CultureInfo.InvariantCulture)); + } + if (varDef.Validation?.Max is { } max) + { + values.Add(max.ToString(CultureInfo.InvariantCulture)); + } + + return values.Count > 0 ? [.. values] : [varDef.DefaultValue?.ToString() ?? "0"]; + } + + private static List CartesianProduct(List> sets) + { + if (sets.Count == 0) + { + return [[]]; + } + + var result = new List { Array.Empty() }; + + foreach (var set in sets) + { + var newResult = new List(); + foreach (var existing in result) + { + foreach (var value in set) + { + var combined = new string[existing.Length + 1]; + existing.CopyTo(combined, 0); + combined[existing.Length] = value; + newResult.Add(combined); + } + } + result = newResult; + } + + return result; + } + + private static string BuildDirectoryName(int index, List varNames, string[] values) + { + var sb = new StringBuilder(); + sb.Append(index.ToString("D3", CultureInfo.InvariantCulture)); + for (var i = 0; i < varNames.Count; i++) + { + sb.Append('_'); + sb.Append(varNames[i]); + sb.Append('-'); + sb.Append(values[i]); + } + return sb.ToString(); + } + + private void RenderResultLine(int index, List varNames, string[] values, string outputDir, string? error) + { + var varSummary = new StringBuilder(); + for (var i = 0; i < varNames.Count; i++) + { + if (i > 0) + { + varSummary.Append(" "); + } + varSummary.Append(CultureInfo.InvariantCulture, $"{varNames[i]}={values[i]}"); + } + + if (error is null) + { + InteractionService.DisplayMessage(KnownEmojis.CheckMark, $"#{index:D3} {varSummary}"); + InteractionService.DisplayPlainText($" → {outputDir}"); + } + else + { + InteractionService.DisplayError($"#{index:D3} {varSummary}"); + InteractionService.DisplayPlainText($" → {outputDir}"); + InteractionService.DisplayPlainText($" Error: {error}"); + } + } + + private int RenderDryRun(List varNames, List matrix, bool jsonOutput) + { + if (jsonOutput) + { + var combinations = new List(); + for (var i = 0; i < matrix.Count; i++) + { + var combo = matrix[i]; + var vars = new Dictionary(); + for (var v = 0; v < varNames.Count; v++) + { + vars[varNames[v]] = combo[v]; + } + combinations.Add(new TemplateTestDryRunCombination { Index = i + 1, Variables = vars }); + } + + var dryRunResult = new TemplateTestDryRunResult { TotalCombinations = matrix.Count, Combinations = combinations }; + var json = JsonSerializer.Serialize(dryRunResult, TemplateTestJsonContext.Default.TemplateTestDryRunResult); + InteractionService.DisplayPlainText(json); + } + else + { + for (var i = 0; i < matrix.Count; i++) + { + var combo = matrix[i]; + var varSummary = new StringBuilder(); + for (var v = 0; v < varNames.Count; v++) + { + if (v > 0) + { + varSummary.Append(" "); + } + varSummary.Append(CultureInfo.InvariantCulture, $"{varNames[v]}={combo[v]}"); + } + InteractionService.DisplayPlainText($" #{i + 1:D3} {varSummary}"); + } + InteractionService.DisplayPlainText(""); + InteractionService.DisplayPlainText($"Total: {matrix.Count} combinations"); + } + return 0; + } + + private void RenderJsonOutput(string templateName, List results, int passed, int failed) + { + var output = new TemplateTestRunResult + { + Template = templateName, + TotalCombinations = results.Count, + Passed = passed, + Failed = failed, + Results = results.Select(r => new TemplateTestRunResultEntry + { + Index = r.Index, + Status = r.Error is null ? "passed" : "failed", + Variables = r.Variables.Where(kv => !string.Equals(kv.Key, "projectName", StringComparison.OrdinalIgnoreCase)) + .ToDictionary(kv => kv.Key, kv => kv.Value), + OutputPath = r.OutputPath, + Error = r.Error + }).ToList() + }; + + var json = JsonSerializer.Serialize(output, TemplateTestJsonContext.Default.TemplateTestRunResult); + InteractionService.DisplayPlainText(json); + } + + private sealed record TestResult(int Index, Dictionary Variables, string OutputPath, string? Error); +} + +internal sealed class TemplateTestDryRunCombination +{ + public int Index { get; set; } + public Dictionary Variables { get; set; } = []; +} + +internal sealed class TemplateTestDryRunResult +{ + public int TotalCombinations { get; set; } + public List Combinations { get; set; } = []; +} + +internal sealed class TemplateTestRunResultEntry +{ + public int Index { get; set; } + public string Status { get; set; } = ""; + public Dictionary Variables { get; set; } = []; + public string OutputPath { get; set; } = ""; + public string? Error { get; set; } +} + +internal sealed class TemplateTestRunResult +{ + public string Template { get; set; } = ""; + public int TotalCombinations { get; set; } + public int Passed { get; set; } + public int Failed { get; set; } + public List Results { get; set; } = []; +} + +[JsonSourceGenerationOptions( + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(TemplateTestDryRunResult))] +[JsonSerializable(typeof(TemplateTestRunResult))] +internal sealed partial class TemplateTestJsonContext : JsonSerializerContext +{ +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 1961cec5410..f496ca77d94 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -409,6 +409,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); // Git template index services builder.Services.AddSingleton(sp => diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs b/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs index 142df7f7977..c0070541b92 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs @@ -25,6 +25,13 @@ internal sealed class GitTemplateVariable public GitTemplateVariableValidation? Validation { get; set; } public List? Choices { get; set; } + + /// + /// Gets or sets explicit test values for matrix testing via aspire template test. + /// Each value can be a string, boolean, or integer matching the variable type. + /// + [JsonConverter(typeof(JsonObjectListConverter))] + public List? TestValues { get; set; } } /// @@ -97,3 +104,51 @@ public override void Write(Utf8JsonWriter writer, object? value, JsonSerializerO } } } + +/// +/// Handles deserializing a list of polymorphic values (strings, booleans, or integers). +/// +internal sealed class JsonObjectListConverter : JsonConverter?> +{ + private static readonly JsonObjectConverter s_elementConverter = new(); + + public override List? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType is not JsonTokenType.StartArray) + { + throw new JsonException("Expected array for testValues."); + } + + var list = new List(); + while (reader.Read() && reader.TokenType is not JsonTokenType.EndArray) + { + var value = s_elementConverter.Read(ref reader, typeof(object), options); + if (value is not null) + { + list.Add(value); + } + } + return list; + } + + public override void Write(Utf8JsonWriter writer, List? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStartArray(); + foreach (var item in value) + { + s_elementConverter.Write(writer, item, options); + } + writer.WriteEndArray(); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index aa474745d4a..15c2dab9415 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -215,6 +215,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddSingleton(new Aspire.Cli.Templating.Git.GitTemplateCache(Path.Combine(Path.GetTempPath(), "aspire-cli-tests", "git-templates"))); services.AddSingleton(); services.AddSingleton(); From a51fa403c98ca4629e43d5b027fd90afd373f906 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 2 Mar 2026 19:41:44 +1100 Subject: [PATCH 23/28] Enable interactive template selection for template test command When no path argument is provided and no local template files exist, the command now fetches the template index and presents an interactive selection prompt filtered to git templates. Selected templates are cloned to a temp directory, tested, then cleaned up. Changes: - --output is now optional (defaults to current directory) - --name can select from the remote template index - Added FetchAsync to IGitTemplateEngine for cloning templates - Added git clone/sparse-checkout logic to GitTemplateEngine Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/Template/TemplateTestCommand.cs | 237 +++++++++++++----- .../Templating/Git/GitTemplateEngine.cs | 151 +++++++++++ .../Templating/Git/IGitTemplateEngine.cs | 9 + 3 files changed, 329 insertions(+), 68 deletions(-) diff --git a/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs index 9c1b5631f95..d6d8cc82f85 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs @@ -18,19 +18,18 @@ internal sealed class TemplateTestCommand : BaseTemplateSubCommand { private static readonly Argument s_pathArgument = new("path") { - Description = "Path to a template directory containing aspire-template.json (defaults to current directory)", + Description = "Path to a template directory containing aspire-template.json (defaults to interactive selection from the template index)", Arity = ArgumentArity.ZeroOrOne }; - private static readonly Option s_outputOption = new("--output", "-o") + private static readonly Option s_outputOption = new("--output", "-o") { - Description = "Base directory for generated variant projects (required)", - Required = true + Description = "Base directory for generated variant projects (defaults to current directory)" }; private static readonly Option s_nameOption = new("--name") { - Description = "Template name when path contains an aspire-template-index.json with multiple templates" + Description = "Template name to select from a local index or the remote template index" }; private static readonly Option s_dryRunOption = new("--dry-run") @@ -44,9 +43,11 @@ internal sealed class TemplateTestCommand : BaseTemplateSubCommand }; private readonly IGitTemplateEngine _engine; + private readonly IGitTemplateIndexService _indexService; public TemplateTestCommand( IGitTemplateEngine engine, + IGitTemplateIndexService indexService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, @@ -55,6 +56,7 @@ public TemplateTestCommand( : base("test", "Test a template by generating all variable combinations", features, updateNotifier, executionContext, interactionService, telemetry) { _engine = engine; + _indexService = indexService; Arguments.Add(s_pathArgument); Options.Add(s_outputOption); Options.Add(s_nameOption); @@ -64,99 +66,139 @@ public TemplateTestCommand( protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - var inputPath = parseResult.GetValue(s_pathArgument) ?? Directory.GetCurrentDirectory(); - inputPath = Path.GetFullPath(inputPath); - var outputBase = Path.GetFullPath(parseResult.GetValue(s_outputOption)!); + var inputPath = parseResult.GetValue(s_pathArgument); + var outputBase = parseResult.GetValue(s_outputOption); var templateName = parseResult.GetValue(s_nameOption); var dryRun = parseResult.GetValue(s_dryRunOption); var jsonOutput = parseResult.GetValue(s_jsonOption); - // Resolve the template directory and manifest - var (templateDir, manifest) = await ResolveTemplateAsync(inputPath, templateName, cancellationToken); - if (manifest is null) - { - return ExitCodeConstants.InvalidCommand; - } + // Resolve output directory — default to cwd + outputBase = outputBase is not null ? Path.GetFullPath(outputBase) : Directory.GetCurrentDirectory(); - // Generate the variable matrix - var (matrixVars, matrix) = GenerateMatrix(manifest); - - if (!jsonOutput) - { - var emoji = dryRun ? KnownEmojis.MagnifyingGlassTiltedLeft : KnownEmojis.Microscope; - var action = dryRun ? "Previewing" : "Testing"; - InteractionService.DisplayMessage(emoji, $"{action} template '{manifest.Name}' ({matrix.Count} combinations)"); - InteractionService.DisplayPlainText(""); - } + // Resolve the template directory and manifest + string? tempDir = null; + string templateDir; + GitTemplateManifest? manifest; - if (dryRun) + try { - return RenderDryRun(matrixVars, matrix, jsonOutput); - } + if (inputPath is not null) + { + // Explicit local path provided + (templateDir, manifest) = await ResolveLocalTemplateAsync(Path.GetFullPath(inputPath), templateName, cancellationToken); + } + else + { + // Try cwd first for local template files, then fall back to index + var cwd = Directory.GetCurrentDirectory(); + var hasLocalManifest = File.Exists(Path.Combine(cwd, "aspire-template.json")); + var hasLocalIndex = File.Exists(Path.Combine(cwd, "aspire-template-index.json")); - // Create output directory - Directory.CreateDirectory(outputBase); + if (hasLocalManifest || hasLocalIndex) + { + (templateDir, manifest) = await ResolveLocalTemplateAsync(cwd, templateName, cancellationToken); + } + else + { + // Select from the remote template index + (templateDir, manifest, tempDir) = await ResolveFromIndexAsync(templateName, cancellationToken); + } + } - // Execute each combination - var results = new List(); - for (var i = 0; i < matrix.Count; i++) - { - var combo = matrix[i]; - var index = i + 1; - var dirName = BuildDirectoryName(index, matrixVars, combo); - var outputDir = Path.Combine(outputBase, dirName); - var projectName = $"TestProject{index:D3}"; - - var variables = new Dictionary { ["projectName"] = projectName }; - for (var v = 0; v < matrixVars.Count; v++) + if (manifest is null) { - variables[matrixVars[v]] = combo[v]; + return ExitCodeConstants.InvalidCommand; } - string? error = null; - try + // Generate the variable matrix + var (matrixVars, matrix) = GenerateMatrix(manifest); + + if (!jsonOutput) { - await _engine.ApplyAsync(templateDir, outputDir, variables, cancellationToken).ConfigureAwait(false); + var emoji = dryRun ? KnownEmojis.MagnifyingGlassTiltedLeft : KnownEmojis.Microscope; + var action = dryRun ? "Previewing" : "Testing"; + InteractionService.DisplayMessage(emoji, $"{action} template '{manifest.Name}' ({matrix.Count} combinations)"); + InteractionService.DisplayPlainText(""); } - catch (Exception ex) + + if (dryRun) { - error = ex.Message; + return RenderDryRun(matrixVars, matrix, jsonOutput); } - results.Add(new TestResult(index, variables, outputDir, error)); + // Create output directory + Directory.CreateDirectory(outputBase); - if (!jsonOutput) + // Execute each combination + var results = new List(); + for (var i = 0; i < matrix.Count; i++) { - RenderResultLine(index, matrixVars, combo, outputDir, error); + var combo = matrix[i]; + var index = i + 1; + var dirName = BuildDirectoryName(index, matrixVars, combo); + var outputDir = Path.Combine(outputBase, dirName); + var projectName = $"TestProject{index:D3}"; + + var variables = new Dictionary { ["projectName"] = projectName }; + for (var v = 0; v < matrixVars.Count; v++) + { + variables[matrixVars[v]] = combo[v]; + } + + string? error = null; + try + { + await _engine.ApplyAsync(templateDir, outputDir, variables, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + error = ex.Message; + } + + results.Add(new TestResult(index, variables, outputDir, error)); + + if (!jsonOutput) + { + RenderResultLine(index, matrixVars, combo, outputDir, error); + } } - } - // Summary - var passed = results.Count(r => r.Error is null); - var failed = results.Count - passed; + // Summary + var passed = results.Count(r => r.Error is null); + var failed = results.Count - passed; - if (jsonOutput) - { - RenderJsonOutput(manifest.Name, results, passed, failed); - } - else - { - InteractionService.DisplayPlainText(""); - if (failed == 0) + if (jsonOutput) { - InteractionService.DisplaySuccess($"All {passed} combinations passed"); + RenderJsonOutput(manifest.Name, results, passed, failed); } else { - InteractionService.DisplayError($"{failed} of {results.Count} combinations failed"); + InteractionService.DisplayPlainText(""); + if (failed == 0) + { + InteractionService.DisplaySuccess($"All {passed} combinations passed"); + } + else + { + InteractionService.DisplayError($"{failed} of {results.Count} combinations failed"); + } + InteractionService.DisplayPlainText($"Output: {outputBase}"); } - InteractionService.DisplayPlainText($"Output: {outputBase}"); - } - return failed > 0 ? 1 : 0; + return failed > 0 ? 1 : 0; + } + finally + { + // Clean up temp directory if we fetched from the index + if (tempDir is not null && Directory.Exists(tempDir)) + { + try { Directory.Delete(tempDir, recursive: true); } + catch { /* best effort cleanup */ } + } + } } - private async Task<(string templateDir, GitTemplateManifest? manifest)> ResolveTemplateAsync( + private async Task<(string templateDir, GitTemplateManifest? manifest)> ResolveLocalTemplateAsync( string inputPath, string? templateName, CancellationToken cancellationToken) { // Check for direct aspire-template.json @@ -225,6 +267,65 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return (inputPath, null); } + private async Task<(string templateDir, GitTemplateManifest? manifest, string? tempDir)> ResolveFromIndexAsync( + string? templateName, CancellationToken cancellationToken) + { + var templates = await InteractionService.ShowStatusAsync( + "Fetching template index...", + () => _indexService.GetTemplatesAsync(cancellationToken: cancellationToken)); + + var gitTemplates = templates.ToList(); + if (gitTemplates.Count == 0) + { + InteractionService.DisplayError("No git templates found in the configured template index."); + return (string.Empty, null, null); + } + + ResolvedTemplate selected; + if (templateName is not null) + { + var match = gitTemplates.FirstOrDefault(t => string.Equals(t.Entry.Name, templateName, StringComparison.OrdinalIgnoreCase)); + if (match is null) + { + InteractionService.DisplayError($"Template '{templateName}' not found in index. Available: {string.Join(", ", gitTemplates.Select(t => t.Entry.Name))}"); + return (string.Empty, null, null); + } + selected = match; + } + else + { + selected = await InteractionService.PromptForSelectionAsync( + "Select a template to test", + gitTemplates, + t => $"{t.Entry.Name} — {t.Entry.Description ?? t.EffectiveRepo}", + cancellationToken); + } + + // Fetch the template to a temp directory + var tempDir = Path.Combine(Path.GetTempPath(), "aspire-template-test", Guid.NewGuid().ToString("N")); + var fetched = await InteractionService.ShowStatusAsync( + $"Fetching template '{selected.Entry.Name}'...", + () => _engine.FetchAsync(selected, tempDir, cancellationToken)); + + if (!fetched) + { + InteractionService.DisplayError($"Failed to fetch template '{selected.Entry.Name}' from {selected.EffectiveRepo}"); + return (string.Empty, null, tempDir); + } + + // Read the manifest + var manifestPath = Path.Combine(tempDir, "aspire-template.json"); + if (!File.Exists(manifestPath)) + { + InteractionService.DisplayError($"aspire-template.json not found in fetched template '{selected.Entry.Name}'"); + return (tempDir, null, tempDir); + } + + var json = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false); + var manifest = JsonSerializer.Deserialize(json, GitTemplateJsonContext.Default.GitTemplateManifest); + return (tempDir, manifest, tempDir); + } + private static (List variableNames, List matrix) GenerateMatrix(GitTemplateManifest manifest) { var varNames = new List(); diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateEngine.cs b/src/Aspire.Cli/Templating/Git/GitTemplateEngine.cs index bfd53fb9fe9..62799a94cfd 100644 --- a/src/Aspire.Cli/Templating/Git/GitTemplateEngine.cs +++ b/src/Aspire.Cli/Templating/Git/GitTemplateEngine.cs @@ -231,4 +231,155 @@ private static bool IsBinaryFile(string filePath) return false; } + + public async Task FetchAsync(ResolvedTemplate resolved, string targetDir, CancellationToken cancellationToken = default) + { + var repo = resolved.EffectiveRepo; + var templatePath = resolved.Entry.Path; + + // For local sources, copy files directly. + if (Uri.TryCreate(repo, UriKind.Absolute, out var uri) && uri.IsFile || (!repo.Contains("://", StringComparison.Ordinal) && !repo.Contains('@') && Directory.Exists(repo))) + { + var sourcePath = Path.Combine(repo, templatePath); + if (!Directory.Exists(sourcePath)) + { + _logger.LogError("Local template path does not exist: {Path}", sourcePath); + return false; + } + CopyDirectory(sourcePath, targetDir); + return true; + } + + var gitRef = resolved.Source.Ref ?? "HEAD"; + Directory.CreateDirectory(targetDir); + + try + { + var cloneResult = await RunGitAsync( + targetDir, + ["clone", "--depth", "1", "--branch", gitRef, "--sparse", "--filter=blob:none", repo, "."], + cancellationToken).ConfigureAwait(false); + + if (cloneResult != 0) + { + cloneResult = await RunGitAsync( + targetDir, + ["clone", "--depth", "1", "--sparse", "--filter=blob:none", repo, "."], + cancellationToken).ConfigureAwait(false); + + if (cloneResult != 0) + { + _logger.LogError("Failed to clone repository {Repo}.", repo); + return false; + } + + await RunGitAsync(targetDir, ["fetch", "origin", gitRef], cancellationToken).ConfigureAwait(false); + await RunGitAsync(targetDir, ["checkout", "FETCH_HEAD"], cancellationToken).ConfigureAwait(false); + } + + if (templatePath is not "." and not "") + { + await RunGitAsync( + targetDir, + ["sparse-checkout", "set", templatePath], + cancellationToken).ConfigureAwait(false); + } + + // Move template files from subdirectory to target root if needed + var templateSubDir = Path.Combine(targetDir, templatePath); + if (templatePath is not "." and not "" && Directory.Exists(templateSubDir)) + { + var tempMove = targetDir + "_move"; + Directory.Move(templateSubDir, tempMove); + + foreach (var entry in Directory.GetFileSystemEntries(targetDir)) + { + var name = Path.GetFileName(entry); + if (name == ".git" || entry == tempMove) + { + continue; + } + if (Directory.Exists(entry)) + { + Directory.Delete(entry, recursive: true); + } + else + { + File.Delete(entry); + } + } + + foreach (var entry in Directory.GetFileSystemEntries(tempMove)) + { + var dest = Path.Combine(targetDir, Path.GetFileName(entry)); + if (Directory.Exists(entry)) + { + Directory.Move(entry, dest); + } + else + { + File.Move(entry, dest); + } + } + Directory.Delete(tempMove, recursive: true); + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch template from {Repo}.", repo); + return false; + } + } + + private static async Task RunGitAsync(string workingDir, string[] args, CancellationToken cancellationToken) + { + var psi = new System.Diagnostics.ProcessStartInfo("git") + { + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + foreach (var arg in args) + { + psi.ArgumentList.Add(arg); + } + + using var process = System.Diagnostics.Process.Start(psi); + if (process is null) + { + return -1; + } + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + return process.ExitCode; + } + + private static void CopyDirectory(string sourceDir, string targetDir) + { + Directory.CreateDirectory(targetDir); + foreach (var dir in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories)) + { + var name = Path.GetFileName(dir); + if (s_excludedDirs.Contains(name)) + { + continue; + } + Directory.CreateDirectory(Path.Combine(targetDir, Path.GetRelativePath(sourceDir, dir))); + } + foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(sourceDir, file); + // Skip files in excluded dirs + var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (parts.Any(s_excludedDirs.Contains)) + { + continue; + } + File.Copy(file, Path.Combine(targetDir, relativePath), overwrite: true); + } + } } diff --git a/src/Aspire.Cli/Templating/Git/IGitTemplateEngine.cs b/src/Aspire.Cli/Templating/Git/IGitTemplateEngine.cs index 426b1aac739..e9b02bf40d7 100644 --- a/src/Aspire.Cli/Templating/Git/IGitTemplateEngine.cs +++ b/src/Aspire.Cli/Templating/Git/IGitTemplateEngine.cs @@ -16,4 +16,13 @@ internal interface IGitTemplateEngine /// Variable values to use for substitution. /// Cancellation token. Task ApplyAsync(string templateDir, string outputDir, IReadOnlyDictionary variables, CancellationToken cancellationToken = default); + + /// + /// Fetches a template from a git repository to a local directory. + /// + /// The resolved template entry with source information. + /// Local directory to clone/copy the template into. + /// Cancellation token. + /// true if the fetch succeeded; otherwise false. + Task FetchAsync(ResolvedTemplate resolved, string targetDir, CancellationToken cancellationToken = default); } From 31653679400c2497a77fdcd34c2285e99c9d24e8 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 2 Mar 2026 20:01:19 +1100 Subject: [PATCH 24/28] Simplify template test output folder naming to {templateName}{index} Output directories are now named like bob0, bob1, ... bob9 using the template name and a zero-based index. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/Template/TemplateTestCommand.cs | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs index d6d8cc82f85..e782c837a5e 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs @@ -135,9 +135,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { var combo = matrix[i]; var index = i + 1; - var dirName = BuildDirectoryName(index, matrixVars, combo); + var dirName = $"{manifest.Name}{i}"; var outputDir = Path.Combine(outputBase, dirName); - var projectName = $"TestProject{index:D3}"; + var projectName = $"{manifest.Name}{i}"; var variables = new Dictionary { ["projectName"] = projectName }; for (var v = 0; v < matrixVars.Count; v++) @@ -423,20 +423,6 @@ private static List CartesianProduct(List> sets) return result; } - private static string BuildDirectoryName(int index, List varNames, string[] values) - { - var sb = new StringBuilder(); - sb.Append(index.ToString("D3", CultureInfo.InvariantCulture)); - for (var i = 0; i < varNames.Count; i++) - { - sb.Append('_'); - sb.Append(varNames[i]); - sb.Append('-'); - sb.Append(values[i]); - } - return sb.ToString(); - } - private void RenderResultLine(int index, List varNames, string[] values, string outputDir, string? error) { var varSummary = new StringBuilder(); From b5b89bc38a4816f0c239433cc98aed0c58789b8d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 2 Mar 2026 20:30:14 +1100 Subject: [PATCH 25/28] Randomize ports per test variant in template test command After applying each template variant, rewrites port numbers in launchSettings.json and apphost.run.json files so each variant uses unique ports. Uses deterministic seeding per variant index for reproducibility, and caches port mappings within a variant so the same original port maps to the same replacement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/Template/TemplateTestCommand.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs index e782c837a5e..8baff7ae443 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs @@ -149,6 +149,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell try { await _engine.ApplyAsync(templateDir, outputDir, variables, cancellationToken).ConfigureAwait(false); + RandomizePorts(outputDir, i); } catch (Exception ex) { @@ -423,6 +424,53 @@ private static List CartesianProduct(List> sets) return result; } + /// + /// Rewrites port numbers in launchSettings.json and apphost.run.json files + /// so that each test variant uses unique ports. + /// + private static void RandomizePorts(string outputDir, int variantIndex) + { + var portFiles = new List(); + foreach (var pattern in new[] { "**/launchSettings.json", "**/apphost.run.json" }) + { + portFiles.AddRange(Directory.GetFiles(outputDir, Path.GetFileName(pattern), SearchOption.AllDirectories)); + } + + if (portFiles.Count == 0) + { + return; + } + + // Use a deterministic seed so results are reproducible per variant + var rng = new Random(variantIndex * 31337); + var portCache = new Dictionary(); + + foreach (var file in portFiles) + { + var content = File.ReadAllText(file); + var updated = System.Text.RegularExpressions.Regex.Replace( + content, + @"(?<=://[^:/""]+:)\d{4,5}(?=[/""/]?)", + match => + { + // Map each original port to a consistent replacement within this variant + if (!portCache.TryGetValue(match.Value, out var replacement)) + { + replacement = rng.Next(10000, 60000).ToString(CultureInfo.InvariantCulture); + portCache[match.Value] = replacement; + } + return replacement; + }, + System.Text.RegularExpressions.RegexOptions.None, + TimeSpan.FromSeconds(5)); + + if (content != updated) + { + File.WriteAllText(file, updated); + } + } + } + private void RenderResultLine(int index, List varNames, string[] values, string outputDir, string? error) { var varSummary = new StringBuilder(); From 78623d0ebf9ccdf40739c20cb22ef5f5c23b22e4 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 2 Mar 2026 21:52:30 +1100 Subject: [PATCH 26/28] Remove BaseTemplateSubCommand, inherit BaseCommand directly Each template subcommand now overrides UpdateNotificationsEnabled directly instead of going through an intermediate base class. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Template/BaseTemplateSubCommand.cs | 22 ------------------- .../Commands/Template/TemplateListCommand.cs | 4 +++- .../Template/TemplateNewIndexCommand.cs | 4 +++- .../Template/TemplateNewManifestCommand.cs | 4 +++- .../Template/TemplateRefreshCommand.cs | 4 +++- .../Template/TemplateSearchCommand.cs | 4 +++- .../Commands/Template/TemplateTestCommand.cs | 4 +++- 7 files changed, 18 insertions(+), 28 deletions(-) delete mode 100644 src/Aspire.Cli/Commands/Template/BaseTemplateSubCommand.cs diff --git a/src/Aspire.Cli/Commands/Template/BaseTemplateSubCommand.cs b/src/Aspire.Cli/Commands/Template/BaseTemplateSubCommand.cs deleted file mode 100644 index b5b5ee3fd7a..00000000000 --- a/src/Aspire.Cli/Commands/Template/BaseTemplateSubCommand.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Cli.Configuration; -using Aspire.Cli.Interaction; -using Aspire.Cli.Telemetry; -using Aspire.Cli.Utils; - -namespace Aspire.Cli.Commands.Template; - -internal abstract class BaseTemplateSubCommand( - string name, - string description, - IFeatures features, - ICliUpdateNotifier updateNotifier, - CliExecutionContext executionContext, - IInteractionService interactionService, - AspireCliTelemetry telemetry) - : BaseCommand(name, description, features, updateNotifier, executionContext, interactionService, telemetry) -{ - protected override bool UpdateNotificationsEnabled => false; -} diff --git a/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs index 9196df0af61..94c41f6fe52 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs @@ -18,8 +18,10 @@ internal sealed class TemplateListCommand( CliExecutionContext executionContext, IInteractionService interactionService, AspireCliTelemetry telemetry) - : BaseTemplateSubCommand("list", "List available templates from all configured sources", features, updateNotifier, executionContext, interactionService, telemetry) + : BaseCommand("list", "List available templates from all configured sources", features, updateNotifier, executionContext, interactionService, telemetry) { + protected override bool UpdateNotificationsEnabled => false; + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { using var activity = Telemetry.StartDiagnosticActivity("template-list"); diff --git a/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs index 3260997ab8f..ac2f156e4b3 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs @@ -11,7 +11,7 @@ namespace Aspire.Cli.Commands.Template; -internal sealed class TemplateNewIndexCommand : BaseTemplateSubCommand +internal sealed class TemplateNewIndexCommand : BaseCommand { private static readonly Argument s_pathArgument = new("path") { @@ -30,6 +30,8 @@ public TemplateNewIndexCommand( Arguments.Add(s_pathArgument); } + protected override bool UpdateNotificationsEnabled => false; + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { var targetDir = parseResult.GetValue(s_pathArgument) ?? Directory.GetCurrentDirectory(); diff --git a/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs index 4d70428991b..5754de371c2 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs @@ -13,7 +13,7 @@ namespace Aspire.Cli.Commands.Template; -internal sealed partial class TemplateNewManifestCommand : BaseTemplateSubCommand +internal sealed partial class TemplateNewManifestCommand : BaseCommand { private static readonly Argument s_pathArgument = new("path") { @@ -32,6 +32,8 @@ public TemplateNewManifestCommand( Arguments.Add(s_pathArgument); } + protected override bool UpdateNotificationsEnabled => false; + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { var targetDir = parseResult.GetValue(s_pathArgument) ?? Directory.GetCurrentDirectory(); diff --git a/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs index 0cccd633471..a123c7a129f 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs @@ -17,8 +17,10 @@ internal sealed class TemplateRefreshCommand( CliExecutionContext executionContext, IInteractionService interactionService, AspireCliTelemetry telemetry) - : BaseTemplateSubCommand("refresh", "Force refresh the template index cache", features, updateNotifier, executionContext, interactionService, telemetry) + : BaseCommand("refresh", "Force refresh the template index cache", features, updateNotifier, executionContext, interactionService, telemetry) { + protected override bool UpdateNotificationsEnabled => false; + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { using var activity = Telemetry.StartDiagnosticActivity("template-refresh"); diff --git a/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs index 5e7c76577b7..22ec7a361d1 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs @@ -11,7 +11,7 @@ namespace Aspire.Cli.Commands.Template; -internal sealed class TemplateSearchCommand : BaseTemplateSubCommand +internal sealed class TemplateSearchCommand : BaseCommand { private static readonly Argument s_keywordArgument = new("keyword") { @@ -33,6 +33,8 @@ public TemplateSearchCommand( Arguments.Add(s_keywordArgument); } + protected override bool UpdateNotificationsEnabled => false; + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { var keyword = parseResult.GetValue(s_keywordArgument)!; diff --git a/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs index 8baff7ae443..a259b27f082 100644 --- a/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs +++ b/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs @@ -14,7 +14,7 @@ namespace Aspire.Cli.Commands.Template; -internal sealed class TemplateTestCommand : BaseTemplateSubCommand +internal sealed class TemplateTestCommand : BaseCommand { private static readonly Argument s_pathArgument = new("path") { @@ -64,6 +64,8 @@ public TemplateTestCommand( Options.Add(s_jsonOption); } + protected override bool UpdateNotificationsEnabled => false; + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { var inputPath = parseResult.GetValue(s_pathArgument); From 8ffa25c389c4b1c4d6d07cad58c09daa93ec8341 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 2 Mar 2026 22:09:54 +1100 Subject: [PATCH 27/28] Update git-templates spec with postInstructions, testValues, template test Documents all recently implemented features: - postInstructions: structured instruction blocks with priority, conditions, and variable substitution - testValues: explicit test values for template test matrix generation - aspire template test command: full documentation with options - Variable field reference table with all properties - scope field on index entries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/git-templates.md | 108 +++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/docs/specs/git-templates.md b/docs/specs/git-templates.md index 5c52fc34f47..0133b2f9efa 100644 --- a/docs/specs/git-templates.md +++ b/docs/specs/git-templates.md @@ -2,7 +2,7 @@ **Status:** Draft **Authors:** Aspire CLI Team -**Last Updated:** 2026-02-27 +**Last Updated:** 2026-03-02 ## 1. Overview @@ -169,6 +169,7 @@ The template index file lives at the root of a repository and describes the temp | `templates[].repo` | string | No | Git URL of an external repository containing the template. If omitted, the template is in the same repo as the index. | | `templates[].language` | string | No | Primary language of the template (`csharp`, `typescript`, `python`, `go`, `java`, `rust`). If omitted, the template is language-agnostic. | | `templates[].tags` | array | No | Tags for filtering and categorization | +| `templates[].scope` | array | No | Where the template appears: `["new"]`, `["init"]`, or `["new", "init"]`. Default: `["new"]` | | `templates[].minAspireVersion` | string | No | Minimum Aspire version required | | `includes` | array | No | Other template indexes to include (federation) | | `includes[].url` | string | Yes | Git URL of the repository containing the included index | @@ -247,6 +248,25 @@ The template manifest lives inside a template directory and describes how to app "postMessages": [ "Your Aspire application '{{projectName}}' has been created!", "Run `cd {{projectName}} && dotnet run --project {{projectName}}.AppHost` to start the application." + ], + "postInstructions": [ + { + "heading": "Get started", + "priority": "primary", + "lines": [ + "cd {{projectName}}", + "dotnet run --project {{projectName}}.AppHost" + ] + }, + { + "heading": "Redis setup", + "priority": "secondary", + "condition": "useRedisCache == true", + "lines": [ + "Redis starts automatically via Aspire.", + "To connect manually: docker run -d -p 6379:6379 redis" + ] + } ] } ``` @@ -268,6 +288,7 @@ The template manifest lives inside a template directory and describes how to app | `substitutions.content` | object | No | Map of content patterns → replacement expressions | | `conditionalFiles` | object | No | Files/directories conditionally included based on variable values | | `postMessages` | array | No | Messages displayed to the user after template application | +| `postInstructions` | array | No | Structured instruction blocks shown after template application (see [Post-Instructions](#post-instructions)) | ### Variable Types @@ -278,6 +299,19 @@ The template manifest lives inside a template directory and describes how to app | `choice` | Selection from predefined options | `choices` array | | `integer` | Numeric integer | `validation.min`, `validation.max` | +### Variable Field Reference + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | string | Yes | Variable type: `string`, `boolean`, `choice`, `integer` | +| `displayName` | string \| object | No | Localizable prompt label | +| `description` | string \| object | No | Localizable help text | +| `required` | boolean | No | Whether a value must be provided | +| `defaultValue` | varies | No | Default value matching the variable type | +| `validation` | object | No | Validation rules (see Variable Types table) | +| `choices` | array | No | Available options for `choice` type variables | +| `testValues` | array | No | Explicit test values for `aspire template test` matrix generation. Values should be strings, booleans, or integers matching the variable type. If omitted, test values are inferred from the variable type. | + ### Substitution Expressions Substitution values use a lightweight expression syntax: @@ -350,6 +384,31 @@ Localizable fields: Localization is optional — templates that use plain strings work unchanged. This design keeps templates self-contained in a single `aspire-template.json` file, avoiding the need for sidecar localization files (unlike the .NET template engine's `localize/templatestrings.{culture}.json` approach). +### Post-Instructions + +The `postInstructions` field provides structured guidance shown to the developer after a template is applied. Unlike `postMessages` (plain strings), post-instructions support headings, priority levels, variable substitution, and conditional display. + +#### Instruction Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `heading` | string \| object | Yes | Localizable heading displayed as a section title | +| `priority` | string | No | `"primary"` (highlighted with 🚀) or `"secondary"` (dimmed with ℹ️). Default: `"secondary"` | +| `lines` | array | Yes | Instruction lines. Support `{{variableName}}` substitution. | +| `condition` | string | No | Conditional expression controlling whether this block is shown | + +#### Condition Syntax + +| Syntax | Description | Example | +|--------|-------------|---------| +| `variable == value` | Equality (case-insensitive) | `"dbProvider == postgres"` | +| `variable != value` | Inequality (case-insensitive) | `"dbProvider != none"` | +| `variable` | Truthy check (non-empty and not `"false"`) | `"useRedisCache"` | + +#### Rendering + +Primary instructions are rendered first with bold formatting and a 🚀 prefix. Secondary instructions follow with dimmed formatting and an ℹ️ prefix. Variable placeholders (`{{name}}`) in lines are replaced with their values. + ## 5. Template Directory Structure A template repository using this system looks like: @@ -666,7 +725,8 @@ aspire template ├── search Search templates by name, description, or tags ├── refresh Force refresh the template cache ├── new [path] Scaffold a new aspire-template.json manifest -└── new-index [path] Scaffold a new aspire-template-index.json index file +├── new-index [path] Scaffold a new aspire-template-index.json index file +└── test [path] Test a template by generating all variable combinations ``` #### `aspire template list` @@ -769,6 +829,50 @@ Created aspire-template-index.json. Add templates to the "templates" array to make them discoverable. ``` +#### `aspire template test [path]` + +Tests a template by generating the full cartesian product of all variable combinations and applying each one to a separate output directory. This is designed for template authors to verify that all variable permutations produce valid output. + +```text +$ aspire template test --output ./test-output + +🔬 Testing template 'aspire-starter' (24 combinations) + +✅ #1 useRedisCache=true dbProvider=none httpPort=1024 + → ./test-output/aspire-starter0 +✅ #2 useRedisCache=true dbProvider=postgres httpPort=1024 + → ./test-output/aspire-starter1 +... +✅ #24 useRedisCache=false dbProvider=sqlite httpPort=65535 + → ./test-output/aspire-starter23 + +All 24 combinations passed +Output: ./test-output +``` + +**Template resolution:** When no path is provided and no local template files exist in the current directory, the command fetches the template index and presents an interactive selection prompt. + +**Matrix generation:** For each variable (excluding `projectName`): + +- `testValues` specified → use those values +- `boolean` → `[true, false]` +- `choice` → all choice values +- `integer` → min, max, and default values +- `string` → default value or `"TestValue"` + +**Port randomization:** Each generated variant gets unique randomized ports in `launchSettings.json` and `apphost.run.json` files, ensuring variants don't conflict if run simultaneously. + +**Output directories:** Named `{templateName}{index}` (e.g., `aspire-starter0`, `aspire-starter1`, ...). + +```text +Options: + [path] Path to template directory (default: interactive selection) + --output, -o Base output directory (default: current directory) + --name Template name to select (for indexes with multiple templates) + --dry-run List combinations without applying + --json Output results as JSON +``` + ### Modified Commands #### `aspire new` From 3ae8557132214561b44ad900472819e3bab70575 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 2 Mar 2026 22:35:06 +1100 Subject: [PATCH 28/28] Add comprehensive git template language test suite 181 test cases across 8 test files covering: - TemplateExpressionEvaluatorTests: 26 tests for {{variable}} substitution, filters (lowercase, uppercase, kebabcase, snakecase, camelcase, pascalcase), word splitting edge cases, unresolved variables, special characters - LocalizableStringTests: 14 tests for plain string, localized objects, culture fallback (exact/parent/first entry), case-insensitive keys, JSON deserialization of both string and object forms - GitTemplateManifestSerializationTests: 33 tests for manifest parsing including all variable types (string/boolean/choice/integer), validation, testValues, substitutions, conditionalFiles, postMessages, postInstructions, scope, localized displayName, roundtrip serialization - GitTemplateIndexSerializationTests: 10 tests for index parsing including templates, publisher, includes (federation), schema field, roundtrip - JsonConverterTests: 24 tests for JsonObjectConverter and JsonObjectListConverter polymorphic deserialization (string/bool/int/null) - GitTemplateEngineTests: 21 tests for end-to-end template application including content/filename substitution, filters, conditional files, binary file handling, excluded dirs, nested structures, postMessages - GitTemplateLogicTests: 35 tests for condition evaluation (==, !=, truthy), variable substitution, CLI token parsing, validation, kebab-case conversion - GitTemplateSchemaCompatibilityTests: 18 tests with real-world template manifests to ensure backward compatibility as schema evolves Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Templating/Git/GitTemplateEngineTests.cs | 556 +++++++++++++ .../Git/GitTemplateIndexSerializationTests.cs | 246 ++++++ .../Templating/Git/GitTemplateLogicTests.cs | 477 +++++++++++ .../GitTemplateManifestSerializationTests.cs | 778 ++++++++++++++++++ .../GitTemplateSchemaCompatibilityTests.cs | 585 +++++++++++++ .../Templating/Git/JsonConverterTests.cs | 263 ++++++ .../Templating/Git/LocalizableStringTests.cs | 208 +++++ .../Git/TemplateExpressionEvaluatorTests.cs | 265 ++++++ 8 files changed, 3378 insertions(+) create mode 100644 tests/Aspire.Cli.Tests/Templating/Git/GitTemplateEngineTests.cs create mode 100644 tests/Aspire.Cli.Tests/Templating/Git/GitTemplateIndexSerializationTests.cs create mode 100644 tests/Aspire.Cli.Tests/Templating/Git/GitTemplateLogicTests.cs create mode 100644 tests/Aspire.Cli.Tests/Templating/Git/GitTemplateManifestSerializationTests.cs create mode 100644 tests/Aspire.Cli.Tests/Templating/Git/GitTemplateSchemaCompatibilityTests.cs create mode 100644 tests/Aspire.Cli.Tests/Templating/Git/JsonConverterTests.cs create mode 100644 tests/Aspire.Cli.Tests/Templating/Git/LocalizableStringTests.cs create mode 100644 tests/Aspire.Cli.Tests/Templating/Git/TemplateExpressionEvaluatorTests.cs diff --git a/tests/Aspire.Cli.Tests/Templating/Git/GitTemplateEngineTests.cs b/tests/Aspire.Cli.Tests/Templating/Git/GitTemplateEngineTests.cs new file mode 100644 index 00000000000..5e4083f0e6f --- /dev/null +++ b/tests/Aspire.Cli.Tests/Templating/Git/GitTemplateEngineTests.cs @@ -0,0 +1,556 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Templating.Git; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Cli.Tests.Templating.Git; + +public class GitTemplateEngineTests(ITestOutputHelper outputHelper) +{ + private readonly GitTemplateEngine _engine = new(NullLogger.Instance); + + #region Content substitution + + [Fact] + public async Task Apply_ContentSubstitution_ReplacesInFileContent() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + // Create template files + await File.WriteAllTextAsync(Path.Combine(templateDir, "Program.cs"), "namespace TemplateApp;"); + await WriteManifestAsync(templateDir, """ + { + "version": 1, + "name": "test", + "substitutions": { + "content": { "TemplateApp": "{{projectName}}" } + } + } + """); + + var variables = new Dictionary { ["projectName"] = "MyApp" }; + await _engine.ApplyAsync(templateDir, outputDir, variables); + + var content = await File.ReadAllTextAsync(Path.Combine(outputDir, "Program.cs")); + Assert.Equal("namespace MyApp;", content); + } + + [Fact] + public async Task Apply_ContentSubstitution_MultiplePatterns() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + await File.WriteAllTextAsync(Path.Combine(templateDir, "config.txt"), "APP=APP_NAME PORT=APP_PORT"); + await WriteManifestAsync(templateDir, """ + { + "version": 1, + "name": "test", + "substitutions": { + "content": { + "APP_NAME": "{{name}}", + "APP_PORT": "{{port}}" + } + } + } + """); + + var variables = new Dictionary { ["name"] = "MyService", ["port"] = "8080" }; + await _engine.ApplyAsync(templateDir, outputDir, variables); + + var content = await File.ReadAllTextAsync(Path.Combine(outputDir, "config.txt")); + Assert.Equal("APP=MyService PORT=8080", content); + } + + [Fact] + public async Task Apply_ContentSubstitution_WithFilter() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + await File.WriteAllTextAsync(Path.Combine(templateDir, "file.txt"), "name=LOWER_NAME"); + await WriteManifestAsync(templateDir, """ + { + "version": 1, + "name": "test", + "substitutions": { + "content": { "LOWER_NAME": "{{projectName | lowercase}}" } + } + } + """); + + var variables = new Dictionary { ["projectName"] = "MyApp" }; + await _engine.ApplyAsync(templateDir, outputDir, variables); + + var content = await File.ReadAllTextAsync(Path.Combine(outputDir, "file.txt")); + Assert.Equal("name=myapp", content); + } + + #endregion + + #region Filename substitution + + [Fact] + public async Task Apply_FilenameSubstitution_RenamesFiles() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + await File.WriteAllTextAsync(Path.Combine(templateDir, "TemplateApp.csproj"), ""); + await WriteManifestAsync(templateDir, """ + { + "version": 1, + "name": "test", + "substitutions": { + "filenames": { "TemplateApp": "{{projectName}}" } + } + } + """); + + var variables = new Dictionary { ["projectName"] = "MyApp" }; + await _engine.ApplyAsync(templateDir, outputDir, variables); + + Assert.True(File.Exists(Path.Combine(outputDir, "MyApp.csproj"))); + Assert.False(File.Exists(Path.Combine(outputDir, "TemplateApp.csproj"))); + } + + [Fact] + public async Task Apply_FilenameSubstitution_RenamesDirectories() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + var subDir = Path.Combine(templateDir, "TemplateApp.AppHost"); + Directory.CreateDirectory(subDir); + await File.WriteAllTextAsync(Path.Combine(subDir, "Program.cs"), "// host"); + await WriteManifestAsync(templateDir, """ + { + "version": 1, + "name": "test", + "substitutions": { + "filenames": { "TemplateApp": "{{projectName}}" } + } + } + """); + + var variables = new Dictionary { ["projectName"] = "MyApp" }; + await _engine.ApplyAsync(templateDir, outputDir, variables); + + Assert.True(Directory.Exists(Path.Combine(outputDir, "MyApp.AppHost"))); + Assert.True(File.Exists(Path.Combine(outputDir, "MyApp.AppHost", "Program.cs"))); + } + + [Fact] + public async Task Apply_FilenameSubstitution_CombinedWithContentSubstitution() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + await File.WriteAllTextAsync(Path.Combine(templateDir, "TemplateApp.cs"), "class TemplateApp {}"); + await WriteManifestAsync(templateDir, """ + { + "version": 1, + "name": "test", + "substitutions": { + "filenames": { "TemplateApp": "{{projectName}}" }, + "content": { "TemplateApp": "{{projectName}}" } + } + } + """); + + var variables = new Dictionary { ["projectName"] = "MyApp" }; + await _engine.ApplyAsync(templateDir, outputDir, variables); + + Assert.True(File.Exists(Path.Combine(outputDir, "MyApp.cs"))); + var content = await File.ReadAllTextAsync(Path.Combine(outputDir, "MyApp.cs")); + Assert.Equal("class MyApp {}", content); + } + + #endregion + + #region Conditional files + + [Fact] + public async Task Apply_ConditionalFile_ExcludesWhenFalse() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + var testsDir = Path.Combine(templateDir, "Tests"); + Directory.CreateDirectory(testsDir); + await File.WriteAllTextAsync(Path.Combine(testsDir, "Test1.cs"), "test"); + await File.WriteAllTextAsync(Path.Combine(templateDir, "Program.cs"), "main"); + await WriteManifestAsync(templateDir, """ + { + "version": 1, + "name": "test", + "conditionalFiles": { + "Tests/": "{{includeTests}}" + } + } + """); + + var variables = new Dictionary { ["includeTests"] = "false" }; + await _engine.ApplyAsync(templateDir, outputDir, variables); + + Assert.False(Directory.Exists(Path.Combine(outputDir, "Tests"))); + Assert.True(File.Exists(Path.Combine(outputDir, "Program.cs"))); + } + + [Fact] + public async Task Apply_ConditionalFile_IncludesWhenTrue() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + var testsDir = Path.Combine(templateDir, "Tests"); + Directory.CreateDirectory(testsDir); + await File.WriteAllTextAsync(Path.Combine(testsDir, "Test1.cs"), "test"); + await WriteManifestAsync(templateDir, """ + { + "version": 1, + "name": "test", + "conditionalFiles": { + "Tests/": "{{includeTests}}" + } + } + """); + + var variables = new Dictionary { ["includeTests"] = "true" }; + await _engine.ApplyAsync(templateDir, outputDir, variables); + + Assert.True(Directory.Exists(Path.Combine(outputDir, "Tests"))); + Assert.True(File.Exists(Path.Combine(outputDir, "Tests", "Test1.cs"))); + } + + [Fact] + public async Task Apply_ConditionalFile_TruthyCheck_NonEmpty() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + var optDir = Path.Combine(templateDir, "Optional"); + Directory.CreateDirectory(optDir); + await File.WriteAllTextAsync(Path.Combine(optDir, "file.txt"), "opt"); + await WriteManifestAsync(templateDir, """ + { + "version": 1, + "name": "test", + "conditionalFiles": { + "Optional/": "feature" + } + } + """); + + // Non-empty, non-false → truthy → include + var variables = new Dictionary { ["feature"] = "enabled" }; + await _engine.ApplyAsync(templateDir, outputDir, variables); + + Assert.True(Directory.Exists(Path.Combine(outputDir, "Optional"))); + } + + [Fact] + public async Task Apply_ConditionalFile_TruthyCheck_FalseString_Excludes() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + var optDir = Path.Combine(templateDir, "Optional"); + Directory.CreateDirectory(optDir); + await File.WriteAllTextAsync(Path.Combine(optDir, "file.txt"), "opt"); + await WriteManifestAsync(templateDir, """ + { + "version": 1, + "name": "test", + "conditionalFiles": { + "Optional/": "{{feature}}" + } + } + """); + + // "false" string → evaluates to "false" → excluded + var variables = new Dictionary { ["feature"] = "false" }; + await _engine.ApplyAsync(templateDir, outputDir, variables); + + Assert.False(Directory.Exists(Path.Combine(outputDir, "Optional"))); + } + + [Fact] + public async Task Apply_ConditionalFile_NotEqual_Condition() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + var dir = Path.Combine(templateDir, "NoDocker"); + Directory.CreateDirectory(dir); + await File.WriteAllTextAsync(Path.Combine(dir, "readme.txt"), "no docker"); + // Engine uses template expression evaluation, not operator parsing. + // To exclude when useDocker is "true", the expression must evaluate to "false" or empty. + await WriteManifestAsync(templateDir, """ + { + "version": 1, + "name": "test", + "conditionalFiles": { + "NoDocker/": "{{showNoDocker}}" + } + } + """); + + // When showNoDocker is "false" → excluded + var variables = new Dictionary { ["showNoDocker"] = "false" }; + await _engine.ApplyAsync(templateDir, outputDir, variables); + Assert.False(Directory.Exists(Path.Combine(outputDir, "NoDocker"))); + + // Clean and test with "true" + Directory.Delete(outputDir, true); + variables = new Dictionary { ["showNoDocker"] = "true" }; + await _engine.ApplyAsync(templateDir, outputDir, variables); + Assert.True(Directory.Exists(Path.Combine(outputDir, "NoDocker"))); + } + + [Fact] + public async Task Apply_ConditionalFile_UndefinedVariable_LeftAsExpression_Included() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + var dir = Path.Combine(templateDir, "Conditional"); + Directory.CreateDirectory(dir); + await File.WriteAllTextAsync(Path.Combine(dir, "file.txt"), "data"); + await WriteManifestAsync(templateDir, """ + { + "version": 1, + "name": "test", + "conditionalFiles": { + "Conditional/": "{{missingVariable}}" + } + } + """); + + var variables = new Dictionary(); + await _engine.ApplyAsync(templateDir, outputDir, variables); + + // Undefined variable → expression left as "{{missingVariable}}" → not empty/false → included + Assert.True(Directory.Exists(Path.Combine(outputDir, "Conditional"))); + } + + #endregion + + #region Excluded files and directories + + [Fact] + public async Task Apply_ExcludesManifestFile() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + await File.WriteAllTextAsync(Path.Combine(templateDir, "file.txt"), "content"); + await WriteManifestAsync(templateDir, """{ "version": 1, "name": "test" }"""); + + await _engine.ApplyAsync(templateDir, outputDir, new Dictionary()); + + Assert.True(File.Exists(Path.Combine(outputDir, "file.txt"))); + Assert.False(File.Exists(Path.Combine(outputDir, "aspire-template.json"))); + } + + [Fact] + public async Task Apply_ExcludesGitDirectory() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + var gitDir = Path.Combine(templateDir, ".git"); + Directory.CreateDirectory(gitDir); + await File.WriteAllTextAsync(Path.Combine(gitDir, "config"), "git config"); + await File.WriteAllTextAsync(Path.Combine(templateDir, "file.txt"), "content"); + + await _engine.ApplyAsync(templateDir, outputDir, new Dictionary()); + + Assert.False(Directory.Exists(Path.Combine(outputDir, ".git"))); + Assert.True(File.Exists(Path.Combine(outputDir, "file.txt"))); + } + + [Fact] + public async Task Apply_ExcludesGitHubDirectory() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + var githubDir = Path.Combine(templateDir, ".github"); + Directory.CreateDirectory(githubDir); + await File.WriteAllTextAsync(Path.Combine(githubDir, "workflows.yml"), "ci"); + await File.WriteAllTextAsync(Path.Combine(templateDir, "file.txt"), "content"); + + await _engine.ApplyAsync(templateDir, outputDir, new Dictionary()); + + Assert.False(Directory.Exists(Path.Combine(outputDir, ".github"))); + } + + #endregion + + #region Binary files + + [Fact] + public async Task Apply_BinaryFile_CopiedWithoutSubstitution() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + // Create a file with a known binary extension + var binaryContent = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; + await File.WriteAllBytesAsync(Path.Combine(templateDir, "icon.png"), binaryContent); + await WriteManifestAsync(templateDir, """ + { + "version": 1, + "name": "test", + "substitutions": { + "content": { "REPLACE": "replaced" } + } + } + """); + + await _engine.ApplyAsync(templateDir, outputDir, new Dictionary()); + + var outputBytes = await File.ReadAllBytesAsync(Path.Combine(outputDir, "icon.png")); + Assert.Equal(binaryContent, outputBytes); + } + + [Fact] + public async Task Apply_BinaryFileByNullByte_CopiedWithoutSubstitution() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + // Create a file with embedded null bytes (detected as binary via sniffing) + var content = new byte[] { 0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x00, 0x57, 0x6F }; + await File.WriteAllBytesAsync(Path.Combine(templateDir, "data.bin"), content); + + await _engine.ApplyAsync(templateDir, outputDir, new Dictionary()); + + var outputBytes = await File.ReadAllBytesAsync(Path.Combine(outputDir, "data.bin")); + Assert.Equal(content, outputBytes); + } + + #endregion + + #region No manifest + + [Fact] + public async Task Apply_NoManifest_CopiesFilesVerbatim() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + await File.WriteAllTextAsync(Path.Combine(templateDir, "file.txt"), "unchanged content"); + + await _engine.ApplyAsync(templateDir, outputDir, new Dictionary()); + + var content = await File.ReadAllTextAsync(Path.Combine(outputDir, "file.txt")); + Assert.Equal("unchanged content", content); + } + + #endregion + + #region Nested directories + + [Fact] + public async Task Apply_NestedDirectories_PreservesStructure() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + var deepDir = Path.Combine(templateDir, "src", "TemplateApp", "Models"); + Directory.CreateDirectory(deepDir); + await File.WriteAllTextAsync(Path.Combine(deepDir, "Model.cs"), "namespace TemplateApp.Models;"); + await WriteManifestAsync(templateDir, """ + { + "version": 1, + "name": "test", + "substitutions": { + "filenames": { "TemplateApp": "{{projectName}}" }, + "content": { "TemplateApp": "{{projectName}}" } + } + } + """); + + var variables = new Dictionary { ["projectName"] = "MyApp" }; + await _engine.ApplyAsync(templateDir, outputDir, variables); + + Assert.True(Directory.Exists(Path.Combine(outputDir, "src", "MyApp", "Models"))); + var content = await File.ReadAllTextAsync(Path.Combine(outputDir, "src", "MyApp", "Models", "Model.cs")); + Assert.Equal("namespace MyApp.Models;", content); + } + + #endregion + + #region Empty template + + [Fact] + public async Task Apply_EmptyTemplate_CreatesOutputDirectory() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + await WriteManifestAsync(templateDir, """{ "version": 1, "name": "empty" }"""); + + await _engine.ApplyAsync(templateDir, outputDir, new Dictionary()); + + Assert.True(Directory.Exists(outputDir)); + } + + #endregion + + #region PostMessages + + [Fact] + public async Task Apply_PostMessages_SubstitutesVariables() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var templateDir = workspace.CreateDirectory("template").FullName; + var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output"); + + await File.WriteAllTextAsync(Path.Combine(templateDir, "file.txt"), "content"); + await WriteManifestAsync(templateDir, """ + { + "version": 1, + "name": "test", + "postMessages": ["Created {{projectName}} successfully"] + } + """); + + var variables = new Dictionary { ["projectName"] = "MyApp" }; + + // Should not throw + await _engine.ApplyAsync(templateDir, outputDir, variables); + } + + #endregion + + private static async Task WriteManifestAsync(string dir, string json) + { + await File.WriteAllTextAsync(Path.Combine(dir, "aspire-template.json"), json); + } +} diff --git a/tests/Aspire.Cli.Tests/Templating/Git/GitTemplateIndexSerializationTests.cs b/tests/Aspire.Cli.Tests/Templating/Git/GitTemplateIndexSerializationTests.cs new file mode 100644 index 00000000000..c327670829d --- /dev/null +++ b/tests/Aspire.Cli.Tests/Templating/Git/GitTemplateIndexSerializationTests.cs @@ -0,0 +1,246 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.Templating.Git; + +namespace Aspire.Cli.Tests.Templating.Git; + +public class GitTemplateIndexSerializationTests +{ + #region Minimal index + + [Fact] + public void Deserialize_MinimalIndex_HasDefaults() + { + var json = """ + { + "version": 1, + "templates": [] + } + """; + + var index = Deserialize(json); + Assert.Equal(1, index.Version); + Assert.Empty(index.Templates); + Assert.Null(index.Publisher); + Assert.Null(index.Includes); + } + + #endregion + + #region Templates + + [Fact] + public void Deserialize_IndexEntry_AllFields() + { + var json = """ + { + "version": 1, + "templates": [ + { + "name": "my-template", + "description": "A test template", + "path": "./my-template", + "repo": "https://github.com/user/repo", + "language": "csharp", + "tags": ["web", "api"], + "scope": ["new", "init"] + } + ] + } + """; + + var index = Deserialize(json); + var entry = index.Templates[0]; + + Assert.Equal("my-template", entry.Name); + Assert.Equal("A test template", entry.Description); + Assert.Equal("./my-template", entry.Path); + Assert.Equal("https://github.com/user/repo", entry.Repo); + Assert.Equal("csharp", entry.Language); + Assert.Equal(["web", "api"], entry.Tags); + Assert.Equal(["new", "init"], entry.Scope); + } + + [Fact] + public void Deserialize_IndexEntry_MinimalFields() + { + var json = """ + { + "version": 1, + "templates": [ + { + "name": "simple", + "path": "." + } + ] + } + """; + + var index = Deserialize(json); + var entry = index.Templates[0]; + + Assert.Equal("simple", entry.Name); + Assert.Equal(".", entry.Path); + Assert.Null(entry.Description); + Assert.Null(entry.Repo); + Assert.Null(entry.Language); + Assert.Null(entry.Tags); + Assert.Null(entry.Scope); + } + + [Fact] + public void Deserialize_MultipleTemplates() + { + var json = """ + { + "version": 1, + "templates": [ + { "name": "template-a", "path": "./a" }, + { "name": "template-b", "path": "./b" }, + { "name": "template-c", "path": "./c" } + ] + } + """; + + var index = Deserialize(json); + Assert.Equal(3, index.Templates.Count); + Assert.Equal("template-a", index.Templates[0].Name); + Assert.Equal("template-b", index.Templates[1].Name); + Assert.Equal("template-c", index.Templates[2].Name); + } + + #endregion + + #region Publisher + + [Fact] + public void Deserialize_Publisher_AllFields() + { + var json = """ + { + "version": 1, + "publisher": { + "name": "Aspire Team", + "url": "https://aspire.dev", + "verified": true + }, + "templates": [] + } + """; + + var index = Deserialize(json); + Assert.Equal("Aspire Team", index.Publisher!.Name); + Assert.Equal("https://aspire.dev", index.Publisher.Url); + Assert.True(index.Publisher.Verified); + } + + [Fact] + public void Deserialize_Publisher_NameOnly() + { + var json = """ + { + "version": 1, + "publisher": { "name": "Community Author" }, + "templates": [] + } + """; + + var index = Deserialize(json); + Assert.Equal("Community Author", index.Publisher!.Name); + Assert.Null(index.Publisher.Url); + Assert.Null(index.Publisher.Verified); + } + + #endregion + + #region Includes (federation) + + [Fact] + public void Deserialize_Includes_SingleEntry() + { + var json = """ + { + "version": 1, + "templates": [], + "includes": [ + { "url": "https://github.com/org/templates" } + ] + } + """; + + var index = Deserialize(json); + Assert.Single(index.Includes!); + Assert.Equal("https://github.com/org/templates", index.Includes![0].Url); + } + + [Fact] + public void Deserialize_Includes_MultipleEntries() + { + var json = """ + { + "version": 1, + "templates": [], + "includes": [ + { "url": "https://github.com/org/templates-a" }, + { "url": "https://github.com/org/templates-b" } + ] + } + """; + + var index = Deserialize(json); + Assert.Equal(2, index.Includes!.Count); + } + + #endregion + + #region Schema field + + [Fact] + public void Deserialize_SchemaField_Preserved() + { + var json = """ + { + "$schema": "https://aka.ms/aspire/template-index-schema/v1", + "version": 1, + "templates": [] + } + """; + + var index = Deserialize(json); + Assert.Equal("https://aka.ms/aspire/template-index-schema/v1", index.Schema); + } + + #endregion + + #region Roundtrip + + [Fact] + public void Serialize_Index_Roundtrips() + { + var index = new GitTemplateIndex + { + Schema = "https://aka.ms/aspire/template-index-schema/v1", + Templates = [ + new GitTemplateIndexEntry { Name = "test", Path = "./test" } + ] + }; + + var json = JsonSerializer.Serialize(index, GitTemplateJsonContext.Default.GitTemplateIndex); + var deserialized = JsonSerializer.Deserialize(json, GitTemplateJsonContext.Default.GitTemplateIndex); + + Assert.NotNull(deserialized); + Assert.Single(deserialized.Templates); + Assert.Equal("test", deserialized.Templates[0].Name); + } + + #endregion + + private static GitTemplateIndex Deserialize(string json) + { + var index = JsonSerializer.Deserialize(json, GitTemplateJsonContext.Default.GitTemplateIndex); + Assert.NotNull(index); + return index; + } +} diff --git a/tests/Aspire.Cli.Tests/Templating/Git/GitTemplateLogicTests.cs b/tests/Aspire.Cli.Tests/Templating/Git/GitTemplateLogicTests.cs new file mode 100644 index 00000000000..7d3f26c6d22 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Templating/Git/GitTemplateLogicTests.cs @@ -0,0 +1,477 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using Aspire.Cli.Templating.Git; + +namespace Aspire.Cli.Tests.Templating.Git; + +/// +/// Tests for the internal logic methods of : condition evaluation, +/// variable substitution, CLI value parsing, validation, and kebab-case conversion. +/// These methods are private/static, so we invoke them via reflection for thorough testing. +/// +public class GitTemplateLogicTests +{ + private static readonly Type s_gitTemplateType = typeof(GitTemplate); + + #region EvaluateCondition + + [Theory] + [InlineData(null, true)] // null → always true + [InlineData("", true)] // empty → always true + [InlineData(" ", true)] // whitespace → always true + public void EvaluateCondition_NullOrEmpty_ReturnsTrue(string? condition, bool expected) + { + var result = InvokeEvaluateCondition(condition, []); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("useRedis == true", "true", true)] + [InlineData("useRedis == true", "false", false)] + [InlineData("useRedis == true", "TRUE", true)] // case-insensitive + [InlineData("db == postgres", "postgres", true)] + [InlineData("db == postgres", "sqlserver", false)] + public void EvaluateCondition_Equality_Works(string condition, string value, bool expected) + { + var variables = new Dictionary { ["useRedis"] = value, ["db"] = value }; + var result = InvokeEvaluateCondition(condition, variables); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("useRedis != true", "true", false)] + [InlineData("useRedis != true", "false", true)] + [InlineData("db != postgres", "sqlserver", true)] + [InlineData("db != postgres", "postgres", false)] + public void EvaluateCondition_Inequality_Works(string condition, string value, bool expected) + { + var variables = new Dictionary { ["useRedis"] = value, ["db"] = value }; + var result = InvokeEvaluateCondition(condition, variables); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("myFlag", "true", true)] + [InlineData("myFlag", "yes", true)] + [InlineData("myFlag", "anything", true)] + [InlineData("myFlag", "false", false)] + [InlineData("myFlag", "", false)] + public void EvaluateCondition_BareVariable_TruthyCheck(string condition, string value, bool expected) + { + var variables = new Dictionary { ["myFlag"] = value }; + var result = InvokeEvaluateCondition(condition, variables); + Assert.Equal(expected, result); + } + + [Fact] + public void EvaluateCondition_UndefinedVariable_ReturnsFalse() + { + var result = InvokeEvaluateCondition("missingVar", new Dictionary()); + Assert.False(result); + } + + [Fact] + public void EvaluateCondition_UndefinedVariable_EqualityCheck_ReturnsFalse() + { + var result = InvokeEvaluateCondition("missing == value", new Dictionary()); + Assert.False(result); + } + + [Fact] + public void EvaluateCondition_UndefinedVariable_EqualityToEmpty_ReturnsTrue() + { + // missing variable resolves to "" which equals "" + var result = InvokeEvaluateCondition("missing == ", new Dictionary()); + Assert.True(result); + } + + [Fact] + public void EvaluateCondition_SpacesAroundOperator_Trimmed() + { + var variables = new Dictionary { ["x"] = "y" }; + var result = InvokeEvaluateCondition(" x == y ", variables); + Assert.True(result); + } + + #endregion + + #region SubstituteVariables + + [Fact] + public void SubstituteVariables_ReplacesPlaceholders() + { + var variables = new Dictionary { ["name"] = "MyApp", ["port"] = "5000" }; + var result = InvokeSubstituteVariables("cd {{name}} && run on {{port}}", variables); + Assert.Equal("cd MyApp && run on 5000", result); + } + + [Fact] + public void SubstituteVariables_NoPlaceholders_Unchanged() + { + var variables = new Dictionary { ["name"] = "MyApp" }; + var result = InvokeSubstituteVariables("no placeholders here", variables); + Assert.Equal("no placeholders here", result); + } + + [Fact] + public void SubstituteVariables_MissingVariable_LeftAsIs() + { + var variables = new Dictionary(); + var result = InvokeSubstituteVariables("{{missing}}", variables); + Assert.Equal("{{missing}}", result); + } + + [Fact] + public void SubstituteVariables_CaseInsensitive() + { + var variables = new Dictionary { ["ProjectName"] = "MyApp" }; + var result = InvokeSubstituteVariables("{{projectname}}", variables); + Assert.Equal("MyApp", result); + } + + #endregion + + #region ToKebabCase + + [Theory] + [InlineData("useRedis", "use-redis")] + [InlineData("useRedisCache", "use-redis-cache")] + [InlineData("ABC", "a-b-c")] + [InlineData("myApp", "my-app")] + [InlineData("a", "a")] + [InlineData("", "")] + [InlineData("Simple", "simple")] + [InlineData("alllowercase", "alllowercase")] + [InlineData("ALLUPPERCASE", "a-l-l-u-p-p-e-r-c-a-s-e")] + public void ToKebabCase_ConvertsCorrectly(string input, string expected) + { + var result = InvokeToKebabCase(input); + Assert.Equal(expected, result); + } + + #endregion + + #region ValidateCliValue + + [Theory] + [InlineData("true")] + [InlineData("false")] + [InlineData("True")] + [InlineData("False")] + public void ValidateCliValue_Boolean_ValidValues_ReturnsNull(string value) + { + var varDef = new GitTemplateVariable { Type = "boolean" }; + var result = InvokeValidateCliValue("flag", value, varDef); + Assert.Null(result); + } + + [Theory] + [InlineData("yes")] + [InlineData("1")] + [InlineData("on")] + [InlineData("notabool")] + public void ValidateCliValue_Boolean_InvalidValues_ReturnsError(string value) + { + var varDef = new GitTemplateVariable { Type = "boolean" }; + var result = InvokeValidateCliValue("flag", value, varDef); + Assert.NotNull(result); + Assert.Contains("true", result, StringComparison.OrdinalIgnoreCase); + Assert.Contains("false", result, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ValidateCliValue_Choice_ValidValue_ReturnsNull() + { + var varDef = new GitTemplateVariable + { + Type = "choice", + Choices = + [ + new GitTemplateVariableChoice { Value = "postgres" }, + new GitTemplateVariableChoice { Value = "sqlserver" } + ] + }; + var result = InvokeValidateCliValue("db", "postgres", varDef); + Assert.Null(result); + } + + [Fact] + public void ValidateCliValue_Choice_CaseInsensitive_ReturnsNull() + { + var varDef = new GitTemplateVariable + { + Type = "choice", + Choices = [new GitTemplateVariableChoice { Value = "postgres" }] + }; + var result = InvokeValidateCliValue("db", "POSTGRES", varDef); + Assert.Null(result); + } + + [Fact] + public void ValidateCliValue_Choice_InvalidValue_ReturnsError() + { + var varDef = new GitTemplateVariable + { + Type = "choice", + Choices = [new GitTemplateVariableChoice { Value = "postgres" }] + }; + var result = InvokeValidateCliValue("db", "mysql", varDef); + Assert.NotNull(result); + Assert.Contains("postgres", result); + } + + [Theory] + [InlineData("42", null, null, null)] // valid int, no bounds + [InlineData("0", null, null, null)] // zero + [InlineData("-5", null, null, null)] // negative + [InlineData("1024", 1024, 65535, null)] // at min + [InlineData("65535", 1024, 65535, null)] // at max + [InlineData("5000", 1024, 65535, null)] // in range + public void ValidateCliValue_Integer_ValidValues_ReturnsNull( + string value, int? min, int? max, string? expectedError) + { + var varDef = new GitTemplateVariable + { + Type = "integer", + Validation = (min.HasValue || max.HasValue) ? new GitTemplateVariableValidation { Min = min, Max = max } : null + }; + var result = InvokeValidateCliValue("port", value, varDef); + Assert.Equal(expectedError, result); + } + + [Fact] + public void ValidateCliValue_Integer_BelowMin_ReturnsError() + { + var varDef = new GitTemplateVariable + { + Type = "integer", + Validation = new GitTemplateVariableValidation { Min = 1024 } + }; + var result = InvokeValidateCliValue("port", "100", varDef); + Assert.NotNull(result); + Assert.Contains("1024", result); + } + + [Fact] + public void ValidateCliValue_Integer_AboveMax_ReturnsError() + { + var varDef = new GitTemplateVariable + { + Type = "integer", + Validation = new GitTemplateVariableValidation { Max = 65535 } + }; + var result = InvokeValidateCliValue("port", "70000", varDef); + Assert.NotNull(result); + Assert.Contains("65535", result); + } + + [Theory] + [InlineData("not-a-number")] + [InlineData("3.14")] + [InlineData("abc")] + public void ValidateCliValue_Integer_NonInteger_ReturnsError(string value) + { + var varDef = new GitTemplateVariable { Type = "integer" }; + var result = InvokeValidateCliValue("count", value, varDef); + Assert.NotNull(result); + Assert.Contains("integer", result, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ValidateCliValue_String_MatchesPattern_ReturnsNull() + { + var varDef = new GitTemplateVariable + { + Type = "string", + Validation = new GitTemplateVariableValidation { Pattern = "^[a-z]+$" } + }; + var result = InvokeValidateCliValue("name", "myapp", varDef); + Assert.Null(result); + } + + [Fact] + public void ValidateCliValue_String_FailsPattern_ReturnsError() + { + var varDef = new GitTemplateVariable + { + Type = "string", + Validation = new GitTemplateVariableValidation + { + Pattern = "^[a-z]+$", + Message = "Must be lowercase letters only" + } + }; + var result = InvokeValidateCliValue("name", "MyApp123", varDef); + Assert.NotNull(result); + Assert.Equal("Must be lowercase letters only", result); + } + + [Fact] + public void ValidateCliValue_String_FailsPattern_DefaultMessage() + { + var varDef = new GitTemplateVariable + { + Type = "string", + Validation = new GitTemplateVariableValidation { Pattern = "^[a-z]+$" } + }; + var result = InvokeValidateCliValue("name", "UPPER", varDef); + Assert.NotNull(result); + Assert.Contains("pattern", result, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ValidateCliValue_String_NoValidation_ReturnsNull() + { + var varDef = new GitTemplateVariable { Type = "string" }; + var result = InvokeValidateCliValue("name", "anything goes", varDef); + Assert.Null(result); + } + + #endregion + + #region ParseUnmatchedTokens + + [Fact] + public void ParseUnmatchedTokens_KeyValuePairs_Parsed() + { + var tokens = new[] { "--name", "MyApp", "--port", "5000" }; + var result = InvokeParseUnmatchedTokens(tokens); + Assert.Equal("MyApp", result["name"]); + Assert.Equal("5000", result["port"]); + } + + [Fact] + public void ParseUnmatchedTokens_BareFlag_TreatedAsBoolTrue() + { + var tokens = new[] { "--useRedis" }; + var result = InvokeParseUnmatchedTokens(tokens); + Assert.Equal("true", result["useRedis"]); + } + + [Fact] + public void ParseUnmatchedTokens_MixedFlagsAndValues_Parsed() + { + var tokens = new[] { "--useRedis", "--name", "MyApp", "--verbose" }; + var result = InvokeParseUnmatchedTokens(tokens); + Assert.Equal("true", result["useRedis"]); + Assert.Equal("MyApp", result["name"]); + Assert.Equal("true", result["verbose"]); + } + + [Fact] + public void ParseUnmatchedTokens_NonDashToken_Ignored() + { + var tokens = new[] { "positional", "--name", "MyApp" }; + var result = InvokeParseUnmatchedTokens(tokens); + Assert.Single(result); + Assert.Equal("MyApp", result["name"]); + } + + [Fact] + public void ParseUnmatchedTokens_EmptyTokens_ReturnsEmpty() + { + var result = InvokeParseUnmatchedTokens([]); + Assert.Empty(result); + } + + [Fact] + public void ParseUnmatchedTokens_ConsecutiveFlags_AllTreatedAsTrue() + { + var tokens = new[] { "--a", "--b", "--c" }; + var result = InvokeParseUnmatchedTokens(tokens); + Assert.Equal("true", result["a"]); + Assert.Equal("true", result["b"]); + Assert.Equal("true", result["c"]); + } + + #endregion + + #region TryGetCliValue + + [Fact] + public void TryGetCliValue_ExactMatch_ReturnsValue() + { + var cliValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["useRedis"] = "true" + }; + var found = InvokeTryGetCliValue(cliValues, "useRedis", out var value); + Assert.True(found); + Assert.Equal("true", value); + } + + [Fact] + public void TryGetCliValue_KebabCaseMatch_ReturnsValue() + { + var cliValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["use-redis"] = "true" + }; + var found = InvokeTryGetCliValue(cliValues, "useRedis", out var value); + Assert.True(found); + Assert.Equal("true", value); + } + + [Fact] + public void TryGetCliValue_NoMatch_ReturnsFalse() + { + var cliValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + var found = InvokeTryGetCliValue(cliValues, "useRedis", out var value); + Assert.False(found); + Assert.Equal("", value); + } + + #endregion + + #region Reflection helpers + + private static bool InvokeEvaluateCondition(string? condition, Dictionary variables) + { + var method = s_gitTemplateType.GetMethod("EvaluateCondition", BindingFlags.Static | BindingFlags.NonPublic)!; + return (bool)method.Invoke(null, [condition, variables])!; + } + + private static string InvokeSubstituteVariables(string text, Dictionary variables) + { + var method = s_gitTemplateType.GetMethod("SubstituteVariables", BindingFlags.Static | BindingFlags.NonPublic)!; + return (string)method.Invoke(null, [text, variables])!; + } + + private static string InvokeToKebabCase(string input) + { + var method = s_gitTemplateType.GetMethod("ToKebabCase", BindingFlags.Static | BindingFlags.NonPublic)!; + return (string)method.Invoke(null, [input])!; + } + + private static string? InvokeValidateCliValue(string varName, string value, GitTemplateVariable varDef) + { + var method = s_gitTemplateType.GetMethod("ValidateCliValue", BindingFlags.Static | BindingFlags.NonPublic)!; + return (string?)method.Invoke(null, [varName, value, varDef]); + } + + private static Dictionary InvokeParseUnmatchedTokens(string[] tokens) + { + // Create a mock ParseResult — we need to simulate unmatched tokens + // ParseUnmatchedTokens works with parseResult.UnmatchedTokens which is an IReadOnlyList + // Since ParseResult is complex, we'll test via the raw logic + var method = s_gitTemplateType.GetMethod("ParseUnmatchedTokens", BindingFlags.Static | BindingFlags.NonPublic)!; + + // We need a real ParseResult. Let's create one with unmatched tokens. + var rootCommand = new System.CommandLine.RootCommand { TreatUnmatchedTokensAsErrors = false }; + var parseResult = rootCommand.Parse(tokens); + return (Dictionary)method.Invoke(null, [parseResult])!; + } + + private static bool InvokeTryGetCliValue(Dictionary cliValues, string varName, out string value) + { + var method = s_gitTemplateType.GetMethod("TryGetCliValue", BindingFlags.Static | BindingFlags.NonPublic)!; + var parameters = new object?[] { cliValues, varName, null }; + var result = (bool)method.Invoke(null, parameters)!; + value = (string)parameters[2]!; + return result; + } + + #endregion +} diff --git a/tests/Aspire.Cli.Tests/Templating/Git/GitTemplateManifestSerializationTests.cs b/tests/Aspire.Cli.Tests/Templating/Git/GitTemplateManifestSerializationTests.cs new file mode 100644 index 00000000000..c3413d5df20 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Templating/Git/GitTemplateManifestSerializationTests.cs @@ -0,0 +1,778 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.Templating.Git; + +namespace Aspire.Cli.Tests.Templating.Git; + +public class GitTemplateManifestSerializationTests +{ + #region Minimal manifest + + [Fact] + public void Deserialize_MinimalManifest_HasDefaults() + { + var json = """ + { + "version": 1, + "name": "my-template" + } + """; + + var manifest = Deserialize(json); + + Assert.Equal(1, manifest.Version); + Assert.Equal("my-template", manifest.Name); + Assert.Null(manifest.DisplayName); + Assert.Null(manifest.Description); + Assert.Null(manifest.Language); + Assert.Null(manifest.Scope); + Assert.Null(manifest.Variables); + Assert.Null(manifest.Substitutions); + Assert.Null(manifest.ConditionalFiles); + Assert.Null(manifest.PostMessages); + Assert.Null(manifest.PostInstructions); + } + + #endregion + + #region Full manifest + + [Fact] + public void Deserialize_FullManifest_AllFieldsPopulated() + { + var json = """ + { + "$schema": "https://aka.ms/aspire/template-schema/v1", + "version": 1, + "name": "full-template", + "displayName": "Full Template", + "description": "A complete template with all features", + "language": "csharp", + "scope": ["new", "init"], + "variables": { + "appName": { + "type": "string", + "displayName": "Application Name", + "required": true, + "defaultValue": "MyApp" + } + }, + "substitutions": { + "filenames": { "TemplateApp": "{{appName}}" }, + "content": { "TemplateApp": "{{appName}}" } + }, + "conditionalFiles": { + "Tests/": "includeTests == true" + }, + "postMessages": ["Template created successfully"], + "postInstructions": [ + { + "heading": "Get started", + "priority": "primary", + "lines": ["cd {{appName}}", "dotnet run"] + } + ] + } + """; + + var manifest = Deserialize(json); + + Assert.Equal("https://aka.ms/aspire/template-schema/v1", manifest.Schema); + Assert.Equal("full-template", manifest.Name); + Assert.Equal("Full Template", manifest.DisplayName?.Resolve()); + Assert.Equal("A complete template with all features", manifest.Description?.Resolve()); + Assert.Equal("csharp", manifest.Language); + Assert.Equal(["new", "init"], manifest.Scope); + Assert.NotNull(manifest.Variables); + Assert.Single(manifest.Variables); + Assert.NotNull(manifest.Substitutions); + Assert.NotNull(manifest.ConditionalFiles); + Assert.Single(manifest.PostMessages!); + Assert.Single(manifest.PostInstructions!); + } + + #endregion + + #region Variables + + [Fact] + public void Deserialize_StringVariable_WithValidation() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": { + "namespace": { + "type": "string", + "displayName": "Namespace", + "description": "The root namespace", + "required": true, + "defaultValue": "MyApp", + "validation": { + "pattern": "^[A-Za-z][A-Za-z0-9.]*$", + "message": "Must be a valid .NET namespace" + } + } + } + } + """; + + var manifest = Deserialize(json); + var variable = manifest.Variables!["namespace"]; + + Assert.Equal("string", variable.Type); + Assert.Equal("Namespace", variable.DisplayName?.Resolve()); + Assert.Equal("The root namespace", variable.Description?.Resolve()); + Assert.True(variable.Required); + Assert.Equal("MyApp", variable.DefaultValue as string); + Assert.Equal("^[A-Za-z][A-Za-z0-9.]*$", variable.Validation?.Pattern); + Assert.Equal("Must be a valid .NET namespace", variable.Validation?.Message); + } + + [Fact] + public void Deserialize_BooleanVariable_WithDefaults() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": { + "useRedis": { + "type": "boolean", + "displayName": "Include Redis cache", + "defaultValue": true + } + } + } + """; + + var manifest = Deserialize(json); + var variable = manifest.Variables!["useRedis"]; + + Assert.Equal("boolean", variable.Type); + Assert.Equal(true, variable.DefaultValue); + } + + [Fact] + public void Deserialize_BooleanVariable_DefaultFalse() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": { + "enableTelemetry": { + "type": "boolean", + "defaultValue": false + } + } + } + """; + + var manifest = Deserialize(json); + var variable = manifest.Variables!["enableTelemetry"]; + Assert.Equal(false, variable.DefaultValue); + } + + [Fact] + public void Deserialize_ChoiceVariable_WithChoices() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": { + "database": { + "type": "choice", + "displayName": "Database", + "choices": [ + { "value": "postgres", "displayName": "PostgreSQL" }, + { "value": "sqlserver", "displayName": "SQL Server" }, + { "value": "none", "displayName": "None" } + ], + "defaultValue": "postgres" + } + } + } + """; + + var manifest = Deserialize(json); + var variable = manifest.Variables!["database"]; + + Assert.Equal("choice", variable.Type); + Assert.Equal(3, variable.Choices!.Count); + Assert.Equal("postgres", variable.Choices[0].Value); + Assert.Equal("PostgreSQL", variable.Choices[0].DisplayName?.Resolve()); + Assert.Equal("sqlserver", variable.Choices[1].Value); + Assert.Equal("none", variable.Choices[2].Value); + Assert.Equal("postgres", variable.DefaultValue as string); + } + + [Fact] + public void Deserialize_IntegerVariable_WithMinMax() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": { + "port": { + "type": "integer", + "displayName": "HTTP Port", + "defaultValue": 5000, + "validation": { + "min": 1024, + "max": 65535 + } + } + } + } + """; + + var manifest = Deserialize(json); + var variable = manifest.Variables!["port"]; + + Assert.Equal("integer", variable.Type); + Assert.Equal(5000, variable.DefaultValue); + Assert.Equal(1024, variable.Validation?.Min); + Assert.Equal(65535, variable.Validation?.Max); + } + + [Fact] + public void Deserialize_MultipleVariables_AllPresent() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": { + "name": { "type": "string", "defaultValue": "App" }, + "useRedis": { "type": "boolean", "defaultValue": false }, + "db": { "type": "choice", "choices": [{"value": "pg"}], "defaultValue": "pg" }, + "port": { "type": "integer", "defaultValue": 5000 } + } + } + """; + + var manifest = Deserialize(json); + Assert.Equal(4, manifest.Variables!.Count); + Assert.Contains("name", manifest.Variables.Keys); + Assert.Contains("useRedis", manifest.Variables.Keys); + Assert.Contains("db", manifest.Variables.Keys); + Assert.Contains("port", manifest.Variables.Keys); + } + + [Fact] + public void Deserialize_VariableWithNoDefaultValue_DefaultIsNull() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": { + "name": { "type": "string", "required": true } + } + } + """; + + var manifest = Deserialize(json); + Assert.Null(manifest.Variables!["name"].DefaultValue); + } + + [Fact] + public void Deserialize_VariableWithTestValues_ParsesCorrectly() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": { + "framework": { + "type": "choice", + "choices": [ + { "value": "minimal-api" }, + { "value": "controllers" }, + { "value": "blazor" } + ], + "testValues": ["minimal-api", "controllers"] + } + } + } + """; + + var manifest = Deserialize(json); + var variable = manifest.Variables!["framework"]; + Assert.NotNull(variable.TestValues); + Assert.Equal(2, variable.TestValues.Count); + Assert.Equal("minimal-api", variable.TestValues[0]); + Assert.Equal("controllers", variable.TestValues[1]); + } + + [Fact] + public void Deserialize_TestValues_MixedTypes() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": { + "useHttps": { + "type": "boolean", + "testValues": [true, false] + } + } + } + """; + + var manifest = Deserialize(json); + var variable = manifest.Variables!["useHttps"]; + Assert.NotNull(variable.TestValues); + Assert.Equal(2, variable.TestValues.Count); + Assert.Equal(true, variable.TestValues[0]); + Assert.Equal(false, variable.TestValues[1]); + } + + [Fact] + public void Deserialize_TestValues_IntegerValues() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": { + "port": { + "type": "integer", + "testValues": [1024, 5000, 65535] + } + } + } + """; + + var manifest = Deserialize(json); + var variable = manifest.Variables!["port"]; + Assert.NotNull(variable.TestValues); + Assert.Equal(3, variable.TestValues.Count); + Assert.Equal(1024, variable.TestValues[0]); + Assert.Equal(5000, variable.TestValues[1]); + Assert.Equal(65535, variable.TestValues[2]); + } + + #endregion + + #region Substitutions + + [Fact] + public void Deserialize_Substitutions_FilenamesOnly() + { + var json = """ + { + "version": 1, + "name": "test", + "substitutions": { + "filenames": { "TemplateApp": "{{projectName}}" } + } + } + """; + + var manifest = Deserialize(json); + Assert.NotNull(manifest.Substitutions?.Filenames); + Assert.Null(manifest.Substitutions?.Content); + Assert.Equal("{{projectName}}", manifest.Substitutions!.Filenames!["TemplateApp"]); + } + + [Fact] + public void Deserialize_Substitutions_ContentOnly() + { + var json = """ + { + "version": 1, + "name": "test", + "substitutions": { + "content": { "PLACEHOLDER": "{{value}}" } + } + } + """; + + var manifest = Deserialize(json); + Assert.Null(manifest.Substitutions?.Filenames); + Assert.NotNull(manifest.Substitutions?.Content); + Assert.Equal("{{value}}", manifest.Substitutions.Content["PLACEHOLDER"]); + } + + [Fact] + public void Deserialize_Substitutions_MultiplePatterns() + { + var json = """ + { + "version": 1, + "name": "test", + "substitutions": { + "filenames": { + "App": "{{projectName}}", + "Template": "{{projectName | pascalcase}}" + }, + "content": { + "APP_NAME": "{{projectName}}", + "APP_LOWER": "{{projectName | lowercase}}" + } + } + } + """; + + var manifest = Deserialize(json); + Assert.Equal(2, manifest.Substitutions!.Filenames!.Count); + Assert.Equal(2, manifest.Substitutions.Content!.Count); + } + + #endregion + + #region Conditional files + + [Fact] + public void Deserialize_ConditionalFiles_ParsesCorrectly() + { + var json = """ + { + "version": 1, + "name": "test", + "conditionalFiles": { + "Tests/": "includeTests == true", + "Redis/": "useRedis", + "Docker/": "docker != false" + } + } + """; + + var manifest = Deserialize(json); + Assert.Equal(3, manifest.ConditionalFiles!.Count); + Assert.Equal("includeTests == true", manifest.ConditionalFiles["Tests/"]); + Assert.Equal("useRedis", manifest.ConditionalFiles["Redis/"]); + Assert.Equal("docker != false", manifest.ConditionalFiles["Docker/"]); + } + + #endregion + + #region PostMessages + + [Fact] + public void Deserialize_PostMessages_SimpleStrings() + { + var json = """ + { + "version": 1, + "name": "test", + "postMessages": [ + "Run 'dotnet run' to start", + "Visit https://localhost:5001" + ] + } + """; + + var manifest = Deserialize(json); + Assert.Equal(2, manifest.PostMessages!.Count); + Assert.Equal("Run 'dotnet run' to start", manifest.PostMessages[0]); + } + + [Fact] + public void Deserialize_PostMessages_EmptyArray() + { + var json = """ + { + "version": 1, + "name": "test", + "postMessages": [] + } + """; + + var manifest = Deserialize(json); + Assert.Empty(manifest.PostMessages!); + } + + #endregion + + #region PostInstructions + + [Fact] + public void Deserialize_PostInstruction_AllFields() + { + var json = """ + { + "version": 1, + "name": "test", + "postInstructions": [ + { + "heading": "Get started", + "priority": "primary", + "lines": ["cd {{projectName}}", "dotnet run"], + "condition": "framework == minimal-api" + } + ] + } + """; + + var manifest = Deserialize(json); + var instruction = manifest.PostInstructions![0]; + Assert.Equal("Get started", instruction.Heading.Resolve()); + Assert.Equal("primary", instruction.Priority); + Assert.Equal(2, instruction.Lines.Count); + Assert.Equal("cd {{projectName}}", instruction.Lines[0]); + Assert.Equal("framework == minimal-api", instruction.Condition); + } + + [Fact] + public void Deserialize_PostInstruction_DefaultPriorityIsSecondary() + { + var json = """ + { + "version": 1, + "name": "test", + "postInstructions": [ + { + "heading": "Info", + "lines": ["Some info"] + } + ] + } + """; + + var manifest = Deserialize(json); + Assert.Equal("secondary", manifest.PostInstructions![0].Priority); + } + + [Fact] + public void Deserialize_PostInstruction_NoCondition_IsNull() + { + var json = """ + { + "version": 1, + "name": "test", + "postInstructions": [ + { + "heading": "Always shown", + "lines": ["Do this"] + } + ] + } + """; + + var manifest = Deserialize(json); + Assert.Null(manifest.PostInstructions![0].Condition); + } + + [Fact] + public void Deserialize_PostInstruction_LocalizedHeading() + { + var json = """ + { + "version": 1, + "name": "test", + "postInstructions": [ + { + "heading": { "en": "Get started", "de": "Erste Schritte" }, + "lines": ["dotnet run"] + } + ] + } + """; + + var manifest = Deserialize(json); + Assert.NotNull(manifest.PostInstructions![0].Heading); + } + + [Fact] + public void Deserialize_MultiplePostInstructions_MixedPriority() + { + var json = """ + { + "version": 1, + "name": "test", + "postInstructions": [ + { "heading": "Primary", "priority": "primary", "lines": ["a"] }, + { "heading": "Secondary", "priority": "secondary", "lines": ["b"] }, + { "heading": "Default", "lines": ["c"] }, + { "heading": "Conditional", "lines": ["d"], "condition": "flag == true" } + ] + } + """; + + var manifest = Deserialize(json); + Assert.Equal(4, manifest.PostInstructions!.Count); + Assert.Equal("primary", manifest.PostInstructions[0].Priority); + Assert.Equal("secondary", manifest.PostInstructions[1].Priority); + Assert.Equal("secondary", manifest.PostInstructions[2].Priority); + Assert.Equal("flag == true", manifest.PostInstructions[3].Condition); + } + + #endregion + + #region Scope + + [Fact] + public void Deserialize_Scope_NewOnly() + { + var json = """{ "version": 1, "name": "test", "scope": ["new"] }"""; + var manifest = Deserialize(json); + Assert.Equal(["new"], manifest.Scope); + } + + [Fact] + public void Deserialize_Scope_InitOnly() + { + var json = """{ "version": 1, "name": "test", "scope": ["init"] }"""; + var manifest = Deserialize(json); + Assert.Equal(["init"], manifest.Scope); + } + + [Fact] + public void Deserialize_Scope_Both() + { + var json = """{ "version": 1, "name": "test", "scope": ["new", "init"] }"""; + var manifest = Deserialize(json); + Assert.Equal(["new", "init"], manifest.Scope); + } + + [Fact] + public void Deserialize_Scope_Missing_IsNull() + { + var json = """{ "version": 1, "name": "test" }"""; + var manifest = Deserialize(json); + Assert.Null(manifest.Scope); + } + + #endregion + + #region Localized display names + + [Fact] + public void Deserialize_LocalizedDisplayName_ObjectForm() + { + var json = """ + { + "version": 1, + "name": "test", + "displayName": { + "en": "My Template", + "de": "Meine Vorlage" + } + } + """; + + var manifest = Deserialize(json); + Assert.NotNull(manifest.DisplayName); + } + + [Fact] + public void Deserialize_LocalizedDisplayName_StringForm() + { + var json = """ + { + "version": 1, + "name": "test", + "displayName": "Simple Name" + } + """; + + var manifest = Deserialize(json); + Assert.Equal("Simple Name", manifest.DisplayName?.Resolve()); + } + + [Fact] + public void Deserialize_VariableLocalizedDisplayName() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": { + "name": { + "type": "string", + "displayName": { "en": "Name", "de": "Name" }, + "description": { "en": "The project name", "de": "Der Projektname" } + } + } + } + """; + + var manifest = Deserialize(json); + var variable = manifest.Variables!["name"]; + Assert.NotNull(variable.DisplayName); + Assert.NotNull(variable.Description); + } + + [Fact] + public void Deserialize_ChoiceLocalizedDisplayName() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": { + "db": { + "type": "choice", + "choices": [ + { + "value": "postgres", + "displayName": { "en": "PostgreSQL", "de": "PostgreSQL" }, + "description": { "en": "Use Postgres", "de": "Postgres verwenden" } + } + ] + } + } + } + """; + + var manifest = Deserialize(json); + var choice = manifest.Variables!["db"].Choices![0]; + Assert.NotNull(choice.DisplayName); + Assert.NotNull(choice.Description); + } + + #endregion + + #region Roundtrip serialization + + [Fact] + public void Serialize_MinimalManifest_ProducesValidJson() + { + var manifest = new GitTemplateManifest { Name = "test-roundtrip" }; + var json = JsonSerializer.Serialize(manifest, GitTemplateJsonContext.Default.GitTemplateManifest); + + Assert.Contains("\"name\"", json); + Assert.Contains("test-roundtrip", json); + + // Roundtrip + var deserialized = JsonSerializer.Deserialize(json, GitTemplateJsonContext.Default.GitTemplateManifest); + Assert.Equal("test-roundtrip", deserialized!.Name); + } + + #endregion + + #region Schema field + + [Fact] + public void Deserialize_SchemaField_Preserved() + { + var json = """ + { + "$schema": "https://aka.ms/aspire/template-schema/v1", + "version": 1, + "name": "test" + } + """; + + var manifest = Deserialize(json); + Assert.Equal("https://aka.ms/aspire/template-schema/v1", manifest.Schema); + } + + #endregion + + private static GitTemplateManifest Deserialize(string json) + { + var manifest = JsonSerializer.Deserialize(json, GitTemplateJsonContext.Default.GitTemplateManifest); + Assert.NotNull(manifest); + return manifest; + } +} diff --git a/tests/Aspire.Cli.Tests/Templating/Git/GitTemplateSchemaCompatibilityTests.cs b/tests/Aspire.Cli.Tests/Templating/Git/GitTemplateSchemaCompatibilityTests.cs new file mode 100644 index 00000000000..216c8140d87 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Templating/Git/GitTemplateSchemaCompatibilityTests.cs @@ -0,0 +1,585 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.Templating.Git; + +namespace Aspire.Cli.Tests.Templating.Git; + +/// +/// Tests that represent real-world template manifests to ensure backward compatibility. +/// Each test simulates a complete template manifest from a real or realistic scenario. +/// When the schema evolves, these tests ensure existing templates continue to parse. +/// +public class GitTemplateSchemaCompatibilityTests +{ + #region V1 schema — real-world template manifests + + [Fact] + public void V1_StarterTemplate_ParsesCorrectly() + { + var json = """ + { + "$schema": "https://aka.ms/aspire/template-schema/v1", + "version": 1, + "name": "aspire-starter", + "displayName": ".NET Aspire Starter", + "description": "A starter template with web frontend and API backend", + "language": "csharp", + "scope": ["new"], + "variables": { + "projectName": { + "type": "string", + "displayName": "Project name", + "required": true, + "defaultValue": "AspireApp" + }, + "useRedis": { + "type": "boolean", + "displayName": "Include Redis cache", + "defaultValue": false + }, + "database": { + "type": "choice", + "displayName": "Database", + "choices": [ + { "value": "none", "displayName": "None" }, + { "value": "postgres", "displayName": "PostgreSQL" }, + { "value": "sqlserver", "displayName": "SQL Server" } + ], + "defaultValue": "none" + }, + "httpPort": { + "type": "integer", + "displayName": "HTTP port", + "defaultValue": 5180, + "validation": { "min": 1024, "max": 65535 } + } + }, + "substitutions": { + "filenames": { "AspireApp": "{{projectName}}" }, + "content": { "AspireApp": "{{projectName}}" } + }, + "conditionalFiles": { + "AspireApp.Tests/": "includeTests == true", + "Redis/": "useRedis == true" + }, + "postMessages": [ + "Your project '{{projectName}}' has been created." + ], + "postInstructions": [ + { + "heading": "Get started", + "priority": "primary", + "lines": [ + "cd {{projectName}}", + "dotnet run --project {{projectName}}.AppHost" + ] + }, + { + "heading": "Redis setup", + "priority": "secondary", + "condition": "useRedis == true", + "lines": ["Docker must be running for the Redis container."] + }, + { + "heading": "Database migration", + "priority": "secondary", + "condition": "database != none", + "lines": [ + "dotnet ef migrations add InitialCreate", + "dotnet ef database update" + ] + } + ] + } + """; + + var manifest = Deserialize(json); + + Assert.Equal("aspire-starter", manifest.Name); + Assert.Equal(".NET Aspire Starter", manifest.DisplayName?.Resolve()); + Assert.Equal(4, manifest.Variables!.Count); + Assert.Equal("string", manifest.Variables["projectName"].Type); + Assert.Equal("boolean", manifest.Variables["useRedis"].Type); + Assert.Equal("choice", manifest.Variables["database"].Type); + Assert.Equal("integer", manifest.Variables["httpPort"].Type); + Assert.Equal(3, manifest.Variables["database"].Choices!.Count); + Assert.Equal(1024, manifest.Variables["httpPort"].Validation!.Min); + Assert.Equal(65535, manifest.Variables["httpPort"].Validation!.Max); + Assert.NotNull(manifest.Substitutions?.Filenames); + Assert.NotNull(manifest.Substitutions?.Content); + Assert.Equal(2, manifest.ConditionalFiles!.Count); + Assert.Single(manifest.PostMessages!); + Assert.Equal(3, manifest.PostInstructions!.Count); + Assert.Equal("primary", manifest.PostInstructions[0].Priority); + Assert.Equal("secondary", manifest.PostInstructions[1].Priority); + Assert.Equal("useRedis == true", manifest.PostInstructions[1].Condition); + } + + [Fact] + public void V1_PythonTemplate_ParsesCorrectly() + { + var json = """ + { + "version": 1, + "name": "aspire-python-starter", + "displayName": "Python + Aspire Starter", + "description": "A polyglot template with Python FastAPI backend", + "language": "python", + "scope": ["new"], + "variables": { + "projectName": { + "type": "string", + "required": true, + "defaultValue": "PyApp" + }, + "webFramework": { + "type": "choice", + "displayName": "Python web framework", + "choices": [ + { "value": "fastapi", "displayName": "FastAPI" }, + { "value": "flask", "displayName": "Flask" }, + { "value": "django", "displayName": "Django" } + ], + "defaultValue": "fastapi", + "testValues": ["fastapi", "flask"] + } + }, + "substitutions": { + "filenames": { "PyApp": "{{projectName}}" }, + "content": { + "PyApp": "{{projectName}}", + "PYAPP_LOWER": "{{projectName | lowercase}}" + } + } + } + """; + + var manifest = Deserialize(json); + + Assert.Equal("aspire-python-starter", manifest.Name); + Assert.Equal("python", manifest.Language); + Assert.Equal(2, manifest.Variables!.Count); + Assert.Equal(3, manifest.Variables["webFramework"].Choices!.Count); + Assert.NotNull(manifest.Variables["webFramework"].TestValues); + Assert.Equal(2, manifest.Variables["webFramework"].TestValues!.Count); + Assert.Contains("{{projectName | lowercase}}", manifest.Substitutions!.Content!.Values); + } + + [Fact] + public void V1_LocalizedTemplate_ParsesCorrectly() + { + var json = """ + { + "version": 1, + "name": "localized-template", + "displayName": { + "en": "Localized Template", + "de": "Lokalisierte Vorlage", + "ja": "ローカライズテンプレート" + }, + "description": { + "en": "A template with localized strings", + "de": "Eine Vorlage mit lokalisierten Zeichenketten" + }, + "variables": { + "name": { + "type": "string", + "displayName": { + "en": "Project name", + "de": "Projektname" + }, + "description": { + "en": "Name of the project", + "de": "Name des Projekts" + }, + "required": true + } + } + } + """; + + var manifest = Deserialize(json); + + Assert.NotNull(manifest.DisplayName); + Assert.NotNull(manifest.Description); + Assert.NotNull(manifest.Variables!["name"].DisplayName); + Assert.NotNull(manifest.Variables["name"].Description); + } + + [Fact] + public void V1_InitTemplate_ScopeInit() + { + var json = """ + { + "version": 1, + "name": "aspire-init", + "displayName": "Add Aspire to existing project", + "scope": ["init"], + "variables": { + "projectName": { + "type": "string", + "required": true + } + } + } + """; + + var manifest = Deserialize(json); + Assert.Equal(["init"], manifest.Scope); + } + + [Fact] + public void V1_DualScopeTemplate() + { + var json = """ + { + "version": 1, + "name": "flexible-template", + "scope": ["new", "init"] + } + """; + + var manifest = Deserialize(json); + Assert.Contains("new", manifest.Scope!); + Assert.Contains("init", manifest.Scope!); + } + + [Fact] + public void V1_TemplateWithTestValues_AllTypes() + { + var json = """ + { + "version": 1, + "name": "test-values-template", + "variables": { + "framework": { + "type": "choice", + "choices": [ + { "value": "minimal-api" }, + { "value": "controllers" }, + { "value": "blazor" } + ], + "testValues": ["minimal-api", "controllers"] + }, + "useHttps": { + "type": "boolean", + "testValues": [true, false] + }, + "port": { + "type": "integer", + "validation": { "min": 1024, "max": 65535 }, + "testValues": [1024, 5000, 65535] + }, + "namespace": { + "type": "string", + "testValues": ["MyApp", "Contoso.App"] + } + } + } + """; + + var manifest = Deserialize(json); + + Assert.Equal(2, manifest.Variables!["framework"].TestValues!.Count); + Assert.Equal(2, manifest.Variables["useHttps"].TestValues!.Count); + Assert.Equal(true, manifest.Variables["useHttps"].TestValues![0]); + Assert.Equal(false, manifest.Variables["useHttps"].TestValues![1]); + Assert.Equal(3, manifest.Variables["port"].TestValues!.Count); + Assert.Equal(1024, manifest.Variables["port"].TestValues![0]); + Assert.Equal(2, manifest.Variables["namespace"].TestValues!.Count); + } + + #endregion + + #region Forward compatibility — unknown fields are ignored + + [Fact] + public void V1_UnknownTopLevelFields_Ignored() + { + var json = """ + { + "version": 1, + "name": "test", + "futureField": "future value", + "anotherFutureField": { "nested": true } + } + """; + + // Should not throw — unknown fields are silently ignored by default + var manifest = Deserialize(json); + Assert.Equal("test", manifest.Name); + } + + [Fact] + public void V1_UnknownVariableFields_Ignored() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": { + "name": { + "type": "string", + "futureProperty": "future", + "anotherFuture": 42 + } + } + } + """; + + var manifest = Deserialize(json); + Assert.Equal("string", manifest.Variables!["name"].Type); + } + + [Fact] + public void V1_UnknownSubstitutionFields_Ignored() + { + var json = """ + { + "version": 1, + "name": "test", + "substitutions": { + "filenames": { "A": "B" }, + "content": { "C": "D" }, + "futureSubType": { "E": "F" } + } + } + """; + + var manifest = Deserialize(json); + Assert.NotNull(manifest.Substitutions); + Assert.Single(manifest.Substitutions!.Filenames!); + Assert.Single(manifest.Substitutions!.Content!); + } + + #endregion + + #region Index schema compatibility + + [Fact] + public void V1_FullIndex_ParsesCorrectly() + { + var json = """ + { + "$schema": "https://aka.ms/aspire/template-index-schema/v1", + "version": 1, + "publisher": { + "name": "Aspire Team", + "url": "https://aspire.dev", + "verified": true + }, + "templates": [ + { + "name": "starter", + "description": "Starter template", + "path": "./starter", + "language": "csharp", + "tags": ["web", "api"], + "scope": ["new"] + }, + { + "name": "python-starter", + "description": "Python starter", + "path": "./python-starter", + "language": "python", + "scope": ["new"] + }, + { + "name": "init-aspire", + "description": "Add Aspire to existing", + "path": "./init", + "scope": ["init"] + } + ], + "includes": [ + { "url": "https://github.com/org/more-templates" } + ] + } + """; + + var index = JsonSerializer.Deserialize(json, GitTemplateJsonContext.Default.GitTemplateIndex); + Assert.NotNull(index); + Assert.Equal(3, index.Templates.Count); + Assert.Equal("starter", index.Templates[0].Name); + Assert.Equal("csharp", index.Templates[0].Language); + Assert.Equal(["web", "api"], index.Templates[0].Tags); + Assert.Equal(["new"], index.Templates[0].Scope); + Assert.NotNull(index.Publisher); + Assert.True(index.Publisher.Verified); + Assert.Single(index.Includes!); + } + + [Fact] + public void V1_IndexWithExternalRepo_ParsesCorrectly() + { + var json = """ + { + "version": 1, + "templates": [ + { + "name": "external-template", + "description": "Template from another repo", + "path": "scenarios/web/src", + "repo": "https://github.com/other/repo" + } + ] + } + """; + + var index = JsonSerializer.Deserialize(json, GitTemplateJsonContext.Default.GitTemplateIndex); + Assert.NotNull(index); + Assert.Equal("https://github.com/other/repo", index.Templates[0].Repo); + Assert.Equal("scenarios/web/src", index.Templates[0].Path); + } + + [Fact] + public void V1_IndexUnknownFields_Ignored() + { + var json = """ + { + "version": 1, + "templates": [ + { + "name": "test", + "path": ".", + "futureField": "ignored" + } + ], + "futureTopLevel": true + } + """; + + var index = JsonSerializer.Deserialize(json, GitTemplateJsonContext.Default.GitTemplateIndex); + Assert.NotNull(index); + Assert.Equal("test", index.Templates[0].Name); + } + + #endregion + + #region Edge cases + + [Fact] + public void EmptySubstitutions_ParsesCorrectly() + { + var json = """ + { + "version": 1, + "name": "test", + "substitutions": { + "filenames": {}, + "content": {} + } + } + """; + + var manifest = Deserialize(json); + Assert.Empty(manifest.Substitutions!.Filenames!); + Assert.Empty(manifest.Substitutions!.Content!); + } + + [Fact] + public void EmptyVariables_ParsesCorrectly() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": {} + } + """; + + var manifest = Deserialize(json); + Assert.Empty(manifest.Variables!); + } + + [Fact] + public void EmptyConditionalFiles_ParsesCorrectly() + { + var json = """ + { + "version": 1, + "name": "test", + "conditionalFiles": {} + } + """; + + var manifest = Deserialize(json); + Assert.Empty(manifest.ConditionalFiles!); + } + + [Fact] + public void ChoiceVariableWithEmptyChoices_ParsesCorrectly() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": { + "empty": { + "type": "choice", + "choices": [] + } + } + } + """; + + var manifest = Deserialize(json); + Assert.Empty(manifest.Variables!["empty"].Choices!); + } + + [Fact] + public void PostInstructionWithEmptyLines_ParsesCorrectly() + { + var json = """ + { + "version": 1, + "name": "test", + "postInstructions": [ + { + "heading": "Empty", + "lines": [] + } + ] + } + """; + + var manifest = Deserialize(json); + Assert.Empty(manifest.PostInstructions![0].Lines); + } + + [Fact] + public void VariableWithAllOptionalFieldsMissing_ParsesCorrectly() + { + var json = """ + { + "version": 1, + "name": "test", + "variables": { + "bare": { "type": "string" } + } + } + """; + + var manifest = Deserialize(json); + var v = manifest.Variables!["bare"]; + Assert.Equal("string", v.Type); + Assert.Null(v.DisplayName); + Assert.Null(v.Description); + Assert.Null(v.Required); + Assert.Null(v.DefaultValue); + Assert.Null(v.Validation); + Assert.Null(v.Choices); + Assert.Null(v.TestValues); + } + + #endregion + + private static GitTemplateManifest Deserialize(string json) + { + var manifest = JsonSerializer.Deserialize(json, GitTemplateJsonContext.Default.GitTemplateManifest); + Assert.NotNull(manifest); + return manifest; + } +} diff --git a/tests/Aspire.Cli.Tests/Templating/Git/JsonConverterTests.cs b/tests/Aspire.Cli.Tests/Templating/Git/JsonConverterTests.cs new file mode 100644 index 00000000000..0595c9425a2 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Templating/Git/JsonConverterTests.cs @@ -0,0 +1,263 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.Templating.Git; + +namespace Aspire.Cli.Tests.Templating.Git; + +public class JsonObjectConverterTests +{ + private static readonly JsonSerializerOptions s_options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonObjectConverter() } + }; + + #region Read + + [Fact] + public void Read_StringValue_ReturnsString() + { + var result = JsonSerializer.Deserialize("\"hello\"", s_options); + Assert.IsType(result); + Assert.Equal("hello", result); + } + + [Fact] + public void Read_TrueValue_ReturnsBool() + { + var result = JsonSerializer.Deserialize("true", s_options); + Assert.IsType(result); + Assert.Equal(true, result); + } + + [Fact] + public void Read_FalseValue_ReturnsBool() + { + var result = JsonSerializer.Deserialize("false", s_options); + Assert.IsType(result); + Assert.Equal(false, result); + } + + [Fact] + public void Read_IntegerValue_ReturnsInt() + { + var result = JsonSerializer.Deserialize("42", s_options); + Assert.IsType(result); + Assert.Equal(42, result); + } + + [Fact] + public void Read_NegativeInteger_ReturnsInt() + { + var result = JsonSerializer.Deserialize("-1", s_options); + Assert.IsType(result); + Assert.Equal(-1, result); + } + + [Fact] + public void Read_DoubleValue_ReturnsDouble() + { + var result = JsonSerializer.Deserialize("3.14", s_options); + Assert.IsType(result); + Assert.Equal(3.14, result); + } + + [Fact] + public void Read_NullValue_ReturnsNull() + { + var result = JsonSerializer.Deserialize("null", s_options); + Assert.Null(result); + } + + [Fact] + public void Read_EmptyString_ReturnsEmptyString() + { + var result = JsonSerializer.Deserialize("\"\"", s_options); + Assert.Equal("", result); + } + + [Fact] + public void Read_ZeroValue_ReturnsInt() + { + var result = JsonSerializer.Deserialize("0", s_options); + Assert.IsType(result); + Assert.Equal(0, result); + } + + #endregion + + #region Write + + [Fact] + public void Write_String_WritesStringJson() + { + var json = JsonSerializer.Serialize("hello", s_options); + Assert.Equal("\"hello\"", json); + } + + [Fact] + public void Write_Bool_WritesBoolJson() + { + var json = JsonSerializer.Serialize(true, s_options); + Assert.Equal("true", json); + } + + [Fact] + public void Write_Int_WritesNumberJson() + { + var json = JsonSerializer.Serialize(42, s_options); + Assert.Equal("42", json); + } + + [Fact] + public void Write_Null_WritesNull() + { + var json = JsonSerializer.Serialize(null, s_options); + Assert.Equal("null", json); + } + + #endregion +} + +public class JsonObjectListConverterTests +{ + private sealed class TestHolder + { + [System.Text.Json.Serialization.JsonConverter(typeof(JsonObjectListConverter))] + public List? Values { get; set; } + } + + private static readonly JsonSerializerOptions s_options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + #region Read + + [Fact] + public void Read_MixedArray_ParsesAllTypes() + { + var json = """{"values": ["hello", true, false, 42]}"""; + var result = JsonSerializer.Deserialize(json, s_options); + + Assert.NotNull(result?.Values); + Assert.Equal(4, result.Values.Count); + Assert.Equal("hello", result.Values[0]); + Assert.Equal(true, result.Values[1]); + Assert.Equal(false, result.Values[2]); + Assert.Equal(42, result.Values[3]); + } + + [Fact] + public void Read_EmptyArray_ReturnsEmptyList() + { + var json = """{"values": []}"""; + var result = JsonSerializer.Deserialize(json, s_options); + Assert.NotNull(result?.Values); + Assert.Empty(result.Values); + } + + [Fact] + public void Read_NullArray_ReturnsNull() + { + var json = """{"values": null}"""; + var result = JsonSerializer.Deserialize(json, s_options); + Assert.Null(result?.Values); + } + + [Fact] + public void Read_StringOnlyArray_Works() + { + var json = """{"values": ["a", "b", "c"]}"""; + var result = JsonSerializer.Deserialize(json, s_options); + + Assert.Equal(3, result!.Values!.Count); + Assert.All(result.Values, v => Assert.IsType(v)); + } + + [Fact] + public void Read_BoolOnlyArray_Works() + { + var json = """{"values": [true, false]}"""; + var result = JsonSerializer.Deserialize(json, s_options); + + Assert.Equal(2, result!.Values!.Count); + Assert.Equal(true, result.Values[0]); + Assert.Equal(false, result.Values[1]); + } + + [Fact] + public void Read_IntOnlyArray_Works() + { + var json = """{"values": [1, 2, 3]}"""; + var result = JsonSerializer.Deserialize(json, s_options); + + Assert.Equal(3, result!.Values!.Count); + Assert.All(result.Values, v => Assert.IsType(v)); + } + + [Fact] + public void Read_NullElementsInArray_SkipsNulls() + { + var json = """{"values": ["a", null, "b"]}"""; + var result = JsonSerializer.Deserialize(json, s_options); + + Assert.Equal(2, result!.Values!.Count); + Assert.Equal("a", result.Values[0]); + Assert.Equal("b", result.Values[1]); + } + + #endregion + + #region Write + + [Fact] + public void Write_MixedList_ProducesValidJson() + { + var holder = new TestHolder { Values = ["hello", true, 42] }; + var json = JsonSerializer.Serialize(holder, s_options); + + Assert.Contains("\"hello\"", json); + Assert.Contains("true", json); + Assert.Contains("42", json); + } + + [Fact] + public void Write_NullList_ProducesNull() + { + var holder = new TestHolder { Values = null }; + var json = JsonSerializer.Serialize(holder, s_options); + Assert.Contains("null", json); + } + + [Fact] + public void Write_EmptyList_ProducesEmptyArray() + { + var holder = new TestHolder { Values = [] }; + var json = JsonSerializer.Serialize(holder, s_options); + Assert.Contains("[]", json); + } + + #endregion + + #region Roundtrip + + [Fact] + public void Roundtrip_MixedValues_PreservesTypes() + { + var original = new TestHolder { Values = ["str", true, false, 100] }; + var json = JsonSerializer.Serialize(original, s_options); + var deserialized = JsonSerializer.Deserialize(json, s_options); + + Assert.NotNull(deserialized?.Values); + Assert.Equal(4, deserialized.Values.Count); + Assert.IsType(deserialized.Values[0]); + Assert.IsType(deserialized.Values[1]); + Assert.IsType(deserialized.Values[2]); + Assert.IsType(deserialized.Values[3]); + } + + #endregion +} diff --git a/tests/Aspire.Cli.Tests/Templating/Git/LocalizableStringTests.cs b/tests/Aspire.Cli.Tests/Templating/Git/LocalizableStringTests.cs new file mode 100644 index 00000000000..9135d5f32ce --- /dev/null +++ b/tests/Aspire.Cli.Tests/Templating/Git/LocalizableStringTests.cs @@ -0,0 +1,208 @@ +// 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; +using Aspire.Cli.Templating.Git; + +namespace Aspire.Cli.Tests.Templating.Git; + +public class LocalizableStringTests +{ + #region Plain string + + [Fact] + public void FromString_ResolvesOriginalValue() + { + var ls = LocalizableString.FromString("hello"); + Assert.Equal("hello", ls.Resolve()); + } + + [Fact] + public void FromString_EmptyString_ResolvesEmpty() + { + var ls = LocalizableString.FromString(""); + Assert.Equal("", ls.Resolve()); + } + + [Fact] + public void ImplicitConversion_StringToLocalizableString_Works() + { + LocalizableString ls = "implicit value"; + Assert.Equal("implicit value", ls.Resolve()); + } + + [Fact] + public void ToString_ReturnsResolvedValue() + { + var ls = LocalizableString.FromString("test"); + Assert.Equal("test", ls.ToString()); + } + + #endregion + + #region Localized object + + [Fact] + public void FromLocalizations_ExactCultureMatch_ResolvesCorrectly() + { + var ls = LocalizableString.FromLocalizations(new Dictionary + { + ["en"] = "English", + ["de"] = "Deutsch" + }); + + var prev = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("de"); + Assert.Equal("Deutsch", ls.Resolve()); + } + finally + { + CultureInfo.CurrentUICulture = prev; + } + } + + [Fact] + public void FromLocalizations_ParentCultureFallback_Works() + { + var ls = LocalizableString.FromLocalizations(new Dictionary + { + ["en"] = "English", + ["de"] = "Deutsch" + }); + + var prev = CultureInfo.CurrentUICulture; + try + { + // "en-US" should fall back to "en" + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + Assert.Equal("English", ls.Resolve()); + } + finally + { + CultureInfo.CurrentUICulture = prev; + } + } + + [Fact] + public void FromLocalizations_NoMatch_FallsBackToFirstEntry() + { + var ls = LocalizableString.FromLocalizations(new Dictionary + { + ["en"] = "English", + ["de"] = "Deutsch" + }); + + var prev = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("ja"); + // Should fall back to first entry + var result = ls.Resolve(); + Assert.True(result == "English" || result == "Deutsch", + $"Expected fallback to first entry, got '{result}'"); + } + finally + { + CultureInfo.CurrentUICulture = prev; + } + } + + [Fact] + public void FromLocalizations_EmptyDictionary_ReturnsEmpty() + { + var ls = LocalizableString.FromLocalizations([]); + Assert.Equal("", ls.Resolve()); + } + + [Fact] + public void FromLocalizations_CaseInsensitiveKeys_Works() + { + var ls = LocalizableString.FromLocalizations(new Dictionary + { + ["EN"] = "English" + }); + + var prev = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en"); + Assert.Equal("English", ls.Resolve()); + } + finally + { + CultureInfo.CurrentUICulture = prev; + } + } + + #endregion + + #region JSON deserialization + + [Fact] + public void Deserialize_PlainString_CreatesLocalizableString() + { + var json = """{"displayName": "My Template"}"""; + var result = JsonSerializer.Deserialize(json, s_jsonOptions); + Assert.NotNull(result?.DisplayName); + Assert.Equal("My Template", result.DisplayName.Resolve()); + } + + [Fact] + public void Deserialize_LocalizedObject_CreatesLocalizableString() + { + var json = """{"displayName": {"en": "English Name", "de": "Deutscher Name"}}"""; + var result = JsonSerializer.Deserialize(json, s_jsonOptions); + + Assert.NotNull(result?.DisplayName); + + var prev = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("de"); + Assert.Equal("Deutscher Name", result.DisplayName.Resolve()); + } + finally + { + CultureInfo.CurrentUICulture = prev; + } + } + + [Fact] + public void Deserialize_NullValue_ReturnsNull() + { + var json = """{"displayName": null}"""; + var result = JsonSerializer.Deserialize(json, s_jsonOptions); + Assert.Null(result?.DisplayName); + } + + [Fact] + public void Deserialize_MissingField_ReturnsNull() + { + var json = """{}"""; + var result = JsonSerializer.Deserialize(json, s_jsonOptions); + Assert.Null(result?.DisplayName); + } + + [Fact] + public void Serialize_PlainString_WritesString() + { + var holder = new TestLocalizableHolder { DisplayName = "Test" }; + var json = JsonSerializer.Serialize(holder, s_jsonOptions); + Assert.Contains("\"Test\"", json); + } + + private sealed class TestLocalizableHolder + { + public LocalizableString? DisplayName { get; set; } + } + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + #endregion +} diff --git a/tests/Aspire.Cli.Tests/Templating/Git/TemplateExpressionEvaluatorTests.cs b/tests/Aspire.Cli.Tests/Templating/Git/TemplateExpressionEvaluatorTests.cs new file mode 100644 index 00000000000..59a58e23b1c --- /dev/null +++ b/tests/Aspire.Cli.Tests/Templating/Git/TemplateExpressionEvaluatorTests.cs @@ -0,0 +1,265 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Templating.Git; + +namespace Aspire.Cli.Tests.Templating.Git; + +public class TemplateExpressionEvaluatorTests +{ + #region Basic substitution + + [Fact] + public void Evaluate_SimpleVariable_SubstitutesValue() + { + var variables = new Dictionary { ["name"] = "MyApp" }; + var result = TemplateExpressionEvaluator.Evaluate("Hello {{name}}", variables); + Assert.Equal("Hello MyApp", result); + } + + [Fact] + public void Evaluate_MultipleVariables_SubstitutesAll() + { + var variables = new Dictionary + { + ["first"] = "Hello", + ["second"] = "World" + }; + var result = TemplateExpressionEvaluator.Evaluate("{{first}} {{second}}", variables); + Assert.Equal("Hello World", result); + } + + [Fact] + public void Evaluate_SameVariableMultipleTimes_SubstitutesAll() + { + var variables = new Dictionary { ["name"] = "App" }; + var result = TemplateExpressionEvaluator.Evaluate("{{name}}.AppHost/{{name}}.csproj", variables); + Assert.Equal("App.AppHost/App.csproj", result); + } + + [Fact] + public void Evaluate_UnresolvedVariable_LeftAsIs() + { + var variables = new Dictionary { ["name"] = "MyApp" }; + var result = TemplateExpressionEvaluator.Evaluate("{{name}} {{unknown}}", variables); + Assert.Equal("MyApp {{unknown}}", result); + } + + [Fact] + public void Evaluate_NoExpressions_ReturnsInputUnchanged() + { + var variables = new Dictionary { ["name"] = "MyApp" }; + var result = TemplateExpressionEvaluator.Evaluate("plain text without expressions", variables); + Assert.Equal("plain text without expressions", result); + } + + [Fact] + public void Evaluate_EmptyInput_ReturnsEmpty() + { + var variables = new Dictionary { ["name"] = "MyApp" }; + var result = TemplateExpressionEvaluator.Evaluate("", variables); + Assert.Equal("", result); + } + + [Fact] + public void Evaluate_EmptyVariableValue_SubstitutesEmpty() + { + var variables = new Dictionary { ["name"] = "" }; + var result = TemplateExpressionEvaluator.Evaluate("prefix-{{name}}-suffix", variables); + Assert.Equal("prefix--suffix", result); + } + + [Fact] + public void Evaluate_VariableWithWhitespaceInExpression_TrimsAndResolves() + { + var variables = new Dictionary { ["name"] = "MyApp" }; + var result = TemplateExpressionEvaluator.Evaluate("{{ name }}", variables); + Assert.Equal("MyApp", result); + } + + [Fact] + public void Evaluate_EmptyVariables_LeavesExpressionsUnchanged() + { + var variables = new Dictionary(); + var result = TemplateExpressionEvaluator.Evaluate("{{name}}", variables); + Assert.Equal("{{name}}", result); + } + + [Fact] + public void Evaluate_AdjacentExpressions_SubstitutesAll() + { + var variables = new Dictionary + { + ["a"] = "X", + ["b"] = "Y" + }; + var result = TemplateExpressionEvaluator.Evaluate("{{a}}{{b}}", variables); + Assert.Equal("XY", result); + } + + #endregion + + #region Filters + + [Theory] + [InlineData("lowercase", "MyApp", "myapp")] + [InlineData("uppercase", "MyApp", "MYAPP")] + [InlineData("kebabcase", "MyApp", "my-app")] + [InlineData("snakecase", "MyApp", "my_app")] + [InlineData("camelcase", "MyApp", "myApp")] + [InlineData("pascalcase", "my-app", "MyApp")] + public void Evaluate_WithFilter_AppliesTransformation(string filter, string inputValue, string expected) + { + var variables = new Dictionary { ["name"] = inputValue }; + var result = TemplateExpressionEvaluator.Evaluate($"{{{{name | {filter}}}}}", variables); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("MyAppName", "my-app-name")] + [InlineData("myAppName", "my-app-name")] + [InlineData("HTTPServer", "http-server")] + [InlineData("SimpleApp", "simple-app")] + [InlineData("ABC", "abc")] // all-uppercase → single word + [InlineData("a", "a")] + [InlineData("AB", "ab")] // all-uppercase short word stays together + public void Evaluate_KebabCase_SplitsWordsCorrectly(string inputValue, string expected) + { + var variables = new Dictionary { ["name"] = inputValue }; + var result = TemplateExpressionEvaluator.Evaluate("{{name | kebabcase}}", variables); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("my-app-name", "my_app_name")] + [InlineData("MyAppName", "my_app_name")] + [InlineData("myApp", "my_app")] + public void Evaluate_SnakeCase_SplitsWordsCorrectly(string inputValue, string expected) + { + var variables = new Dictionary { ["name"] = inputValue }; + var result = TemplateExpressionEvaluator.Evaluate("{{name | snakecase}}", variables); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("my-app-name", "myAppName")] + [InlineData("MyAppName", "myAppName")] + [InlineData("MY_APP", "mYApp")] // word split: M, Y, APP → m + Y + App + public void Evaluate_CamelCase_SplitsWordsCorrectly(string inputValue, string expected) + { + var variables = new Dictionary { ["name"] = inputValue }; + var result = TemplateExpressionEvaluator.Evaluate("{{name | camelcase}}", variables); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("my-app-name", "MyAppName")] + [InlineData("myApp", "MyApp")] + [InlineData("my_app_name", "MyAppName")] + public void Evaluate_PascalCase_SplitsWordsCorrectly(string inputValue, string expected) + { + var variables = new Dictionary { ["name"] = inputValue }; + var result = TemplateExpressionEvaluator.Evaluate("{{name | pascalcase}}", variables); + Assert.Equal(expected, result); + } + + [Fact] + public void Evaluate_FilterWithWhitespace_TrimsAndApplies() + { + var variables = new Dictionary { ["name"] = "MyApp" }; + var result = TemplateExpressionEvaluator.Evaluate("{{ name | lowercase }}", variables); + Assert.Equal("myapp", result); + } + + [Fact] + public void Evaluate_UnknownFilter_ReturnsValueUnchanged() + { + var variables = new Dictionary { ["name"] = "MyApp" }; + var result = TemplateExpressionEvaluator.Evaluate("{{name | nonexistentfilter}}", variables); + Assert.Equal("MyApp", result); + } + + [Fact] + public void Evaluate_FilterCaseInsensitive_Works() + { + var variables = new Dictionary { ["name"] = "MyApp" }; + var result = TemplateExpressionEvaluator.Evaluate("{{name | LOWERCASE}}", variables); + Assert.Equal("myapp", result); + } + + [Fact] + public void Evaluate_MixedFilteredAndUnfiltered_Works() + { + var variables = new Dictionary { ["name"] = "MyApp" }; + var result = TemplateExpressionEvaluator.Evaluate("{{name}} and {{name | lowercase}}", variables); + Assert.Equal("MyApp and myapp", result); + } + + [Fact] + public void Evaluate_FilterOnUnresolvedVariable_LeavesExpressionAsIs() + { + var variables = new Dictionary(); + var result = TemplateExpressionEvaluator.Evaluate("{{missing | lowercase}}", variables); + Assert.Equal("{{missing | lowercase}}", result); + } + + #endregion + + #region Special characters and edge cases + + [Fact] + public void Evaluate_VariableValueContainsBraces_DoesNotRecurse() + { + var variables = new Dictionary { ["name"] = "{{other}}" }; + var result = TemplateExpressionEvaluator.Evaluate("{{name}}", variables); + Assert.Equal("{{other}}", result); + } + + [Fact] + public void Evaluate_VariableValueContainsSpecialChars_PreservesValue() + { + var variables = new Dictionary { ["path"] = "C:\\Users\\test\\file.txt" }; + var result = TemplateExpressionEvaluator.Evaluate("Path: {{path}}", variables); + Assert.Equal("Path: C:\\Users\\test\\file.txt", result); + } + + [Fact] + public void Evaluate_SingleBracePair_NotMatched() + { + var variables = new Dictionary { ["name"] = "MyApp" }; + var result = TemplateExpressionEvaluator.Evaluate("{name}", variables); + Assert.Equal("{name}", result); + } + + [Fact] + public void Evaluate_TripleBraces_RegexMatchesDifferently() + { + var variables = new Dictionary { ["name"] = "MyApp" }; + var result = TemplateExpressionEvaluator.Evaluate("{{{name}}}", variables); + // The regex {{(.+?)}} matches from pos 1: {{name}} with capture "name" + // but the greedy first {{ at pos 0 captures "{name" which is unresolved + // So the expression is left unchanged + Assert.Equal("{{{name}}}", result); + } + + [Fact] + public void Evaluate_MultilineInput_SubstitutesAcrossLines() + { + var variables = new Dictionary { ["name"] = "MyApp" }; + var input = "line1 {{name}}\nline2 {{name}}\nline3"; + var result = TemplateExpressionEvaluator.Evaluate(input, variables); + Assert.Equal("line1 MyApp\nline2 MyApp\nline3", result); + } + + [Theory] + [InlineData("word123", "word-123")] + [InlineData("test42value", "test-42-value")] + public void Evaluate_KebabCaseWithNumbers_SplitsCorrectly(string inputValue, string expected) + { + var variables = new Dictionary { ["name"] = inputValue }; + var result = TemplateExpressionEvaluator.Evaluate("{{name | kebabcase}}", variables); + Assert.Equal(expected, result); + } + + #endregion +}