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/docs/specs/git-templates.md b/docs/specs/git-templates.md new file mode 100644 index 00000000000..0133b2f9efa --- /dev/null +++ b/docs/specs/git-templates.md @@ -0,0 +1,1426 @@ +# Git-Based Template System for Aspire + +**Status:** Draft +**Authors:** Aspire CLI Team +**Last Updated:** 2026-03-02 + +## 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`: + +```text +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[].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 | +| `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", + "scope": ["new"], + "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." + ], + "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" + ] + } + ] +} +``` + +### 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 \| 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 | +| `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 + +| 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` | + +### 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: + +| 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. + +### 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). + +### 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: + +```text +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 + +```text + ┌──────────────────────┐ + │ 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: + +```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) + - 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 + +```text +~/.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: + +```text +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. + +```text +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 +└── test [path] Test a template by generating all variable combinations +``` + +#### `aspire template list` + +Lists all available templates from all configured sources, grouped by source: + +```text +$ 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: + +```text +$ 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: + +```text +$ 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: + +```text +$ 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: + +```text +$ 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. +``` + +#### `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` + +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: + 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. + +##### 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 + +| 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 + +```text +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 + +```text +templates/aspire-ts-starter/ +├── aspire-template.json +├── apphost.ts +├── package.json +├── tsconfig.json +└── services/ + └── api/ + ├── index.ts + └── package.json +``` + +### Python Template Example + +```text +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:** +```text +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:** +```text +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:** +```text +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:** +```text +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:** +```text +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. diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 1b1148075fc..57d7fd7730c 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, + Template.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/TemplateCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateCommand.cs new file mode 100644 index 00000000000..5388b5c5be5 --- /dev/null +++ b/src/Aspire.Cli/Commands/Template/TemplateCommand.cs @@ -0,0 +1,46 @@ +// 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, + TemplateTestCommand testCommand, + 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); + Subcommands.Add(testCommand); + } + + 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..94c41f6fe52 --- /dev/null +++ b/src/Aspire.Cli/Commands/Template/TemplateListCommand.cs @@ -0,0 +1,70 @@ +// 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.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, + IInteractionService interactionService, + AspireCliTelemetry 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"); + + 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) + { + InteractionService.DisplayMessage(KnownEmojis.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/TemplateNewIndexCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs new file mode 100644 index 00000000000..ac2f156e4b3 --- /dev/null +++ b/src/Aspire.Cli/Commands/Template/TemplateNewIndexCommand.cs @@ -0,0 +1,78 @@ +// 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.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; + +internal sealed class TemplateNewIndexCommand : BaseCommand +{ + 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 bool UpdateNotificationsEnabled => false; + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + 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(KnownEmojis.Information, "Cancelled."); + return ExitCodeConstants.Success; + } + } + + var index = new GitTemplateIndex + { + Schema = "https://aka.ms/aspire/template-index-schema/v1", + Templates = + [ + new GitTemplateIndexEntry + { + Name = "my-template", + 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(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 new file mode 100644 index 00000000000..5754de371c2 --- /dev/null +++ b/src/Aspire.Cli/Commands/Template/TemplateNewManifestCommand.cs @@ -0,0 +1,117 @@ +// 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.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 partial class TemplateNewManifestCommand : BaseCommand +{ + 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 bool UpdateNotificationsEnabled => false; + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + 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(KnownEmojis.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 canonicalName = ToPascalCase(name); + + var manifest = new GitTemplateManifest + { + Schema = "https://aka.ms/aspire/template-schema/v1", + Name = name, + Variables = new Dictionary + { + ["projectName"] = new() + { + Type = "string", + Required = true, + DefaultValue = canonicalName, + Validation = new GitTemplateVariableValidation + { + Pattern = "^[A-Za-z][A-Za-z0-9_.]*$", + } + } + }, + Substitutions = new GitTemplateSubstitutions + { + Filenames = new Dictionary + { + [canonicalName] = "{{projectName}}" + }, + Content = new Dictionary + { + [canonicalName] = "{{projectName}}", + [canonicalName.ToLowerInvariant()] = "{{projectName | lowercase}}" + } + } + }; + + 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/Commands/Template/TemplateRefreshCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs new file mode 100644 index 00000000000..a123c7a129f --- /dev/null +++ b/src/Aspire.Cli/Commands/Template/TemplateRefreshCommand.cs @@ -0,0 +1,48 @@ +// 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.Templating.Git; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Commands.Template; + +internal sealed class TemplateRefreshCommand( + IGitTemplateIndexService indexService, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + IInteractionService interactionService, + AspireCliTelemetry 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"); + + 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 new file mode 100644 index 00000000000..22ec7a361d1 --- /dev/null +++ b/src/Aspire.Cli/Commands/Template/TemplateSearchCommand.cs @@ -0,0 +1,101 @@ +// 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.Templating.Git; +using Aspire.Cli.Utils; +using Spectre.Console; + +namespace Aspire.Cli.Commands.Template; + +internal sealed class TemplateSearchCommand : BaseCommand +{ + private static readonly Argument s_keywordArgument = new("keyword") + { + Description = "Search keyword to filter templates by name or tags" + }; + + private readonly IGitTemplateIndexService _indexService; + + public TemplateSearchCommand( + IGitTemplateIndexService indexService, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + IInteractionService interactionService, + AspireCliTelemetry telemetry) + : base("search", "Search templates by keyword", features, updateNotifier, executionContext, interactionService, telemetry) + { + _indexService = indexService; + Arguments.Add(s_keywordArgument); + } + + protected override bool UpdateNotificationsEnabled => false; + + 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); + + 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) + { + InteractionService.DisplayMessage(KnownEmojis.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) + { + 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/Commands/Template/TemplateTestCommand.cs b/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs new file mode 100644 index 00000000000..a259b27f082 --- /dev/null +++ b/src/Aspire.Cli/Commands/Template/TemplateTestCommand.cs @@ -0,0 +1,607 @@ +// 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 : BaseCommand +{ + private static readonly Argument s_pathArgument = new("path") + { + 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") + { + Description = "Base directory for generated variant projects (defaults to current directory)" + }; + + private static readonly Option s_nameOption = new("--name") + { + Description = "Template name to select from a local index or the remote template index" + }; + + 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; + private readonly IGitTemplateIndexService _indexService; + + public TemplateTestCommand( + IGitTemplateEngine engine, + IGitTemplateIndexService indexService, + 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; + _indexService = indexService; + Arguments.Add(s_pathArgument); + Options.Add(s_outputOption); + Options.Add(s_nameOption); + Options.Add(s_dryRunOption); + Options.Add(s_jsonOption); + } + + protected override bool UpdateNotificationsEnabled => false; + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + 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 output directory — default to cwd + outputBase = outputBase is not null ? Path.GetFullPath(outputBase) : Directory.GetCurrentDirectory(); + + // Resolve the template directory and manifest + string? tempDir = null; + string templateDir; + GitTemplateManifest? manifest; + + try + { + 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")); + + if (hasLocalManifest || hasLocalIndex) + { + (templateDir, manifest) = await ResolveLocalTemplateAsync(cwd, templateName, cancellationToken); + } + else + { + // Select from the remote template index + (templateDir, manifest, tempDir) = await ResolveFromIndexAsync(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 = $"{manifest.Name}{i}"; + var outputDir = Path.Combine(outputBase, dirName); + var projectName = $"{manifest.Name}{i}"; + + 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); + RandomizePorts(outputDir, i); + } + 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; + } + 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)> ResolveLocalTemplateAsync( + 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 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(); + 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; + } + + /// + /// 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(); + 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/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/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..f496ca77d94 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(); @@ -402,6 +403,24 @@ 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(); + + // 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.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHttpClient("git-templates"); builder.Services.AddTransient(); #if DEBUG builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Templating/Git/GitTemplate.cs b/src/Aspire.Cli/Templating/Git/GitTemplate.cs new file mode 100644 index 00000000000..84ce0a91073 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplate.cs @@ -0,0 +1,683 @@ +// 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.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; + +/// +/// 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 TemplateRuntime Runtime => TemplateRuntime.Cli; + + public Func PathDeriver => name => name; + + public bool SupportsLanguage(string languageId) => true; + + public IReadOnlyList SelectableAppHostLanguages => []; + + public void ApplyOptions(Command command) + { + // 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( + TemplateInputs inputs, + ParseResult parseResult, + CancellationToken cancellationToken) + { + // 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) + 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")); + + 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; + } + + // 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; + } + } + + // Apply the template + 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 + { + // Clean up temp directory + try + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + catch + { + // Best-effort cleanup. + } + } + } + + /// + /// 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()) + { + 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); + } + } + + /// + /// 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; + 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); + + 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 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") + { + 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/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/GitTemplateEngine.cs b/src/Aspire.Cli/Templating/Git/GitTemplateEngine.cs new file mode 100644 index 00000000000..62799a94cfd --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateEngine.cs @@ -0,0 +1,385 @@ +// 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; + } + + 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/GitTemplateFactory.cs b/src/Aspire.Cli/Templating/Git/GitTemplateFactory.cs new file mode 100644 index 00000000000..9b44d74ce73 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateFactory.cs @@ -0,0 +1,64 @@ +// 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 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) + { + 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, + _interactionService, + _httpClientFactory, + _templateLogger); + } + } +} diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateIndex.cs b/src/Aspire.Cli/Templating/Git/GitTemplateIndex.cs new file mode 100644 index 00000000000..589f4b67004 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateIndex.cs @@ -0,0 +1,63 @@ +// 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 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 List? Scope { get; set; } +} + +/// +/// A reference to another template index (federation). +/// +internal sealed class GitTemplateIndexInclude +{ + public required string Url { get; set; } +} diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs b/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs new file mode 100644 index 00000000000..856917f3f40 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateIndexService.cs @@ -0,0 +1,370 @@ +// 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 Aspire.Cli.GitHub; +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 CommunityDefaultRef = "HEAD"; + 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; + } + + 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 isLocal = IsLocalPath(source.Repo); + var index = (forceRefresh || isLocal) ? 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; + } + + if (!isLocal) + { + _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) + { + // 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 ?? DefaultRefForSource(source), IndexFileName); + + if (rawUrl is null) + { + _logger.LogWarning("Cannot build raw URL for {Repo}. Only GitHub URLs and local paths 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 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(); + + // 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 + }); + } + } + + // 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. + /// + 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; + } + + /// + /// 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; + } +} 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..4aae727ed9e --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateManifest.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.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 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; } + + 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; } +} diff --git a/src/Aspire.Cli/Templating/Git/GitTemplateSource.cs b/src/Aspire.Cli/Templating/Git/GitTemplateSource.cs new file mode 100644 index 00000000000..1e9b325143b --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateSource.cs @@ -0,0 +1,49 @@ +// 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, + + /// + /// Auto-discovered from the authenticated user's personal aspire-templates repo. + /// + Personal, + + /// + /// Auto-discovered from a GitHub organization's aspire-templates repo. + /// + Organization +} + +/// +/// 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/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..c0070541b92 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/GitTemplateVariable.cs @@ -0,0 +1,154 @@ +// 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 Type { get; set; } + + public LocalizableString? DisplayName { get; set; } + + public LocalizableString? Description { 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; } + + /// + /// 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; } +} + +/// +/// 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 LocalizableString? DisplayName { get; set; } + + public LocalizableString? 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; + } + } +} + +/// +/// 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/src/Aspire.Cli/Templating/Git/IGitTemplateEngine.cs b/src/Aspire.Cli/Templating/Git/IGitTemplateEngine.cs new file mode 100644 index 00000000000..e9b02bf40d7 --- /dev/null +++ b/src/Aspire.Cli/Templating/Git/IGitTemplateEngine.cs @@ -0,0 +1,28 @@ +// 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); + + /// + /// 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); +} 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; +} 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()); + } +} 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(); +} 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 +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 305d4ff4781..15c2dab9415 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -209,6 +209,17 @@ 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.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