feat(qqq): add TemplateRegistry abstraction for Voyage mock API

Story #23 - prepares for Voyage API integration:
- TemplateRegistry interface for template listing/download
- GitHubTemplateRegistry wraps existing TemplatesHub
- MockTemplateRegistry with embedded template data
- ListCommand/InitCommand refactored to use registry
- Added --refresh flag to bypass cache
This commit is contained in:
james.maes committed 2026-01-03 20:52:43 -06:00
1 parent 5a967f072f
commit e14565a277
11 files changed
+1078 -85

No files matched your search

+174 -42
View File
@@ -1,78 +1,210 @@
# Session State
Last updated: 2025-12-31
Last updated: 2026-01-03
## Current Status
**CI/CD pipeline is fully operational.** All workflows tested and passing.
**Stories #18-23 complete.** Velocity template engine, computed variables, transforms, manifest v2, templates-hub migration, and Voyage mock API integration all implemented. Ready for Story #24 (prompt validation).
## Recently Completed
### CI/CD Workflow Overhaul (2025-12-31)
### Story #23 - Voyage Mock API Integration (2026-01-03)
Fixed broken release workflow with multiple issues:
Implemented TemplateRegistry abstraction for template management:
- Created `TemplateRegistry` interface with list/get/download/refresh methods
- Created `TemplateInfo` record for template metadata
- Created `MockTemplateRegistry` with embedded template data
- Created `GitHubTemplateRegistry` wrapping existing TemplatesHub
- Created `TemplateRegistryFactory` for registry selection via env var
- Refactored `ListCommand` to use TemplateRegistry with `--refresh` flag
- Refactored `InitCommand` to use TemplateRegistry
- Added 13 unit tests for registry classes
- Added native-image reflection config for TemplateInfo
1. **Native profile** - Moved from main build to `-Pnative` profile in `qctl-cli/pom.xml`
2. **macOS runner** - Updated from retired `macos-13` to `macos-15`
3. **Artifact handling** - Fixed download paths and flattening for release
4. **Docker build** - Fixed binary download to use explicit names instead of patterns
5. **Package updates** - Split into separate jobs:
- Homebrew: Direct push to `QRun-IO/homebrew-qctl` tap
- Scoop/AUR: Create PRs (avoids branch protection issues)
6. **Binary leak fix** - Added cleanup and `add-paths` to prevent committing downloaded artifacts
### Story #22 - Migrate templates-hub to Velocity (2026-01-03)
### Releases
Completed migration of new-qqq-application-template:
- Restructured template repo with `template/` subdirectory
- Converted all Java files to Velocity syntax (`$variable`, `${variable}`)
- Fixed pom.xml with literal blocks (`#[[${maven.prop}]]#`) for Maven properties
- Added native-image reflection config for Velocity runtime classes
- Implemented verbatim file handling for shell scripts (mvnw, gradlew)
- Added Maven property detection to skip validation for `${prop.name}` patterns
- All changes pushed to both template repo and templates-hub
- `v0.0.1` - Initial release (failed due to workflow bugs)
- `v0.0.2` - Successful release with all 5 platform binaries
### Story #21 - Template Manifest Schema v2 (2026-01-03)
Updated manifest schema to v2 with backward compatibility:
- Added `schemaVersion` field (Integer) to TemplateManifest and TemplateEntry
- Added `minimumQctlVersion` field with version check in InitCommand
- Added `required` field (Boolean) to Prompt record
- Added `phase` field (String) to PostGenHook record
- Added `getEffectiveSchemaVersion()` helper method (defaults to 1)
- Added `isVersionSatisfied()` and `parseVersion()` helpers in InitCommand
- Updated TemplateEngineTest constructor calls (9 → 11 params)
Simplified scope (per user agreement):
- Deferred `prompts[].validation` to Story #24
- Deferred conditional fields to future story
### Story #20 - Directory Transforms (2026-01-03)
Implemented post-render file transforms for template processing:
- Created `Transform.java` record with type, pattern, replacement fields
- Created `TransformExecutor.java` for rename/delete operations using glob patterns
- Added `transforms` field to `TemplateManifest` and `TemplatesHub.TemplateEntry`
- Integrated transform execution in `InitCommand.java` after rendering
- Added 13 tests for TransformExecutor
Simplified scope (per user agreement):
- Only `rename` and `delete` transforms (dropped `move` as redundant with path variables)
- Glob pattern matching with `${varName}` substitution in replacement strings
Manifest schema now supports:
```yaml
transforms:
- type: rename
pattern: "**/*Template*.java"
replacement: "${appClassName}.java"
- type: delete
pattern: "**/.gitkeep"
```
### Story #19 - Computed Variables (2026-01-04)
Implemented computed variable support for template manifests:
- Added `replace()` method to `StringTool.java` for string substitution
- Created `ComputedVariable.java` record for manifest definitions
- Added `computed` field to `TemplateManifest` and `TemplatesHub.TemplateEntry`
- Added `evaluateComputed()` method to `TemplateEngine.java`
- Updated `InitCommand.java` to evaluate computed vars after prompts
- Added 16 new tests (9 ComputedVariable + 7 StringTool.replace)
Manifest schema now supports:
```yaml
computed:
- name: packagePath
expression: "$str.replace($packageName, '.', '/')"
- name: appClassName
expression: "$str.pascal($appName)"
```
### Story #18 - Velocity Engine (2026-01-04)
Implemented Apache Velocity template engine replacing Handlebars:
- Created `StringTool.java` with transformation methods ($str.camel(), etc.)
- Created `TemplateRenderException.java` for fail-fast error handling
- Refactored `TemplateEngine.java` to use VelocityEngine
- Added 51 unit tests (15 new + 36 existing)
- Updated native-image reflect-config.json for Velocity classes
### Epic #13-17 Story Details (2026-01-04)
Fleshed out all remaining stories for Epics #13-17:
- **Epic #13** (qrun Build & Publish): #36-42 - Jib integration, OCI packaging, publishing, labels, digest verification, release channels, rollback
- **Epic #14** (qrun Service Operations): #43-49 - Status, logs (SSE streaming), stop, start, restart, deployment history, scale
- **Epic #15** (qstudio AI Planning): #50-53 - Code indexing, plan generation (multi-LLM), path policies, plan ledger
- **Epic #16** (Authentication & Security): #54-57 - OIDC device flow, service accounts, signature verification (Sigstore/PGP), OS keychain integration
- **Epic #17** (Enterprise Features): #58-61 - SSO (SAML/OIDC), RBAC, audit logging, org/team management
### Epic #12 Story Details (2026-01-04)
Fleshed out all 9 stories for Epic #12 (qqq - Template System) with:
- User stories and design specs
- Architecture diagrams (current vs target state)
- Files to create/modify tables
- Implementation task checklists
- Acceptance criteria
Stories updated: #18-26 (Velocity, computed vars, transforms, manifest v2, templates migration, Voyage mock, validation, errors, merge mode)
### Epic #11 Story Details (2026-01-04)
Fleshed out all 9 stories for Epic #11 (qbit - Package Management) with:
- User stories and design specs
- Command interfaces with examples
- Files to create/modify tables
- Implementation task checklists
- Acceptance criteria
Stories updated: #27-35 (search, add, remove, update, list, SemVer, conflicts, vendor, integrity)
### Roadmap & Issue Creation (2026-01-03)
Created comprehensive roadmap and GitHub project structure:
1. **Roadmap document** - `docs/ROADMAP.md` with user journey focus
2. **Milestones** - v0.2.0, v0.3.0, v1.0.0
3. **Epics** - 7 epic issues (#11-#17) with proper labels
4. **Stories** - 44 story issues (#18-#61) linked as sub-issues
5. **Issue types** - Epic for #11-17, Feature for #18-61
6. **Sub-issues** - All stories linked to parent epics
### Epic Summary
| Epic | Milestone | Stories |
|------|-----------|---------|
| #11 qbit - Package Management | v0.2.0 | #27-35 (9) |
| #12 qqq - Template System | v0.2.0 | #18-26 (9) |
| #13 qrun - Build & Publish | v0.3.0 | #36-42 (7) |
| #14 qrun - Service Operations | v0.3.0 | #43-49 (7) |
| #15 qstudio - AI Planning | v1.0.0 | #50-53 (4) |
| #16 Authentication & Security | v1.0.0 | #54-57 (4) |
| #17 Enterprise Features | v1.0.0 | #58-61 (4) |
### Key Design Decisions
- **Template engine**: Apache Velocity (not Handlebars) for QQQ consistency
- **Voyage integration**: Mock APIs for now, real integration when ready
- **User journey focus**: Roadmap organized by concrete use cases
## Current Branch State
- **Branch**: `develop`
- **Last commit**: `334133a fix: remove accidentally committed binary artifacts`
- **Clean**: Yes (no uncommitted changes)
## Infrastructure
### GitHub Secrets Configured
- `HOMEBREW_TAP_TOKEN` - PAT for homebrew tap push
### External Repositories
- `QRun-IO/homebrew-qctl` - Homebrew tap (auto-updated on release)
- `QRun-IO/templates-hub` - Template repository
### Docker Registry
- `ghcr.io/qrun-io/qctl` - Multi-arch images (amd64, arm64)
## Next Steps / TODO
### Immediate
- None - CI/CD is stable
### v0.2.0 - Create & Manage (Priority)
### Short-term
- Continue V1 feature implementation per `docs/internal/prompt-resume.md`
- Implement remaining qrun/qbit commands
**Epic #12 - qqq Template System** (in progress):
1. ~~#18 - Replace Handlebars with Apache Velocity~~ DONE
2. ~~#19 - Add computed variables support~~ DONE
3. ~~#20 - Implement directory transforms~~ DONE
4. ~~#21 - Update template manifest schema v2~~ DONE
5. ~~#22 - Migrate templates-hub to Velocity~~ DONE
6. ~~#23 - Integrate template listing with Voyage mock~~ DONE
7. #24 - Add prompt validation (next)
8. #25 - Improve error messages
9. #26 - Implement --merge mode
### Long-term
- Real OCI registry integration
- OIDC device flow authentication
- Plugin enforcement
**Epic #11 - qbit Package Management** (after qqq):
1. #27 - qbit search command
2. #28 - qbit add with version constraints
3. #29 - qbit remove command
4. #30 - qbit update command
5. #31 - qbit list command
6. #32 - SemVer constraint resolution
7. #33 - Dependency conflict detection
8. #34 - Vendor directory layout
9. #35 - SHA-512 integrity verification
### Infrastructure
- GitHub project: https://github.com/orgs/QRun-IO/projects/12
## How to Continue
When starting a new session, say "continue from last session" and Claude will:
1. Read this file for context
2. Check git status for any pending work
3. Review `docs/internal/prompt-resume.md` for V1 implementation tasks
4. Resume where left off
3. Review GitHub project for current sprint
## Key Files
| File | Purpose |
|------|---------|
| `CLAUDE.md` | Project overview and build commands |
| `docs/ROADMAP.md` | User journey roadmap |
| `docs/internal/session-state.md` | This file - session continuity |
| `docs/internal/prompt-resume.md` | V1 implementation tasks |
| `docs/internal/checklist.md` | Design decisions checklist |
| `.github/workflows/build.yml` | CI/CD pipeline |
| `qctl-cli/pom.xml` | Native build profile |
@@ -20,14 +20,15 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import io.qrun.qctl.qqq.registry.TemplateInfo;
import io.qrun.qctl.qqq.registry.TemplateRegistry;
import io.qrun.qctl.qqq.registry.TemplateRegistryFactory;
import io.qrun.qctl.qqq.template.ConsoleUI;
import io.qrun.qctl.qqq.template.PostGenHookRunner;
import io.qrun.qctl.qqq.template.PromptRunner;
import io.qrun.qctl.qqq.template.TemplateEngine;
import io.qrun.qctl.qqq.template.TemplateManifest;
import io.qrun.qctl.qqq.template.TemplateRenderException;
import io.qrun.qctl.qqq.template.TemplateResolver;
import io.qrun.qctl.qqq.template.TemplatesHub;
import io.qrun.qctl.qqq.template.TransformExecutor;
import io.qrun.qctl.shared.ExitCodes;
import picocli.CommandLine.Command;
@@ -102,11 +103,9 @@ public class InitCommand implements Runnable
ui.subtitle("Initialize a new QQQ project");
ui.println();
// Step 1: Template selection
TemplatesHub hub = new TemplatesHub();
TemplatesHub.TemplateEntry hubEntry = selectTemplate(hub, ui);
TemplatesHub.TemplatesIndex index = hub.fetchIndex();
String gitSource = hubEntry.getGitUrl(index.registry());
// Step 1: Template selection from registry
TemplateRegistry registry = TemplateRegistryFactory.getRegistry();
TemplateInfo templateInfo = selectTemplate(registry, ui);
// Step 2: Get target directory
Path effectiveTargetDir = getTargetDirectory(ui);
@@ -119,26 +118,13 @@ public class InitCommand implements Runnable
System.exit(ExitCodes.CONFLICT);
}
// Step 3: Resolve template
// Step 3: Download template from registry
ui.println();
ui.info("Resolving template...");
TemplateResolver resolver = new TemplateResolver();
Path templatePath = resolver.resolve(gitSource, version);
ui.info("Downloading template...");
Path templatePath = registry.downloadTemplate(templateInfo.id(), version);
// Get manifest from hub entry
TemplateManifest manifest = new TemplateManifest(
hubEntry.schemaVersion(),
hubEntry.id(),
hubEntry.name(),
hubEntry.version(),
hubEntry.description(),
hubEntry.minimumQctlVersion(),
hubEntry.prompts(),
hubEntry.computed(),
hubEntry.transforms(),
hubEntry.postGen(),
hubEntry.ignore()
);
// Get manifest from template info
TemplateManifest manifest = templateInfo.toManifest();
// Check minimum qctl version
if(manifest.minimumQctlVersion() != null)
@@ -231,21 +217,21 @@ public class InitCommand implements Runnable
/***************************************************************************
* Select template - use provided name or prompt interactively.
*
* @param hub templates hub
* @param registry template registry
* @param ui console UI
* @return selected template entry
* @throws IOException if hub cannot be fetched
* @return selected template info
* @throws IOException if registry cannot be fetched
* @since 0.1.0
***************************************************************************/
private TemplatesHub.TemplateEntry selectTemplate(TemplatesHub hub, ConsoleUI ui) throws IOException
private TemplateInfo selectTemplate(TemplateRegistry registry, ConsoleUI ui) throws IOException
{
List<TemplatesHub.TemplateEntry> templates = hub.fetchIndex().templates();
List<TemplateInfo> templates = registry.listTemplates();
// If template name provided and found, use it
Optional<TemplatesHub.TemplateEntry> entry = hub.findById(templateName);
Optional<TemplateInfo> entry = registry.getTemplate(templateName);
if(entry.isPresent())
{
TemplatesHub.TemplateEntry selected = entry.get();
TemplateInfo selected = entry.get();
ui.showValue("Template", selected.name(), selected.id());
return selected;
}
@@ -268,8 +254,8 @@ public class InitCommand implements Runnable
return ui.promptSelect(
"Select a template:",
templates,
TemplatesHub.TemplateEntry::name,
TemplatesHub.TemplateEntry::description,
TemplateInfo::name,
TemplateInfo::description,
defaultIndex
);
}
@@ -14,13 +14,17 @@ package io.qrun.qctl.qqq;
import java.io.IOException;
import io.qrun.qctl.qqq.template.TemplatesHub;
import java.util.List;
import io.qrun.qctl.qqq.registry.TemplateInfo;
import io.qrun.qctl.qqq.registry.TemplateRegistry;
import io.qrun.qctl.qqq.registry.TemplateRegistryFactory;
import io.qrun.qctl.shared.ExitCodes;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/*******************************************************************************
* List available project templates from the hub.
* List available project templates from the registry.
*
* @since 0.1.0
*******************************************************************************/
@@ -30,8 +34,12 @@ public class ListCommand implements Runnable
private static final String ANSI_BOLD = "\u001B[1m";
private static final String ANSI_CYAN = "\u001B[36m";
private static final String ANSI_DIM = "\u001B[2m";
private static final String ANSI_GREEN = "\u001B[32m";
private static final String ANSI_RESET = "\u001B[0m";
@Option(names = "--refresh", description = "Bypass cache and fetch fresh data")
boolean refresh;
/***************************************************************************
@@ -50,11 +58,17 @@ public class ListCommand implements Runnable
{
try
{
System.out.println("Fetching templates from hub...\n");
TemplatesHub hub = new TemplatesHub();
var index = hub.fetchIndex();
System.out.println("Fetching templates from registry...\n");
TemplateRegistry registry = TemplateRegistryFactory.getRegistry();
if(index.templates() == null || index.templates().isEmpty())
if(refresh)
{
registry.refresh();
}
List<TemplateInfo> templates = registry.listTemplates();
if(templates.isEmpty())
{
System.out.println("No templates available.");
return;
@@ -62,9 +76,10 @@ public class ListCommand implements Runnable
System.out.println(ANSI_BOLD + "Available Templates:" + ANSI_RESET + "\n");
for(var template : index.templates())
for(TemplateInfo template : templates)
{
System.out.println(ANSI_CYAN + " " + template.id() + ANSI_RESET);
System.out.println(ANSI_CYAN + " " + template.id() + ANSI_RESET
+ " " + ANSI_GREEN + "v" + template.version() + ANSI_RESET);
System.out.println(" " + template.name());
System.out.println(" " + ANSI_DIM + template.description() + ANSI_RESET);
if(template.tags() != null && !template.tags().isEmpty())
@@ -74,7 +89,7 @@ public class ListCommand implements Runnable
System.out.println();
}
System.out.println("Use: qctl qqq init <template-id> <target-dir>");
System.out.println("Use: qctl qqq init <template-id> -o <target-dir>");
}
catch(IOException e)
{
@@ -0,0 +1,160 @@
/*
* All Rights Reserved
*
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
*
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
*
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
* for any purpose other than the purpose for which they were provided to you.
*/
package io.qrun.qctl.qqq.registry;
import java.io.IOException;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import io.qrun.qctl.qqq.template.TemplateResolver;
import io.qrun.qctl.qqq.template.TemplatesHub;
/*******************************************************************************
* Template registry backed by GitHub templates-hub repository.
*
* Fetches template index from raw.githubusercontent.com and clones templates
* from GitHub repositories. Includes caching with configurable TTL.
*
* @since 0.2.0
*******************************************************************************/
public class GitHubTemplateRegistry implements TemplateRegistry
{
private static final Duration DEFAULT_CACHE_TTL = Duration.ofMinutes(15);
private final TemplatesHub hub;
private final TemplateResolver resolver;
private final Duration cacheTtl;
private List<TemplateInfo> cachedTemplates;
private Instant cacheExpiry;
/***************************************************************************
* Construct with default cache TTL.
*
* @since 0.2.0
***************************************************************************/
public GitHubTemplateRegistry()
{
this(DEFAULT_CACHE_TTL);
}
/***************************************************************************
* Construct with custom cache TTL.
*
* @param cacheTtl cache duration
* @since 0.2.0
***************************************************************************/
public GitHubTemplateRegistry(Duration cacheTtl)
{
this.hub = new TemplatesHub();
this.resolver = new TemplateResolver();
this.cacheTtl = cacheTtl;
}
@Override
public List<TemplateInfo> listTemplates() throws IOException
{
if(cachedTemplates != null && cacheExpiry != null && Instant.now().isBefore(cacheExpiry))
{
return cachedTemplates;
}
TemplatesHub.TemplatesIndex index = hub.fetchIndex();
if(index.templates() == null)
{
cachedTemplates = List.of();
}
else
{
cachedTemplates = index.templates().stream()
.map(entry -> convertToTemplateInfo(entry, index.registry()))
.toList();
}
cacheExpiry = Instant.now().plus(cacheTtl);
return cachedTemplates;
}
@Override
public Optional<TemplateInfo> getTemplate(String id) throws IOException
{
return listTemplates().stream()
.filter(t -> t.id().equals(id))
.findFirst();
}
@Override
public Path downloadTemplate(String id, String version) throws IOException
{
Optional<TemplateInfo> template = getTemplate(id);
if(template.isEmpty())
{
throw new IOException("Template not found: " + id);
}
return resolver.resolve(template.get().downloadUrl(), version);
}
@Override
public void refresh()
{
cachedTemplates = null;
cacheExpiry = null;
}
/***************************************************************************
* Convert hub entry to TemplateInfo.
*
* @param entry hub template entry
* @param registry base registry URL
* @return template info
* @since 0.2.0
***************************************************************************/
private TemplateInfo convertToTemplateInfo(TemplatesHub.TemplateEntry entry, String registry)
{
return new TemplateInfo(
entry.id(),
entry.name(),
entry.version(),
entry.description(),
entry.getGitUrl(registry),
entry.schemaVersion(),
entry.minimumQctlVersion(),
entry.tags(),
entry.maintainer(),
entry.prompts(),
entry.computed(),
entry.transforms(),
entry.postGen(),
entry.ignore()
);
}
}
@@ -0,0 +1,164 @@
/*
* All Rights Reserved
*
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
*
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
*
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
* for any purpose other than the purpose for which they were provided to you.
*/
package io.qrun.qctl.qqq.registry;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import io.qrun.qctl.qqq.template.ComputedVariable;
import io.qrun.qctl.qqq.template.TemplateManifest;
import io.qrun.qctl.qqq.template.TemplateResolver;
import io.qrun.qctl.qqq.template.Transform;
/*******************************************************************************
* Mock template registry with embedded template data.
*
* Provides stable template listing without network dependencies.
* Used for development, testing, and as a fallback when Voyage is unavailable.
*
* @since 0.2.0
*******************************************************************************/
public class MockTemplateRegistry implements TemplateRegistry
{
private static final String GITHUB_REGISTRY = "https://github.com/QRun-IO";
private static final List<TemplateInfo> MOCK_TEMPLATES = List.of(
new TemplateInfo(
"new-qqq-application",
"QQQ Application Starter",
"1.0.0",
"Complete QQQ application with entities, database, and dashboard",
GITHUB_REGISTRY + "/new-qqq-application-template.git",
2,
"0.2.0",
List.of("qqq", "java", "starter"),
"QRun-IO",
List.of(
new TemplateManifest.Prompt("projectName", "Project name", "text", "my-qqq-app", null, null, true),
new TemplateManifest.Prompt("groupId", "Maven group ID", "text", "com.example", null, null, true),
new TemplateManifest.Prompt("artifactId", "Maven artifact ID", "text", "my-qqq-app", null, null, true),
new TemplateManifest.Prompt("packageName", "Java package name", "text", "com.example.myapp", null, null, true)
),
List.of(
new ComputedVariable("packagePath", "$str.replace($packageName, '.', '/')")
),
List.of(
new Transform("delete", "**/.gitkeep", null)
),
List.of(
new TemplateManifest.PostGenHook("Initialize git repository", "git init", null, true, "init"),
new TemplateManifest.PostGenHook("Build project", "mvn clean compile -DskipTests", null, true, "build")
),
List.of(".git", ".github", ".idea", "target", "*.iml", "LICENSE", "README.md")
),
new TemplateInfo(
"qqq-web-api",
"QQQ Web API",
"1.0.0",
"REST API application with Javalin middleware and OpenAPI docs",
GITHUB_REGISTRY + "/qqq-web-api-template.git",
2,
"0.2.0",
List.of("qqq", "java", "api", "rest"),
"QRun-IO",
List.of(
new TemplateManifest.Prompt("projectName", "Project name", "text", "my-api", null, null, true),
new TemplateManifest.Prompt("groupId", "Maven group ID", "text", "com.example", null, null, true),
new TemplateManifest.Prompt("artifactId", "Maven artifact ID", "text", "my-api", null, null, true),
new TemplateManifest.Prompt("packageName", "Java package name", "text", "com.example.api", null, null, true)
),
List.of(new ComputedVariable("packagePath", "$str.replace($packageName, '.', '/')")),
List.of(new Transform("delete", "**/.gitkeep", null)),
List.of(),
List.of(".git", ".github", "target")
),
new TemplateInfo(
"qqq-cli-tool",
"QQQ CLI Tool",
"1.0.0",
"Command-line tool with Picocli and native image support",
GITHUB_REGISTRY + "/qqq-cli-tool-template.git",
2,
"0.2.0",
List.of("qqq", "java", "cli", "native"),
"QRun-IO",
List.of(
new TemplateManifest.Prompt("projectName", "Project name", "text", "my-cli", null, null, true),
new TemplateManifest.Prompt("groupId", "Maven group ID", "text", "com.example", null, null, true),
new TemplateManifest.Prompt("artifactId", "Maven artifact ID", "text", "my-cli", null, null, true),
new TemplateManifest.Prompt("packageName", "Java package name", "text", "com.example.cli", null, null, true),
new TemplateManifest.Prompt("commandName", "Main command name", "text", "mycli", null, null, true)
),
List.of(new ComputedVariable("packagePath", "$str.replace($packageName, '.', '/')")),
List.of(new Transform("delete", "**/.gitkeep", null)),
List.of(),
List.of(".git", ".github", "target")
)
);
private final TemplateResolver resolver;
/***************************************************************************
* Default constructor.
*
* @since 0.2.0
***************************************************************************/
public MockTemplateRegistry()
{
this.resolver = new TemplateResolver();
}
@Override
public List<TemplateInfo> listTemplates()
{
return MOCK_TEMPLATES;
}
@Override
public Optional<TemplateInfo> getTemplate(String id)
{
return MOCK_TEMPLATES.stream()
.filter(t -> t.id().equals(id))
.findFirst();
}
@Override
public Path downloadTemplate(String id, String version) throws IOException
{
Optional<TemplateInfo> template = getTemplate(id);
if(template.isEmpty())
{
throw new IOException("Template not found: " + id);
}
return resolver.resolve(template.get().downloadUrl(), version);
}
@Override
public void refresh()
{
// No-op for mock registry - data is embedded
}
}
@@ -0,0 +1,82 @@
/*
* All Rights Reserved
*
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
*
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
*
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
* for any purpose other than the purpose for which they were provided to you.
*/
package io.qrun.qctl.qqq.registry;
import java.util.List;
import io.qrun.qctl.qqq.template.ComputedVariable;
import io.qrun.qctl.qqq.template.TemplateManifest;
import io.qrun.qctl.qqq.template.Transform;
/*******************************************************************************
* Template metadata from the registry.
*
* Contains all information needed to display and initialize a template.
*
* @param id unique template identifier
* @param name display name
* @param version template version (semver)
* @param description brief description
* @param downloadUrl URL to fetch template archive or git repo
* @param schemaVersion manifest schema version
* @param minimumQctlVersion minimum qctl version required
* @param tags categorization tags
* @param maintainer template maintainer
* @param prompts interactive prompts for template variables
* @param computed computed variables derived from prompts
* @param transforms file transforms (rename/delete)
* @param postGen post-generation hooks
* @param ignore patterns to ignore during rendering
* @since 0.2.0
*******************************************************************************/
public record TemplateInfo(
String id,
String name,
String version,
String description,
String downloadUrl,
Integer schemaVersion,
String minimumQctlVersion,
List<String> tags,
String maintainer,
List<TemplateManifest.Prompt> prompts,
List<ComputedVariable> computed,
List<Transform> transforms,
List<TemplateManifest.PostGenHook> postGen,
List<String> ignore
)
{
/***************************************************************************
* Convert to TemplateManifest for rendering.
*
* @return template manifest
* @since 0.2.0
***************************************************************************/
public TemplateManifest toManifest()
{
return new TemplateManifest(
schemaVersion,
id,
name,
version,
description,
minimumQctlVersion,
prompts,
computed,
transforms,
postGen,
ignore
);
}
}
@@ -0,0 +1,76 @@
/*
* All Rights Reserved
*
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
*
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
*
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
* for any purpose other than the purpose for which they were provided to you.
*/
package io.qrun.qctl.qqq.registry;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
/*******************************************************************************
* Registry for fetching and managing project templates.
*
* Provides abstraction over template sources (GitHub, Voyage API, mock).
* Implementations handle caching, authentication, and download logic.
*
* @since 0.2.0
*******************************************************************************/
public interface TemplateRegistry
{
/***************************************************************************
* List all available templates.
*
* @return list of template metadata
* @throws IOException if registry cannot be reached
* @since 0.2.0
***************************************************************************/
List<TemplateInfo> listTemplates() throws IOException;
/***************************************************************************
* Get template by ID.
*
* @param id template identifier
* @return template metadata if found
* @throws IOException if registry cannot be reached
* @since 0.2.0
***************************************************************************/
Optional<TemplateInfo> getTemplate(String id) throws IOException;
/***************************************************************************
* Download template to local path.
*
* @param id template identifier
* @param version specific version or null for latest
* @return path to downloaded template directory
* @throws IOException if download fails
* @since 0.2.0
***************************************************************************/
Path downloadTemplate(String id, String version) throws IOException;
/***************************************************************************
* Refresh the template cache.
*
* Forces a fresh fetch from the registry on next call.
*
* @since 0.2.0
***************************************************************************/
void refresh();
}
@@ -0,0 +1,118 @@
/*
* All Rights Reserved
*
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
*
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
*
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
* for any purpose other than the purpose for which they were provided to you.
*/
package io.qrun.qctl.qqq.registry;
/*******************************************************************************
* Factory for creating template registry instances.
*
* Selects the appropriate registry implementation based on configuration.
* Currently supports: GitHub (default), Mock (for testing/offline).
* Future: Voyage API registry.
*
* @since 0.2.0
*******************************************************************************/
public final class TemplateRegistryFactory
{
private static final String REGISTRY_TYPE_ENV = "QCTL_REGISTRY_TYPE";
private static final String REGISTRY_TYPE_GITHUB = "github";
private static final String REGISTRY_TYPE_MOCK = "mock";
private static TemplateRegistry instance;
/***************************************************************************
* Private constructor - utility class.
*
* @since 0.2.0
***************************************************************************/
private TemplateRegistryFactory()
{
}
/***************************************************************************
* Get the configured template registry.
*
* Registry type is determined by QCTL_REGISTRY_TYPE environment variable:
* - "github" (default): GitHub templates-hub repository
* - "mock": Embedded mock templates for testing/offline
* - "voyage": Voyage API (future)
*
* @return template registry instance
* @since 0.2.0
***************************************************************************/
public static TemplateRegistry getRegistry()
{
if(instance == null)
{
instance = createRegistry();
}
return instance;
}
/***************************************************************************
* Create a new registry instance based on configuration.
*
* @return new template registry
* @since 0.2.0
***************************************************************************/
private static TemplateRegistry createRegistry()
{
String registryType = System.getenv(REGISTRY_TYPE_ENV);
if(registryType == null || registryType.isBlank())
{
registryType = REGISTRY_TYPE_GITHUB;
}
return switch(registryType.toLowerCase())
{
case REGISTRY_TYPE_MOCK -> new MockTemplateRegistry();
case REGISTRY_TYPE_GITHUB -> new GitHubTemplateRegistry();
default -> new GitHubTemplateRegistry();
};
}
/***************************************************************************
* Reset the cached registry instance.
*
* Used for testing to allow switching registry types.
*
* @since 0.2.0
***************************************************************************/
public static void reset()
{
instance = null;
}
/***************************************************************************
* Set a specific registry instance.
*
* Used for testing to inject mock registries.
*
* @param registry registry to use
* @since 0.2.0
***************************************************************************/
public static void setRegistry(TemplateRegistry registry)
{
instance = registry;
}
}
@@ -1,4 +1,13 @@
[
{
"name": "io.qrun.qctl.qqq.registry.TemplateInfo",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true
},
{
"name": "io.qrun.qctl.qqq.template.TemplatesHub$TemplatesIndex",
"allDeclaredConstructors": true,
@@ -0,0 +1,173 @@
/*
* All Rights Reserved
*
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
*
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
*
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
* for any purpose other than the purpose for which they were provided to you.
*/
package io.qrun.qctl.qqq.registry;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Unit tests for MockTemplateRegistry.
*
* @since 0.2.0
*******************************************************************************/
class MockTemplateRegistryTest
{
private MockTemplateRegistry registry;
@BeforeEach
void setUp()
{
registry = new MockTemplateRegistry();
}
@Test
void testListTemplates_returnsEmbeddedTemplates()
{
List<TemplateInfo> templates = registry.listTemplates();
assertThat(templates).isNotEmpty();
assertThat(templates).hasSizeGreaterThanOrEqualTo(3);
}
@Test
void testListTemplates_containsExpectedTemplates()
{
List<TemplateInfo> templates = registry.listTemplates();
List<String> ids = templates.stream()
.map(TemplateInfo::id)
.toList();
assertThat(ids).contains("new-qqq-application", "qqq-web-api", "qqq-cli-tool");
}
@Test
void testGetTemplate_existingTemplate_returnsTemplate()
{
Optional<TemplateInfo> result = registry.getTemplate("new-qqq-application");
assertThat(result).isPresent();
assertThat(result.get().id()).isEqualTo("new-qqq-application");
assertThat(result.get().name()).isEqualTo("QQQ Application Starter");
assertThat(result.get().version()).isEqualTo("1.0.0");
}
@Test
void testGetTemplate_nonExistingTemplate_returnsEmpty()
{
Optional<TemplateInfo> result = registry.getTemplate("non-existent");
assertThat(result).isEmpty();
}
@Test
void testTemplateInfo_hasExpectedMetadata()
{
Optional<TemplateInfo> result = registry.getTemplate("new-qqq-application");
assertThat(result).isPresent();
TemplateInfo info = result.get();
assertThat(info.schemaVersion()).isEqualTo(2);
assertThat(info.minimumQctlVersion()).isEqualTo("0.2.0");
assertThat(info.tags()).contains("qqq", "java", "starter");
assertThat(info.maintainer()).isEqualTo("QRun-IO");
}
@Test
void testTemplateInfo_hasPrompts()
{
Optional<TemplateInfo> result = registry.getTemplate("new-qqq-application");
assertThat(result).isPresent();
TemplateInfo info = result.get();
assertThat(info.prompts()).isNotEmpty();
assertThat(info.prompts()).hasSize(4);
List<String> promptNames = info.prompts().stream()
.map(p -> p.name())
.toList();
assertThat(promptNames).containsExactly("projectName", "groupId", "artifactId", "packageName");
}
@Test
void testTemplateInfo_hasComputedVariables()
{
Optional<TemplateInfo> result = registry.getTemplate("new-qqq-application");
assertThat(result).isPresent();
TemplateInfo info = result.get();
assertThat(info.computed()).isNotEmpty();
assertThat(info.computed().get(0).name()).isEqualTo("packagePath");
}
@Test
void testTemplateInfo_hasTransforms()
{
Optional<TemplateInfo> result = registry.getTemplate("new-qqq-application");
assertThat(result).isPresent();
TemplateInfo info = result.get();
assertThat(info.transforms()).isNotEmpty();
assertThat(info.transforms().get(0).type()).isEqualTo("delete");
}
@Test
void testTemplateInfo_toManifest_convertsCorrectly()
{
Optional<TemplateInfo> result = registry.getTemplate("new-qqq-application");
assertThat(result).isPresent();
var manifest = result.get().toManifest();
assertThat(manifest.id()).isEqualTo("new-qqq-application");
assertThat(manifest.name()).isEqualTo("QQQ Application Starter");
assertThat(manifest.schemaVersion()).isEqualTo(2);
assertThat(manifest.prompts()).hasSize(4);
}
@Test
void testRefresh_doesNothing()
{
// Refresh is a no-op for mock registry - just ensure it doesn't throw
registry.refresh();
// Templates should still be available
assertThat(registry.listTemplates()).isNotEmpty();
}
}
@@ -0,0 +1,78 @@
/*
* All Rights Reserved
*
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
*
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
*
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
* for any purpose other than the purpose for which they were provided to you.
*/
package io.qrun.qctl.qqq.registry;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Unit tests for TemplateRegistryFactory.
*
* @since 0.2.0
*******************************************************************************/
class TemplateRegistryFactoryTest
{
@BeforeEach
void setUp()
{
TemplateRegistryFactory.reset();
}
@AfterEach
void tearDown()
{
TemplateRegistryFactory.reset();
}
@Test
void testGetRegistry_returnsSameInstance()
{
TemplateRegistry first = TemplateRegistryFactory.getRegistry();
TemplateRegistry second = TemplateRegistryFactory.getRegistry();
assertThat(first).isSameAs(second);
}
@Test
void testReset_clearsCache()
{
TemplateRegistry first = TemplateRegistryFactory.getRegistry();
TemplateRegistryFactory.reset();
TemplateRegistry second = TemplateRegistryFactory.getRegistry();
assertThat(first).isNotSameAs(second);
}
@Test
void testSetRegistry_overridesDefault()
{
MockTemplateRegistry mock = new MockTemplateRegistry();
TemplateRegistryFactory.setRegistry(mock);
TemplateRegistry result = TemplateRegistryFactory.getRegistry();
assertThat(result).isSameAs(mock);
}
}