Author SHA1 Message Date
james.maes b5086fbd63 Merge branch 'develop' 2026-01-04 19:43:39 -06:00
james.maes dd275cb9fe chore: bump version to 0.2.1 for next development cycle 2026-01-04 19:34:13 -06:00
james.maes 25927df071 Merge develop into main for v0.2.0 release 2026-01-04 19:22:05 -06:00
james.maes 8ac4fdf2e1 feat(core): add doctor command and dependency pre-flight checks
- Add `qctl doctor` command to check Java, Maven, Git, Docker
- Add pre-flight dependency checks before running post-gen hooks
- Cross-platform support (Windows/Linux/macOS)
2026-01-04 19:13:29 -06:00
james.maes 386e6caecf fix: read version from properties file for native image compatibility 2026-01-04 18:09:38 -06:00
james.maes 04914eaf2d docs: update documentation for E2E testing infrastructure
- Add testing section to CLAUDE.md and README.md
- Update architecture.md with Testing Architecture section
- Create docs/testing.md with fixture and harness documentation
- Document __VARNAME__ path syntax for CI compatibility
2026-01-04 17:30:24 -06:00
james.maes 73c271c201 feat(qqq): add structured error handling with suggestions
Add TemplateRenderException constructors for undefined variable errors
with suggestions. Refactor InitCommand/ListCommand to return Integer
exit codes. Fix TransformExecutor and TemplateManifest for schema v2.
2026-01-04 12:12:08 -06:00
james.maes bc93243f6b fix(template): use __VAR__ syntax in paths for CI compatibility
Rename fixture directories from $packagePath to __packagePath__ to
prevent shell/Maven interpretation. TemplateEngine now converts
__VARNAME__ to $VARNAME before Velocity rendering.
2026-01-04 12:07:10 -06:00
james.maes a2ec447003 test(integration): add comprehensive E2E tests for init command
Add 35 tests covering variable substitution, computed variables,
directory transforms, merge mode, and edge cases. Includes test
harness and 8 fixture templates for offline testing.
2026-01-04 11:57:42 -06:00
james.maes abc118dbe5 feat(qqq): add --merge mode for selective regeneration
Implements Story #26 with include/exclude patterns, interactive
conflict resolution, and file backups.

Closes #26
2026-01-03 21:21:40 -06:00
james.maes 5005b8e5fa docs: update session state for story #25 completion 2026-01-03 21:11:42 -06:00
james.maes 30e124dde5 feat(qqq): add structured error handling with suggestions
- Add SuggestionEngine using Levenshtein distance for fuzzy matching
- Add TemplateError with categories mapped to exit codes
- Add ErrorFormatter for consistent ANSI-colored output
- Integrate did-you-mean suggestions in template commands
- Add --verbose flag for stack trace output
2026-01-03 21:10:32 -06:00
james.maes daea5e66b3 docs: update session state for Story #24 completion 2026-01-03 21:01:54 -06:00
james.maes a2e85f7f93 feat(qqq): add prompt validation with retry loop (#24)
- Add PromptValidation record for validation rules (pattern, length, range, enum)
- Add PromptValidator with support for required, pattern, length, range, and enum validation
- Update PromptRunner to validate and re-prompt on failure
- Validate --var CLI overrides, fallback to interactive prompt if invalid
- Add ConsoleUI methods for validation feedback (error, success, warning)
- Add 20 unit tests for PromptValidator

Closes #24
2026-01-03 21:00:41 -06:00
james.maes e14565a277 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
2026-01-03 20:52:43 -06:00
james.maes 5a967f072f feat(qqq): implement Velocity template engine with computed vars and transforms
Stories #18-21 complete:
- Apache Velocity replaces Handlebars for QQQ consistency
- StringTool with $str.camel(), pascal(), replace() helpers
- Computed variables ($str.replace($packageName, '.', '/'))
- Transform executor for rename/delete operations
- Manifest schema v2 with minimumQctlVersion check
2026-01-03 20:45:06 -06:00
james.maes a71af51f47 feat: add native-image reflection for Velocity and verbatim file handling 2026-01-03 20:42:36 -06:00
james.maes 1380b18652 docs: update CLAUDE.md and add session state for continuity 2025-12-31 08:28:31 -06:00
james.maes 334133af71 fix: remove accidentally committed binary artifacts 2025-12-31 08:24:37 -06:00
KofTwentyTwo a7abd429fe chore(aur): update PKGBUILD to v0.0.2 2025-12-31 08:12:59 -06:00
KofTwentyTwo 932f4d8e34 chore(scoop): update manifest to v0.0.2 2025-12-31 08:12:42 -06:00
james.maes 674adedcba fix(ci): use macos-15 for Intel builds 2025-12-31 07:41:00 -06:00
james.maes 18a3377ea7 fix(ci): repair broken release workflow
- Move native build to profile (fixes -Pnative flag)
- Fix Docker artifact download paths
- Split package updates: Homebrew to tap, Scoop/AUR via PRs
- Fix SHA replacement to work on subsequent releases
- Skip native builds on PRs for faster CI
2025-12-31 07:25:06 -06:00
james.maes 5a42f7b41e build: implement CI-friendly versioning with revision property 2025-12-31 06:42:14 -06:00
james.maes bdb5833be2 chore: bump version to 0.0.1 for initial release 2025-12-31 06:28:10 -06:00
james.maes f751b2eb4e refactor: reorganize repository structure
- Move design docs to docs/
- Move packaging files to packaging/
- Create GitHub issues and milestones for tracking
- Update workflow and README references
2025-12-31 06:23:24 -06:00
james.maes b4adabecfe docs: update CLAUDE.md and TODO.md with session progress 2025-12-30 20:55:35 -06:00
james.maes 418f09fea1 refactor(qqq): make init command interactive with ConsoleUI
- Add ConsoleUI for centralized console I/O
- InitCommand now prompts for template and directory
- Template defaults to new-qqq-application
- PromptRunner uses ConsoleUI for consistency
2025-12-30 20:50:42 -06:00
james.maes 759f40d61e fix(cli): add version provider to qqq subcommand 2025-12-30 20:27:06 -06:00
james.maes 608e807704 feat(cli): implement version flag with build metadata
Add VersionProvider to display version, build time, and git commit.
2025-12-30 20:24:09 -06:00
james.maes ec52f64c49 docs: update CLAUDE.md and TODO.md with distribution info 2025-12-30 20:15:56 -06:00
james.maes c2f619798b docs: add release workflow and distribution info to README 2025-12-30 20:13:59 -06:00
james.maes fe4883c966 feat(dist): add Docker, Scoop, and AUR distribution 2025-12-30 20:10:50 -06:00
james.maes fb10e15ef2 feat(brew): add Homebrew formula with auto-update on release 2025-12-30 20:05:08 -06:00
james.maes 98f4a470d3 fix(ci): use macos-15 runner (macos-13 deprecated) 2025-12-30 19:54:12 -06:00
james.maes 514cd3d5ae docs: add TODO with project roadmap 2025-12-30 19:48:22 -06:00
james.maes 618f09e5f0 fix: remove unused import, add README 2025-12-30 19:47:22 -06:00
james.maes a25309b75d feat(qqq): add init and list commands for template scaffolding
- Add qqq init command with hub-based template discovery
- Add qqq list command to display available templates
- Add Handlebars templating with native image support
- Add GitHub Actions workflow for multi-platform builds
- Add ExitCodes constants, cleanup duplicate Javadoc
- Remove native profile from qctl-core (only qctl-cli builds)
2025-12-30 19:43:32 -06:00
james.maes 8028f9b101 build(ide): updated 2025-12-29 06:47:25 -06:00
james.maes 44ecc8793d docs: Standardize README 2025-12-24 14:21:42 -06:00
james.maes 67478867d9 ci: Add GitHub release configuration 2025-12-24 12:44:44 -06:00
119 changed files with 14744 additions and 149 deletions

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
{
"permissions": {
"allow": [
"Bash(grep:*)",
"Bash(mvn checkstyle:check:*)",
"Bash(mvn spotless:check:*)",
"Bash(xargs:*)",
"Bash(mvn clean verify:*)",
"Bash(/usr/libexec/java_home:*)",
"Bash(JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn clean verify:*)",
"Bash(/Users/james.maes/Git.Local/QRunIO/qctl/qctl-cli/target/qctl:*)",
"Bash(JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn compile:*)",
"Bash(JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn checkstyle:check:*)",
"Bash(JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn clean package:*)",
"Bash(git clone:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(unzip:*)",
"Bash(/Users/james.maes/Git.Local/QRunIO/qctl/qctl-core/target/qctl:*)",
"Bash(gh run list:*)",
"Bash(gh run view:*)",
"Bash(JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn spotless:apply:*)",
"Bash(mkdir:*)",
"Bash(mv:*)",
"Bash(gh label list:*)",
"Bash(gh repo view:*)",
"Bash(gh issue create:*)",
"Bash(gh issue list:*)",
"Bash(gh api:*)",
"Bash(gh issue edit:*)",
"Bash(gh repo create:*)",
"Bash(git branch:*)",
"Bash(git tag:*)",
"Bash(JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn clean compile:*)",
"Bash(gh release view:*)",
"Bash(gh release download:*)",
"Bash(shasum:*)",
"Bash(brew untap:*)",
"Bash(brew tap:*)",
"Bash(brew install:*)",
"Bash(qctl:*)",
"Bash(qctl --help:*)",
"Bash(git checkout:*)",
"Bash(cat:*)",
"Bash(gh secret list:*)",
"Bash(gh secret set:*)",
"Bash(gh run watch:*)",
"Bash(gh pr list:*)",
"Bash(gh pr merge:*)",
"Bash(git pull:*)",
"Bash(git stash:*)"
]
}
}
+31
View File
@@ -0,0 +1,31 @@
# GitHub Release Configuration
# Auto-generates release notes from PR labels
changelog:
exclude:
labels:
- ignore-for-release
- duplicate
- invalid
authors:
- dependabot
- dependabot[bot]
categories:
- title: Breaking Changes
labels:
- breaking-change
- breaking
- title: New Features
labels:
- enhancement
- feature
- title: Bug Fixes
labels:
- bug
- fix
- title: Dependencies
labels:
- dependencies
- title: Other Changes
labels:
- "*"
+310
View File
@@ -0,0 +1,310 @@
name: Build
on:
push:
branches: [main, develop]
tags: ['v*']
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up GraalVM
uses: graalvm/setup-graalvm@v1
with:
java-version: '21'
distribution: 'graalvm'
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache Maven packages
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Run tests
run: mvn clean verify -q
build-native:
needs: test
if: github.event_name == 'push'
strategy:
matrix:
include:
- os: ubuntu-latest
artifact: qctl-linux-amd64
- os: ubuntu-24.04-arm
artifact: qctl-linux-arm64
- os: macos-15
artifact: qctl-macos-amd64
- os: macos-14
artifact: qctl-macos-arm64
- os: windows-latest
artifact: qctl-windows-amd64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Set up GraalVM
uses: graalvm/setup-graalvm@v1
with:
java-version: '21'
distribution: 'graalvm'
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache Maven packages
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Build native image
run: mvn clean package -DskipTests -Pnative -q
- name: Rename artifact (Unix)
if: runner.os != 'Windows'
run: mv qctl-cli/target/qctl qctl-cli/target/${{ matrix.artifact }}
- name: Rename artifact (Windows)
if: runner.os == 'Windows'
run: mv qctl-cli/target/qctl.exe qctl-cli/target/${{ matrix.artifact }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: qctl-cli/target/${{ matrix.artifact }}
release:
needs: build-native
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Flatten artifacts
run: |
mkdir -p release
find artifacts -type f -exec mv {} release/ \;
ls -la release/
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: release/*
generate_release_notes: true
docker:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Download Linux AMD64 artifact
uses: actions/download-artifact@v4
with:
name: qctl-linux-amd64
path: .
- name: Download Linux ARM64 artifact
uses: actions/download-artifact@v4
with:
name: qctl-linux-arm64
path: .
- name: Prepare binaries
run: |
chmod +x qctl-linux-amd64 qctl-linux-arm64
# Dockerfile expects qctl-linux-amd64 and qctl-linux-arm64
ls -la qctl-linux-*
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: packaging/docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/qrun-io/qctl:${{ github.ref_name }}
ghcr.io/qrun-io/qctl:latest
update-homebrew:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Checkout homebrew-qctl tap
uses: actions/checkout@v4
with:
repository: QRun-IO/homebrew-qctl
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: homebrew-tap
- name: Update Homebrew formula
env:
VERSION: ${{ github.ref_name }}
run: |
VERSION="${VERSION#v}"
SHA_MACOS_ARM64=$(shasum -a 256 artifacts/qctl-macos-arm64/qctl-macos-arm64 | cut -d' ' -f1)
SHA_MACOS_AMD64=$(shasum -a 256 artifacts/qctl-macos-amd64/qctl-macos-amd64 | cut -d' ' -f1)
SHA_LINUX_ARM64=$(shasum -a 256 artifacts/qctl-linux-arm64/qctl-linux-arm64 | cut -d' ' -f1)
SHA_LINUX_AMD64=$(shasum -a 256 artifacts/qctl-linux-amd64/qctl-linux-amd64 | cut -d' ' -f1)
FORMULA="homebrew-tap/Formula/qctl.rb"
# Update version
sed -i "s/version \"[^\"]*\"/version \"${VERSION}\"/" "$FORMULA"
# Update SHA256 hashes in order: macos-arm64, macos-amd64, linux-arm64, linux-amd64
awk -v v1="$SHA_MACOS_ARM64" -v v2="$SHA_MACOS_AMD64" -v v3="$SHA_LINUX_ARM64" -v v4="$SHA_LINUX_AMD64" '
BEGIN { n = 0 }
/sha256/ {
n++
if (n == 1) sub(/sha256 "[^"]*"/, "sha256 \"" v1 "\"")
else if (n == 2) sub(/sha256 "[^"]*"/, "sha256 \"" v2 "\"")
else if (n == 3) sub(/sha256 "[^"]*"/, "sha256 \"" v3 "\"")
else if (n == 4) sub(/sha256 "[^"]*"/, "sha256 \"" v4 "\"")
}
{ print }
' "$FORMULA" > "$FORMULA.tmp" && mv "$FORMULA.tmp" "$FORMULA"
- name: Commit and push
run: |
cd homebrew-tap
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Formula/qctl.rb
git diff --staged --quiet || git commit -m "Update qctl to ${GITHUB_REF_NAME}"
git push
update-scoop:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: develop
- name: Download Windows artifact
uses: actions/download-artifact@v4
with:
name: qctl-windows-amd64.exe
path: artifacts
- name: Update Scoop manifest
env:
VERSION: ${{ github.ref_name }}
run: |
VERSION="${VERSION#v}"
SHA_WINDOWS=$(shasum -a 256 artifacts/qctl-windows-amd64.exe | cut -d' ' -f1)
MANIFEST="packaging/scoop/qctl.json"
# Update version
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" "$MANIFEST"
# Update download URL version
sed -i "s|/download/v[^/]*/|/download/v${VERSION}/|g" "$MANIFEST"
# Update hash
sed -i "s/\"hash\": \"[^\"]*\"/\"hash\": \"${SHA_WINDOWS}\"/" "$MANIFEST"
- name: Clean up artifacts
run: rm -rf artifacts
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore(scoop): update manifest to ${{ github.ref_name }}"
title: "chore(scoop): update manifest to ${{ github.ref_name }}"
body: "Automated Scoop manifest update for release ${{ github.ref_name }}"
branch: chore/scoop-${{ github.ref_name }}
base: develop
add-paths: packaging/scoop/qctl.json
update-aur:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: develop
- name: Download Linux AMD64 artifact
uses: actions/download-artifact@v4
with:
name: qctl-linux-amd64
path: artifacts
- name: Update AUR PKGBUILD
env:
VERSION: ${{ github.ref_name }}
run: |
VERSION="${VERSION#v}"
SHA_LINUX=$(shasum -a 256 artifacts/qctl-linux-amd64 | cut -d' ' -f1)
PKGBUILD="packaging/aur/PKGBUILD"
# Update version
sed -i "s/pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD"
# Reset pkgrel for new version
sed -i "s/pkgrel=.*/pkgrel=1/" "$PKGBUILD"
# Update hash
sed -i "s/sha256sums_x86_64=('[^']*')/sha256sums_x86_64=('${SHA_LINUX}')/" "$PKGBUILD"
- name: Clean up artifacts
run: rm -rf artifacts
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore(aur): update PKGBUILD to ${{ github.ref_name }}"
title: "chore(aur): update PKGBUILD to ${{ github.ref_name }}"
body: "Automated AUR PKGBUILD update for release ${{ github.ref_name }}"
branch: chore/aur-${{ github.ref_name }}
base: develop
add-paths: packaging/aur/PKGBUILD
+1
View File
@@ -22,6 +22,7 @@
*/target/
/target/
.flattened-pom.xml
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
+2 -2
View File
@@ -10,6 +10,7 @@
<module name="qctl-qbit" />
<module name="qctl-qqq" />
<module name="qctl-integration-tests" />
<module name="qctl-cli" />
<module name="qctl-qrun" />
<module name="qctl-shared" />
</profile>
@@ -20,8 +21,6 @@
<processorPath useClasspath="false">
<entry name="$MAVEN_REPOSITORY$/info/picocli/picocli-codegen/4.7.5/picocli-codegen-4.7.5.jar" />
<entry name="$MAVEN_REPOSITORY$/info/picocli/picocli/4.7.5/picocli-4.7.5.jar" />
<entry name="$MAVEN_REPOSITORY$/info/picocli/picocli-codegen/4.7.5/picocli-codegen-4.7.5.jar" />
<entry name="$MAVEN_REPOSITORY$/info/picocli/picocli/4.7.5/picocli-4.7.5.jar" />
</processorPath>
<module name="qctl-core" />
</profile>
@@ -29,6 +28,7 @@
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="qctl-cli" options="-parameters" />
<module name="qctl-core" options="-parameters" />
<module name="qctl-integration-tests" options="-parameters" />
<module name="qctl-qbit" options="-parameters" />
+2
View File
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/qctl-cli/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/qctl-cli/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/qctl-core/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/qctl-core/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/qctl-integration-tests/src/main/java" charset="UTF-8" />
+191
View File
@@ -0,0 +1,191 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
qctl is a multi-module Maven CLI tool for the QRun platform. It provides commands for project scaffolding (`qqq`), package management (`qbit`), deployment (`qrun`), and AI-assisted planning (`qstudio`).
## Build Commands
```bash
# Full build with tests
mvn clean verify
# Fast compile (skip tests)
mvn compile -DskipTests
# Checkstyle only
mvn checkstyle:check
# Spotless check/apply
mvn spotless:check
mvn spotless:apply
# Native build (requires GraalVM 21 and -Pnative profile)
JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn clean package -DskipTests -Pnative
# Output: qctl-cli/target/qctl
# Test version flag
./qctl-cli/target/qctl --version
```
## Module Structure
```
qctl/
├── qctl-shared # DTOs, ExitCodes, SPI interfaces, utilities
├── qctl-core # Config, HTTP client, auth, cache, logging, Main entrypoint
├── qctl-qqq # Template scaffolding (init, list) with Velocity
├── qctl-qbit # Package management commands, lockfile handling
├── qctl-qrun # OCI packaging and deployment commands
├── qctl-qstudio # AI planning commands (offline V1)
├── qctl-cli # Aggregator for native image build (has native profile)
├── qctl-integration-tests # E2E and integration tests with fixture templates
├── docs/ # Architecture and design documentation
├── packaging/ # Distribution manifests (Homebrew, Scoop, AUR, Docker)
└── codestyle/ # Checkstyle config and license headers
```
## Architecture
**Plugin System**: Commands discovered via `ServiceLoader<CommandPlugin>`. Each module implements `CommandPlugin` and registers via `META-INF/services`.
**CLI Framework**: Picocli with annotation-based commands. Main entrypoint in `qctl-core/Main.java`.
**Template System**: Templates fetched from [QRun-IO/templates-hub](https://github.com/QRun-IO/templates-hub). Apache Velocity for rendering with StringTool helpers (`$str.kebab()`, `$str.pascal()`, `$str.replace()`). Supports computed variables and transforms. Path variables use `__VARNAME__` or `$VARNAME` syntax.
**Interactive Commands**: All console I/O goes through `ConsoleUI` class (`qctl-qqq/template/ConsoleUI.java`). This separates UI from logic.
**Version Info**: `VersionProvider` reads from `version.properties` (Maven-filtered). Shows version, build timestamp, and git commit hash. Uses `${revision}` property for CI-friendly versioning.
**Native Image**: GraalVM 21 with reflection config. Native build is in a Maven profile (`-Pnative`) in `qctl-cli/pom.xml`.
## Code Style
- **3-space indentation** (Checkstyle enforced)
- **Braces on new line** (`LeftCurly: nl`)
- **Javadoc required** on all methods (flowerbox style)
- **No star imports**
- **Import order**: javax, java, third-party, static (alphabetical)
Flowerbox Javadoc:
```java
/***************************************************************************
* Brief description.
*
* @param foo description
* @return description
* @since 0.1.0
***************************************************************************/
```
## Key Patterns
**Exit Codes** (`qctl-shared/ExitCodes.java`):
- 0=success, 1=generic, 2=usage, 3=network, 4=auth, 5=not found, 6=validation, 7=integrity, 8=conflict, 9=cancelled
**Config Paths**:
- macOS: `~/Library/Application Support/qctl/qctl.yaml`
- Linux: `$XDG_CONFIG_HOME/qctl/qctl.yaml`
- Windows: `%APPDATA%\qctl\qctl.yaml`
## CI/CD Pipeline
Workflow: `.github/workflows/build.yml`
**Trigger**: Push to `main`/`develop`, tags `v*`, PRs
**Jobs**:
1. `test` - Run `mvn clean verify`
2. `build-native` - Build native images for 5 platforms (only on push, not PRs)
3. `release` - Create GitHub release with artifacts (only on tags)
4. `docker` - Build/push multi-arch Docker image to GHCR
5. `update-homebrew` - Push to `QRun-IO/homebrew-qctl` tap
6. `update-scoop` - Create PR for Scoop manifest update
7. `update-aur` - Create PR for AUR PKGBUILD update
**Build Matrix**:
| Platform | Runner | Artifact |
|----------|--------|----------|
| Linux x64 | ubuntu-latest | qctl-linux-amd64 |
| Linux ARM64 | ubuntu-24.04-arm | qctl-linux-arm64 |
| macOS Intel | macos-15 | qctl-macos-amd64 |
| macOS ARM | macos-14 | qctl-macos-arm64 |
| Windows x64 | windows-latest | qctl-windows-amd64.exe |
**Required Secrets**:
- `HOMEBREW_TAP_TOKEN` - PAT with repo scope for homebrew tap
**Release Process**:
```bash
git tag -a v0.0.3 -m "Release v0.0.3"
git push origin v0.0.3
```
## Distribution
| Channel | Location | Update Method |
|---------|----------|---------------|
| Homebrew | `QRun-IO/homebrew-qctl` | Direct push |
| Scoop | `packaging/scoop/qctl.json` | PR to develop |
| AUR | `packaging/aur/PKGBUILD` | PR to develop |
| Docker | `ghcr.io/qrun-io/qctl` | Direct push |
| GitHub | Releases | Auto-created |
**Install**:
```bash
# Homebrew
brew tap QRun-IO/qctl && brew install qctl
# Docker
docker run ghcr.io/qrun-io/qctl --help
```
## Native Image Notes
- Native profile is in `qctl-cli/pom.xml` (activated with `-Pnative`)
- Velocity requires runtime initialization for template parsing
- Jackson records need reflection config in `META-INF/native-image/reflect-config.json`
- Resources config in `META-INF/native-image/resource-config.json`
## Testing
**Frameworks**: JUnit 5 + AssertJ + Mockito
**Run Tests**:
```bash
mvn test # Unit tests only
mvn verify # Unit + integration tests
mvn test -pl qctl-integration-tests # E2E tests only
```
**Test Modules**:
- Unit tests in each module's `src/test/java/`
- Integration tests in `qctl-integration-tests/`
**E2E Test Infrastructure** (`qctl-integration-tests/`):
- `CommandTestHarness` - In-process command execution with stdout/stderr capture
- `TestTemplateRegistry` - Fixture-based template registry for offline testing
- `TestableConsoleUI` - Simulated user input for interactive prompts
- `ExitCodeAssertions` - Fluent assertions for exit codes
**Test Fixtures** (`src/test/resources/fixtures/templates/`):
| Fixture | Purpose |
|---------|---------|
| `test-minimal` | Basic template with no prompts |
| `test-with-prompts` | Template with interactive prompts |
| `test-full-substitution` | Tests variable replacement in content |
| `test-computed-vars` | Tests computed variables (packagePath) |
| `test-transforms` | Tests delete transforms |
| `test-variable-typo` | Tests "Did you mean?" suggestions |
**Path Variable Syntax**:
- Use `__VARNAME__` in directory names (converted to `$VARNAME` at runtime)
- Avoids shell/Maven interpretation of `$` characters
- Example: `template/src/main/java/__packagePath__/App.java`
## Session Continuity
To continue from last session, see `docs/internal/session-state.md` for current progress and next steps.
+231
View File
@@ -1,2 +1,233 @@
# qctl
Command-line interface for the QRun ecosystem. Scaffold QQQ applications, manage qBits, and deploy to qRun.
## Installation
### Homebrew (macOS/Linux)
```bash
brew tap QRun-IO/qctl
brew install qctl
```
### Scoop (Windows)
```powershell
scoop bucket add qrun https://github.com/QRun-IO/qctl
scoop install qctl
```
### AUR (Arch Linux)
```bash
yay -S qctl-bin
```
### Docker
```bash
docker run --rm ghcr.io/qrun-io/qctl:latest --help
# Mount current directory for project scaffolding
docker run --rm -v $(pwd):/work -w /work ghcr.io/qrun-io/qctl:latest qqq init new-qqq-application my-app
```
### Pre-built Binaries
Download from [Releases](https://github.com/QRun-IO/qctl/releases):
| Platform | Binary |
|----------|--------|
| Linux x64 | `qctl-linux-amd64` |
| Linux ARM64 | `qctl-linux-arm64` |
| macOS Intel | `qctl-macos-amd64` |
| macOS Apple Silicon | `qctl-macos-arm64` |
| Windows | `qctl-windows-amd64.exe` |
```bash
# Example: macOS Apple Silicon
curl -L https://github.com/QRun-IO/qctl/releases/latest/download/qctl-macos-arm64 -o qctl
chmod +x qctl
sudo mv qctl /usr/local/bin/
```
### Build from Source
Requires GraalVM 21+:
```bash
git clone https://github.com/QRun-IO/qctl.git
cd qctl
JAVA_HOME=/path/to/graalvm mvn clean package -DskipTests -Pnative
./qctl-cli/target/qctl --version
```
## Quick Start
```bash
# List available project templates
qctl qqq list
# Scaffold a new QQQ application
qctl qqq init new-qqq-application my-app
# With custom variables
qctl qqq init new-qqq-application my-app \
--var=projectName=MyApp \
--var=groupId=com.example \
--var=package=com.example.myapp
```
## Commands
### qqq - Scaffolding
| Command | Description |
|---------|-------------|
| `qctl qqq list` | List available templates from the hub |
| `qctl qqq init <template> <dir>` | Initialize a new project from template |
**Init Options:**
- `--var=key=value` - Set template variable
- `--version=1.0.0` - Use specific template version
- `--dry-run` - Preview without writing files
- `--force` - Overwrite existing directory
- `--no-prompt` - Skip interactive prompts
- `--skip-hooks` - Skip post-generation hooks
### auth - Authentication
| Command | Description |
|---------|-------------|
| `qctl auth login` | Authenticate to qRun APIs |
| `qctl auth logout` | Clear stored credentials |
### qbit - Package Management
| Command | Description |
|---------|-------------|
| `qctl qbit resolve` | Resolve qBit dependencies |
### qrun - Deployment
| Command | Description |
|---------|-------------|
| `qctl qrun status` | Check deployment status |
| `qctl qrun package` | Package application |
| `qctl qrun publish` | Publish to registry |
### cache - Maintenance
| Command | Description |
|---------|-------------|
| `qctl cache clean` | Clear local cache |
## Global Options
```
--debug Enable debug logging
--verbose Enable verbose output
--offline Run without network access
--hermetic Resolve strictly from lockfile
--output Output format: text|json
--help Show help
--version Show version
```
## Templates
Templates are managed in [QRun-IO/templates-hub](https://github.com/QRun-IO/templates-hub). Available templates:
| ID | Description |
|----|-------------|
| `new-qqq-application` | Complete QQQ application with entities, database, and dashboard |
## Project Structure
```
qctl/
├── qctl-cli/ # Native image entry point (aggregates all modules)
├── qctl-core/ # Core commands (auth, cache)
├── qctl-shared/ # Shared utilities and constants
├── qctl-qqq/ # Scaffolding commands (init, list)
├── qctl-qbit/ # Package management commands
├── qctl-qrun/ # Deployment commands
├── qctl-qstudio/ # Planning commands
├── qctl-integration-tests/ # E2E tests with fixture templates
├── docs/ # Architecture and design documentation
├── packaging/ # Distribution manifests (Homebrew, Scoop, AUR, Docker)
└── codestyle/ # Checkstyle config and license headers
```
## Development
```bash
# Run all tests
mvn clean verify
# Run unit tests only
mvn test
# Run E2E tests only
mvn test -pl qctl-integration-tests
# Build native image (requires GraalVM)
JAVA_HOME=/path/to/graalvm mvn clean package -DskipTests -Pnative
# Run with JVM (faster iteration)
mvn clean package -DskipTests
java -jar qctl-cli/target/qctl-cli-0.1.0-SNAPSHOT.jar --help
```
### Testing
The project includes comprehensive E2E tests in `qctl-integration-tests/`:
| Test Class | Coverage |
|------------|----------|
| `InitCommandE2ETest` | CLI flags and basic init behavior |
| `InitCommandFullE2ETest` | Variable substitution, computed vars, transforms |
| `NativeBinaryE2ETest` | Native binary smoke tests (requires build) |
Test fixtures in `src/test/resources/fixtures/templates/` provide offline, deterministic testing.
## Releasing
Create a tagged release to trigger the full CI/CD pipeline:
```bash
git tag v1.0.0
git push origin v1.0.0
```
This automatically:
1. Runs tests on Ubuntu
2. Builds native binaries for all platforms
3. Creates a GitHub Release with binaries attached
4. Pushes Docker image to `ghcr.io/qrun-io/qctl`
5. Updates package manifests (Homebrew, Scoop, AUR) with correct checksums
### Distribution Channels
| Channel | Package | Auto-Updated |
|---------|---------|--------------|
| Homebrew | `packaging/homebrew/qctl.rb` | Yes |
| Scoop | `packaging/scoop/qctl.json` | Yes |
| AUR | `packaging/aur/PKGBUILD` | Yes |
| Docker | `packaging/docker/Dockerfile` | Yes |
| GitHub | Releases page | Yes |
### Supported Platforms
| Platform | Architecture | Binary |
|----------|--------------|--------|
| Linux | x64 | `qctl-linux-amd64` |
| Linux | ARM64 | `qctl-linux-arm64` |
| macOS | Intel | `qctl-macos-amd64` |
| macOS | Apple Silicon | `qctl-macos-arm64` |
| Windows | x64 | `qctl-windows-amd64.exe` |
## License
AGPL-3.0 - See [LICENSE](LICENSE) for details.
+363
View File
@@ -0,0 +1,363 @@
# qctl Roadmap
## Vision
qctl is the unified CLI for QRun platform developers. It enables the complete developer lifecycle: create projects, manage dependencies, build/deploy applications, and operate running services.
## Core Integration
**Voyage** - The QRun platform backend that qctl integrates with for:
- Template registry (qqq)
- Package registry (qbit)
- Artifact storage & deployment (qrun)
- Service orchestration & observability (qrun)
---
## User Journeys
### Journey 1: Create a New QQQ Application
**Goal:** Developer creates a new QQQ application from a template and has a running local dev environment.
**User Flow:**
```bash
# Step 1: See available templates
$ qctl qqq list
Available templates:
qqq-web-api Web API application with Javalin middleware
qqq-cli-tool Command-line tool with Picocli
qqq-batch Batch processing application
qqq-full-stack Full-stack with React frontend
# Step 2: Initialize a new project
$ qctl qqq init qqq-web-api my-app
? Java package [com.example.myapp]: com.acme.orders
? Maven groupId [com.acme]: com.acme
? Maven artifactId [my-app]: orders-service
? Application name [myApp]: ordersApp
? Application label [My App]: Orders Service
Creating project from template: qqq-web-api
Cloning template...
Rendering files with Velocity...
Moving src/main/java/com/example/orders → src/main/java/com/acme/orders
Moving src/test/java/com/example/orders → src/test/java/com/acme/orders
Running post-gen hook: build
> mvn clean verify -DskipTests
[BUILD SUCCESS]
Project created: ./my-app
Next steps:
cd my-app
mvn spring-boot:run
# Step 3: Run locally
$ cd my-app && mvn spring-boot:run
[INFO] Started OrdersApp in 2.3s
```
**Required Capabilities:**
| Capability | Description | Status |
|------------|-------------|--------|
| Template listing | Fetch templates from Voyage (or local cache) | POC - needs Voyage integration |
| Template selection | Interactive or CLI arg | POC - needs polish |
| Prompt collection | Interactive prompts from manifest | POC - needs validation |
| CLI var overrides | `--var key=value` for non-interactive | POC - needs testing |
| Velocity rendering | Render file contents with Velocity | **TODO** (currently Handlebars) |
| Path rendering | Render file/dir names with variables | POC - switch to Velocity |
| Computed variables | Derive `packagePath` from `packageName` | **TODO** |
| Directory transforms | Move directories per manifest rules | **TODO** |
| Post-gen hooks | Run shell commands after render | POC - needs phases |
| Dry-run mode | Show what would be created | POC - needs refinement |
| Force overwrite | `--force` to overwrite existing | POC - needs merge mode |
| Error handling | Clear errors with recovery suggestions | **TODO** |
| Template caching | Cache templates with TTL | **TODO** |
**Design Decision: Template Engine**
**Decision:** Use **Apache Velocity** instead of Handlebars for consistency with QQQ's existing template/report system.
- QQQ already uses Velocity for template rendering
- Template authors familiar with QQQ will know Velocity syntax (`$variable`, `#if...#end`)
- More powerful than Handlebars (full control flow, macros)
- Requires migration from current Handlebars implementation
**Design Decision: Template Transformation**
**Decision:** Hybrid approach with Velocity rendering + structural transformations.
1. **Velocity for content:** Templates use `$packageName`, `$groupId` in source files
2. **Manifest rules for structure:** Define path transformations and directory moves
3. **Computed variables:** Derive `packagePath` from `packageName` automatically
**Template Manifest Example:**
```yaml
name: qqq-web-api
prompts:
- name: packageName
message: "Java package"
default: "com.example.myapp"
- name: groupId
message: "Maven groupId"
default: "com.example"
- name: artifactId
message: "Maven artifactId"
default: "my-qqq-app"
- name: appName
message: "Application name"
default: "myApp"
computed:
packagePath: "${packageName.replace('.', '/')}"
transforms:
- type: move
from: "src/main/java/com/example/orders"
to: "src/main/java/${packagePath}"
- type: move
from: "src/test/java/com/example/orders"
to: "src/test/java/${packagePath}"
postGen:
- name: build
command: "mvn clean verify -DskipTests"
```
---
### Journey 2: Add QBit Dependencies
**Goal:** Developer finds and adds QQQ modules (qbits) to their project.
```bash
# Search for authentication modules
qctl qbit search auth
# Add the SSO qbit to project
qctl qbit add @qrun/auth-sso
# See what's installed
qctl qbit list
# Update to latest versions
qctl qbit update
```
**Required Capabilities:**
- [ ] Search Voyage registry by keyword/tag
- [ ] View qbit details (description, version, dependencies)
- [ ] Add qbit with version constraint (`@qrun/auth-sso@^2.0`)
- [ ] Resolve dependency tree (handle conflicts)
- [ ] Generate/update lockfile (`qbits.lock`)
- [ ] Vendor packages to `vendor/qbits/`
- [ ] Integrity verification (sha512)
---
### Journey 3: Build & Deploy Application
**Goal:** Developer packages their application and deploys it to an environment.
```bash
# Build OCI image
qctl qrun package
# Deploy to dev environment
qctl qrun publish --env dev
# Deploy to production
qctl qrun publish --env prod --channel stable
```
**Required Capabilities:**
- [ ] Build OCI image via Jib
- [ ] Push to Voyage artifact registry
- [ ] Create release record with metadata
- [ ] Deploy to target environment via Voyage
- [ ] Support release channels (dev, beta, stable)
- [ ] Rollback to previous version
---
### Journey 4: Operate Running Services
**Goal:** Developer monitors and manages their deployed applications.
```bash
# Check service status
qctl qrun status --app my-app --env prod
# View logs
qctl qrun logs --app my-app --env prod --follow
# Restart service
qctl qrun restart --app my-app --env prod
# Stop service (maintenance)
qctl qrun stop --app my-app --env prod
# Start service
qctl qrun start --app my-app --env prod
```
**Required Capabilities:**
- [ ] Query Voyage for service status (replicas, health, version)
- [ ] Stream logs from Voyage
- [ ] Stop/start/restart services via Voyage API
- [ ] View deployment history
- [ ] Scale replicas up/down
---
## Milestones
### v0.2.0 - Create & Manage
**Focus:** Complete "Create New Application" and "Add Dependencies" journeys.
**Epic: qqq - Template System**
| Feature | Description |
|---------|-------------|
| Velocity engine | Replace Handlebars with Apache Velocity |
| Computed variables | Auto-derive `packagePath` from `packageName` |
| Directory transforms | Move folders per manifest `transforms` rules |
| Template manifest v2 | Add `computed` and `transforms` sections |
| Migrate templates | Update templates-hub to Velocity syntax |
| Voyage mock integration | Template listing from mock API |
| Prompt validation | Validate required fields, types, constraints |
| Error messages | Clear errors with recovery suggestions |
| Merge mode | `--merge` for selective regeneration |
**Epic: qbit - Package Management**
| Feature | Description |
|---------|-------------|
| qbit search | Search against Voyage mock registry |
| qbit add | Add package with version constraints |
| qbit remove | Remove package, update lockfile |
| qbit update | Update packages to latest |
| qbit list | List installed packages |
| SemVer resolution | Constraint solving (^, ~, exact) |
| Conflict detection | Identify and report version conflicts |
| Vendor layout | Download to `vendor/qbits/` |
| Integrity verification | SHA-512 hash checks |
---
### v0.3.0 - Deploy & Operate
**Focus:** Complete "Deploy Application" and "Operate Services" journeys.
**Epic: qrun - Build & Publish**
| Feature | Description |
|---------|-------------|
| Jib integration | OCI image building |
| qrun package | Build container image |
| qrun publish | Push to Voyage registry |
| OCI labels | Inject standard and custom labels |
| Digest verification | Verify image after push |
| Release channels | stable/beta/alpha support |
| qrun rollback | Rollback to previous version |
**Epic: qrun - Service Operations**
| Feature | Description |
|---------|-------------|
| qrun status | Service status from Voyage |
| qrun logs | Stream logs with --follow |
| qrun stop | Stop service |
| qrun start | Start service |
| qrun restart | Restart service |
| Deployment history | View past deployments |
| qrun scale | Scale replicas up/down |
---
### v1.0.0 - Production Ready
**Focus:** AI planning, security hardening, enterprise features.
**Epic: qstudio - AI Planning**
| Feature | Description |
|---------|-------------|
| Code indexing | Local codebase analysis |
| qstudio plan | Generate implementation plan |
| Path policies | Allow/deny path lists |
| Ledger storage | Track plan history |
**Epic: Authentication & Security**
| Feature | Description |
|---------|-------------|
| OIDC device flow | Full authentication flow |
| Service accounts | CI/CD support |
| Signature enforcement | Require signed packages/images |
| OS keychain | Secure credential storage |
**Epic: Enterprise Features**
| Feature | Description |
|---------|-------------|
| SSO integration | SAML/OIDC enterprise SSO |
| RBAC | Team-based access control |
| Audit logging | Compliance audit trail |
| Org management | Organization/team management |
---
## Voyage Integration
**Voyage** is the QRun platform backend (currently in development). Until Voyage is ready, qctl will:
1. Use mock APIs for development and testing
2. Support local/offline modes where possible
3. Design against Voyage's planned API contracts (standard QQQ data model)
Voyage will provide a versioned API that drives templates, qbits, artifacts, and service orchestration.
## Voyage API Dependencies (Planned)
qctl requires these Voyage API endpoints:
### Templates (qqq)
- `GET /v1/templates` - List available templates
- `GET /v1/templates/{id}` - Get template details
- `GET /v1/templates/{id}/versions/{version}` - Download template
### Packages (qbit)
- `GET /v1/qbits?q={query}` - Search packages
- `GET /v1/qbits/{id}` - Get package details
- `GET /v1/qbits/{id}/versions/{version}` - Download package
### Artifacts (qrun)
- `POST /v1/artifacts` - Register artifact
- `GET /v1/artifacts/{id}` - Get artifact info
- `POST /v1/releases` - Create release
- `POST /v1/deployments` - Deploy to environment
### Services (qrun)
- `GET /v1/apps/{app}/status` - Service status
- `GET /v1/apps/{app}/logs` - Service logs
- `POST /v1/apps/{app}/restart` - Restart service
- `POST /v1/apps/{app}/stop` - Stop service
- `POST /v1/apps/{app}/start` - Start service
- `POST /v1/apps/{app}/scale` - Scale replicas
---
## GitHub Issues Structure
Each milestone will have:
- 1 Epic issue tracking the user journey
- Story issues for each capability (linked to epic)
- Labels: `epic`, `story`, `module:qqq|qbit|qrun|qstudio`
Example:
```
Epic: [v0.2.0] Create New Application Journey
├── Story: Template registry integration with Voyage
├── Story: Add web-api template
├── Story: Add cli-tool template
├── Story: Add batch-processor template
├── Story: Implement qqq upgrade command
└── Story: Enhanced post-init hooks
```
+209
View File
@@ -0,0 +1,209 @@
# qctl Vision & Architecture
## Purpose
qctl is the unified CLI for the QRun platform - a complete developer workflow tool for building, packaging, deploying, and managing QQQ-based applications. It consolidates project scaffolding, dependency management, artifact publishing, and AI-assisted development into a single binary.
## Core Principles
1. **Single Binary**: One native executable for all QRun platform operations
2. **Offline-First**: Full functionality without network when possible (hermetic mode)
3. **Declarative Config**: YAML-driven configuration with sensible defaults
4. **Platform Parity**: Consistent behavior across macOS, Linux, Windows
5. **Developer Experience**: Fast startup (native), helpful errors, shell completion
## Command Structure
```
qctl
├── qqq # Project scaffolding
│ ├── new <name> # Create new project from template
│ ├── list # List available templates
│ └── upgrade # Upgrade project to newer template version
├── qbit # Dependency management (QQQ modules)
│ ├── search <query> # Search registry for qBits
│ ├── add <pkg>[@ver] # Add qBit to project
│ ├── remove <pkg> # Remove qBit
│ ├── update [pkg] # Update qBit(s)
│ ├── list # List installed qBits
│ └── resolve # Resolve and lock dependencies
├── qrun # Build, package, deploy
│ ├── package # Build OCI image via Jib
│ ├── publish # Push to registry, create release
│ ├── status # Check deployment status
│ └── logs # Stream application logs
├── qstudio # AI-assisted development
│ └── plan # Generate implementation plan
├── auth # Authentication
│ ├── login # Authenticate (device flow or API key)
│ ├── logout # Clear credentials
│ └── whoami # Show current identity
└── cache # Local cache management
├── ls # List cached items
├── prune # Remove old entries
└── clean # Clear entire cache
```
## Module Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ qctl-cli │
│ (Native image aggregator, Main class) │
└─────────────────────────────────────────────────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ qctl-qqq │ │ qctl-qbit │ │ qctl-qrun │ │qctl-qstudio │
│ Templates │ │ Packages │ │ Deploy │ │ AI Plan │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │ │ │
└──────────────┴──────┬───────┴──────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ qctl-core │
│ Config, HTTP, Auth, Cache, Output, Logging, CLI framework │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ qctl-shared │
│ DTOs, SemVer, SPI, Utilities, Constants │
└─────────────────────────────────────────────────────────────────┘
```
## Data Flow: `qctl qqq init`
```
User: qctl qqq init qqq-app my-project
1. Load config (defaults → global → project → env → flags)
2. Resolve template:
- Check local cache for "qqq-app" template
- If missing/outdated, fetch from templates-hub
- Verify signature (warn-only in V1)
3. Prompt for variables (or use --no-prompt defaults)
4. Evaluate computed variables (e.g., packagePath from packageName)
5. Render template via Apache Velocity:
- Process template.yaml manifest (schema v2)
- Substitute $variables in file content and paths
- Convert __VARNAME__ to $VARNAME in paths (CI-safe syntax)
- Apply transforms (delete patterns)
- Copy rendered files to target directory
6. Post-generation hooks (if defined)
7. Output success message with next steps
```
## Configuration Precedence
```
Lowest ──────────────────────────────────────────────────► Highest
Built-in Global Config Project Config Env Vars CLI Flags
defaults ~/...qctl.yaml ./qctl.yaml QCTL_* --flag
```
## Exit Codes
| Code | Meaning | When |
|------|----------------------------|--------------------------------|
| 0 | Success | Operation completed |
| 1 | Generic error | Unexpected failure |
| 2 | Usage/config error | Invalid arguments or config |
| 3 | Network error | Connection failed, timeout |
| 4 | Auth error | 401/403 from API |
| 5 | Not found | 404 from API |
| 6 | Validation error | 400/422, bad input |
| 7 | Integrity error | Hash/signature mismatch |
| 8 | Conflict | 409, state conflict |
| 9 | Cancelled | User interrupted (Ctrl+C) |
## V1 Scope (Current Target)
**Included**:
- `qqq new` from local and HTTPS templates
- `qbit` search/add/list/update/remove with lockfile
- `qrun package` (Jib) and `publish` to registry
- `qstudio plan` (offline, writes plan.md)
- Config loading, validation, profiles
- Native builds for macOS/Linux; jlink for Windows
**Deferred (Post-V1)**:
- Sigstore enforcement (currently warn-only)
- Plugin system
- Self-update mechanism
- `qstudio` diff apply (AI code changes)
- Streaming logs
- Promote/rollback workflows
## Template Structure (new-qqq-application)
```
templates/new-qqq-application/
├── template.yaml # Manifest with prompts, computed vars, transforms
├── template/ # Template files directory
│ ├── README.md # Velocity template with $variables
│ ├── pom.xml # Maven POM with $groupId, $artifactId, etc.
│ └── src/
│ └── main/
│ └── java/
│ └── $packagePath/ # Path variable substitution
│ └── Application.java # Uses $packageName, $projectName
```
**template.yaml Schema v2**:
```yaml
schemaVersion: 2
id: new-qqq-application
name: New QQQ Application
version: 1.0.0
prompts:
- name: packageName
message: Java package name
type: text
defaultValue: com.example
required: true
computed:
- name: packagePath
expression: "$str.replace($packageName, '.', '/')"
transforms:
- type: delete
pattern: "**/.gitkeep"
```
## Key Files
- `qctl.yaml` - Project and global configuration
- `qbits.lock` - Locked dependency versions (JSON)
- `vendor/qbits/` - Vendored qBit packages
- `.qctl/` - Project-local qctl state
## Technology Stack
- **Language**: Java 21 (Temurin/GraalVM)
- **CLI**: Picocli 4.7.5
- **Config**: Jackson YAML + JSON Schema validation
- **HTTP**: JDK HttpClient with retry/backoff
- **Templates**: Apache Velocity 2.4.1
- **Build**: Maven 3.9+, GraalVM native-image
- **Test**: JUnit 5, AssertJ, Mockito, Testcontainers
## Testing Architecture
```
qctl-integration-tests/
├── src/test/java/io/qrun/qctl/e2e/
│ ├── InitCommandE2ETest.java # CLI flag tests
│ ├── InitCommandFullE2ETest.java # Full variable/transform tests
│ └── harness/
│ ├── CommandTestHarness.java # In-process command execution
│ ├── TestTemplateRegistry.java # Fixture-based registry
│ └── TestableConsoleUI.java # Simulated user input
└── src/test/resources/fixtures/templates/
├── test-minimal/ # Basic template
├── test-computed-vars/ # Computed variable tests
├── test-full-substitution/ # Variable replacement tests
└── test-transforms/ # Transform operation tests
```
**Path Variable Convention**: Use `__VARNAME__` in directory names for templates. The engine converts this to `$VARNAME` before Velocity processing, avoiding shell/Maven interpretation issues.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+242
View File
@@ -0,0 +1,242 @@
# Session State
Last updated: 2026-01-03
## Current Status
**Stories #18-25 complete.** All qqq template system stories finished except #26 (merge mode). Ready for Story #26 or to start Epic #11 (qbit package management).
## Recently Completed
### Story #25 - Improve Error Messages (2026-01-04)
Implemented structured error handling with recovery suggestions:
- Created `SuggestionEngine` with Levenshtein distance for fuzzy matching
- Created `TemplateError` with categories mapped to exit codes
- Created `ErrorFormatter` for consistent ANSI-colored output
- Added "Did you mean?" suggestions for mistyped template names
- Added `--verbose` flag to show stack traces
- Added 27 unit tests (13 SuggestionEngine, 6 TemplateError, 8 ErrorFormatter)
- Updated InitCommand and ListCommand to use structured errors
### Story #24 - Add Prompt Validation (2026-01-03)
Implemented input validation with retry loop for template prompts:
- Created `PromptValidation` record for validation rules (pattern, length, range, enum)
- Created `PromptValidator` class with support for required, pattern, length, range, and enum validation
- Updated `PromptRunner` to validate input and re-prompt on failure
- Added validation for `--var` CLI overrides with fallback to interactive prompt
- Added `ConsoleUI` methods for validation feedback (error checkmark, success checkmark, warning)
- Added 20 unit tests for PromptValidator
Manifest schema now supports:
```yaml
prompts:
- name: packageName
message: "Java package"
required: true
validation:
pattern: "^[a-z][a-z0-9]*(\\.[a-z][a-z0-9]*)*$"
message: "Must be a valid Java package name"
```
### Story #23 - Voyage Mock API Integration (2026-01-03)
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
### Story #22 - Migrate templates-hub to Velocity (2026-01-03)
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
### 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`
- **Clean**: Yes (no uncommitted changes)
## Next Steps / TODO
### v0.2.0 - Create & Manage (Priority)
**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~~ DONE
8. ~~#25 - Improve error messages~~ DONE
9. #26 - Implement --merge mode (next)
**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 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 |
| `.github/workflows/build.yml` | CI/CD pipeline |
+55
View File
@@ -0,0 +1,55 @@
# Testing Guide
qctl uses JUnit 5 with comprehensive E2E tests that verify actual file content, variable substitution, and generated project validity.
## Running Tests
```bash
# All tests
mvn verify
# Integration tests only
mvn test -pl qctl-integration-tests
# Specific test class
mvn test -pl qctl-integration-tests -Dtest=InitCommandFullE2ETest
```
## Test Fixtures
Fixtures in `qctl-integration-tests/src/test/resources/fixtures/templates/`:
| Fixture | Purpose |
|---------|---------|
| `test-minimal` | Basic template with no variables |
| `test-computed-vars` | Computed variable evaluation (`packagePath` from `packageName`) |
| `test-full-substitution` | Variable replacement in content and paths |
| `test-transforms` | Delete and rename transforms |
| `test-with-prompts` | Interactive prompt handling |
| `test-variable-typo` | "Did you mean?" error suggestions |
| `test-transform-error` | Error message quality |
## Path Variable Convention
Use `__VARNAME__` syntax in directory names (e.g., `__packagePath__`). The template engine converts this to `$VARNAME` before Velocity processing, avoiding shell/Maven interpretation issues on CI.
```
template/src/main/java/__packagePath__/App.java
↓ converted to
template/src/main/java/$packagePath/App.java
↓ Velocity renders
src/main/java/com/example/app/App.java
```
## Test Harness
- **CommandTestHarness**: Executes commands in-process, captures stdout/stderr/exit code
- **TestTemplateRegistry**: Provides fixtures without network access
- **TestableConsoleUI**: Simulates user input for interactive prompts
## Adding a Fixture
1. Create directory under `fixtures/templates/<name>/`
2. Add `template.yaml` manifest (schema v2)
3. Add `template/` directory with files
4. Register in `TestTemplateRegistry.java`
+16
View File
@@ -0,0 +1,16 @@
# Maintainer: QRun-IO <contact@qrun.io>
pkgname=qctl-bin
pkgver=0.0.2
pkgrel=1
pkgdesc="CLI for the QRun ecosystem - scaffold, package, and deploy QQQ apps"
arch=('x86_64')
url="https://github.com/QRun-IO/qctl"
license=('AGPL-3.0')
provides=('qctl')
conflicts=('qctl')
source_x86_64=("https://github.com/QRun-IO/qctl/releases/download/v${pkgver}/qctl-linux-amd64")
sha256sums_x86_64=('374f0f10a8aa781e1b5b8d72483ec257dfee9794b69419c7700f55ad7fbcf4b3')
package() {
install -Dm755 "${srcdir}/qctl-linux-amd64" "${pkgdir}/usr/bin/qctl"
}
+11
View File
@@ -0,0 +1,11 @@
FROM alpine:3.19 AS base
ARG TARGETARCH
RUN apk add --no-cache ca-certificates git
COPY qctl-linux-${TARGETARCH} /usr/local/bin/qctl
RUN chmod +x /usr/local/bin/qctl
ENTRYPOINT ["qctl"]
CMD ["--help"]
+39
View File
@@ -0,0 +1,39 @@
class Qctl < Formula
desc "CLI for the QRun ecosystem - scaffold, package, and deploy QQQ apps"
homepage "https://github.com/QRun-IO/qctl"
version "0.1.0"
license "AGPL-3.0"
on_macos do
on_arm do
url "https://github.com/QRun-IO/qctl/releases/download/v#{version}/qctl-macos-arm64"
sha256 "PLACEHOLDER_ARM64_SHA256"
end
on_intel do
url "https://github.com/QRun-IO/qctl/releases/download/v#{version}/qctl-macos-amd64"
sha256 "PLACEHOLDER_AMD64_SHA256"
end
end
on_linux do
on_arm do
url "https://github.com/QRun-IO/qctl/releases/download/v#{version}/qctl-linux-arm64"
sha256 "PLACEHOLDER_LINUX_ARM64_SHA256"
end
on_intel do
url "https://github.com/QRun-IO/qctl/releases/download/v#{version}/qctl-linux-amd64"
sha256 "PLACEHOLDER_LINUX_AMD64_SHA256"
end
end
depends_on "git" => :recommended
def install
binary = Dir["qctl*"].first
bin.install binary => "qctl"
end
test do
assert_match "qctl #{version}", shell_output("#{bin}/qctl --version")
end
end
+23
View File
@@ -0,0 +1,23 @@
{
"version": "0.0.2",
"description": "CLI for the QRun ecosystem - scaffold, package, and deploy QQQ apps",
"homepage": "https://github.com/QRun-IO/qctl",
"license": "AGPL-3.0",
"architecture": {
"64bit": {
"url": "https://github.com/QRun-IO/qctl/releases/download/v0.0.2/qctl-windows-amd64.exe",
"hash": "358aee9b7655afe8f86342ecdbebe150bdda9a1868d1848718876a18faab6f5e"
}
},
"bin": [["qctl-windows-amd64.exe", "qctl"]],
"checkver": {
"github": "https://github.com/QRun-IO/qctl"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/QRun-IO/qctl/releases/download/v0.0.2/qctl-windows-amd64.exe"
}
}
}
}
+69 -20
View File
@@ -5,7 +5,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<version>${revision}</version>
<packaging>pom</packaging>
<name>qctl</name>
<description>qctl CLI - multi-module project</description>
@@ -22,24 +22,26 @@
</modules>
<properties>
<revision>0.2.1</revision>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<picocli.version>4.7.5</picocli.version>
<jackson.version>2.17.1</jackson.version>
<logback.version>1.5.13</logback.version>
<slf4j.version>2.0.13</slf4j.version>
<networknt.json.schema.version>1.4.1</networknt.json.schema.version>
<handlebars.version>4.4.0</handlebars.version>
<bouncycastle.version>1.78.1</bouncycastle.version>
<junit.jupiter.version>5.10.2</junit.jupiter.version>
<assertj.version>3.25.3</assertj.version>
<mockito.version>5.12.0</mockito.version>
<testcontainers.version>1.19.7</testcontainers.version>
<spotless.version>2.43.0</spotless.version>
<checkstyle.version>10.17.0</checkstyle.version>
<errorprone.version>2.28.0</errorprone.version>
<native.maven.plugin.version>0.10.4</native.maven.plugin.version>
<picocli.version>4.7.6</picocli.version>
<jackson.version>2.18.2</jackson.version>
<logback.version>1.5.15</logback.version>
<slf4j.version>2.0.16</slf4j.version>
<networknt.json.schema.version>1.5.4</networknt.json.schema.version>
<velocity.version>2.4.1</velocity.version>
<bouncycastle.version>1.79</bouncycastle.version>
<junit.jupiter.version>5.11.4</junit.jupiter.version>
<assertj.version>3.27.2</assertj.version>
<mockito.version>5.15.2</mockito.version>
<testcontainers.version>1.20.4</testcontainers.version>
<spotless.version>2.44.0</spotless.version>
<checkstyle.version>10.21.1</checkstyle.version>
<errorprone.version>2.36.0</errorprone.version>
<native.maven.plugin.version>0.10.5</native.maven.plugin.version>
<git.commit.id.plugin.version>9.0.1</git.commit.id.plugin.version>
</properties>
<dependencyManagement>
@@ -64,10 +66,15 @@
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
<version>1.6.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<version>3.13.0</version>
<configuration>
<release>${maven.compiler.release}</release>
<showWarnings>true</showWarnings>
@@ -77,7 +84,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<version>3.5.2</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@@ -95,12 +102,54 @@
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.1</version>
<version>3.5.0</version>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
<configuration>
<updatePomFile>true</updatePomFile>
<flattenMode>resolveCiFriendliesOnly</flattenMode>
</configuration>
<executions>
<execution>
<id>flatten</id>
<phase>process-resources</phase>
<goals>
<goal>flatten</goal>
</goals>
</execution>
<execution>
<id>flatten.clean</id>
<phase>clean</phase>
<goals>
<goal>clean</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>io.github.git-commit-id</groupId>
<artifactId>git-commit-id-maven-plugin</artifactId>
<version>${git.commit.id.plugin.version}</version>
<executions>
<execution>
<id>get-git-info</id>
<phase>initialize</phase>
<goals>
<goal>revision</goal>
</goals>
</execution>
</executions>
<configuration>
<failOnNoGitDirectory>false</failOnNoGitDirectory>
<generateGitPropertiesFile>false</generateGitPropertiesFile>
</configuration>
</plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
@@ -148,7 +197,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.3.1</version>
<version>3.6.0</version>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
+41 -36
View File
@@ -6,7 +6,7 @@
<parent>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<version>${revision}</version>
</parent>
<artifactId>qctl-cli</artifactId>
<name>qctl-cli</name>
@@ -37,39 +37,44 @@
<version>${project.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>${native.maven.plugin.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<mainClass>io.qrun.qctl.core.Main</mainClass>
<imageName>qctl</imageName>
<useArgFile>false</useArgFile>
<buildArgs>
<buildArg>--no-fallback</buildArg>
<buildArg>--enable-url-protocols=http,https</buildArg>
<buildArg>-H:+ReportExceptionStackTraces</buildArg>
<buildArg>-H:IncludeResources=.*\.properties$</buildArg>
<buildArg>-H:IncludeResources=.*\.xml$</buildArg>
<buildArg>-H:IncludeResources=.*\.json$</buildArg>
<buildArg>-H:IncludeResources=.*\.yaml$</buildArg>
<buildArg>-H:IncludeResources=.*\.yml$</buildArg>
<buildArg>-H:IncludeResources=META-INF/services/.*</buildArg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>${native.maven.plugin.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<mainClass>io.qrun.qctl.core.Main</mainClass>
<imageName>qctl</imageName>
<useArgFile>false</useArgFile>
<buildArgs>
<buildArg>--no-fallback</buildArg>
<buildArg>--enable-url-protocols=http,https</buildArg>
<buildArg>-H:+ReportExceptionStackTraces</buildArg>
<buildArg>-H:IncludeResources=.*\.properties$</buildArg>
<buildArg>-H:IncludeResources=.*\.xml$</buildArg>
<buildArg>-H:IncludeResources=.*\.json$</buildArg>
<buildArg>-H:IncludeResources=.*\.yaml$</buildArg>
<buildArg>-H:IncludeResources=.*\.yml$</buildArg>
<buildArg>-H:IncludeResources=META-INF/services/.*</buildArg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
+17 -51
View File
@@ -6,7 +6,7 @@
<parent>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<version>${revision}</version>
</parent>
<artifactId>qctl-core</artifactId>
<name>qctl-core</name>
@@ -75,6 +75,22 @@
</dependency>
</dependencies>
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>version.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>version.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<plugin>
@@ -117,54 +133,4 @@
</plugins>
</build>
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>${native.maven.plugin.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<skip>false</skip>
<mainClass>io.qrun.qctl.core.Main</mainClass>
<imageName>qctl</imageName>
<useArgFile>false</useArgFile>
<agent>
<enabled>true</enabled>
<defaultMode>Standard</defaultMode>
</agent>
<buildArgs>
<buildArg>--no-fallback</buildArg>
<buildArg>--enable-http</buildArg>
<buildArg>--enable-https</buildArg>
<buildArg>--enable-url-protocols=http,https</buildArg>
<buildArg>--initialize-at-build-time=org.slf4j</buildArg>
<buildArg>-H:+ReportExceptionStackTraces</buildArg>
<buildArg>-H:+UnlockExperimentalVMOptions</buildArg>
<buildArg>-H:IncludeResources=.*\.properties$</buildArg>
<buildArg>-H:IncludeResources=.*\.xml$</buildArg>
<buildArg>-H:IncludeResources=.*\.json$</buildArg>
<buildArg>-H:IncludeResources=.*\.yaml$</buildArg>
<buildArg>-H:IncludeResources=.*\.yml$</buildArg>
<buildArg>-H:IncludeResources=META-INF/services/.*</buildArg>
<buildArg>-H:ReflectionConfigurationFiles=${project.basedir}/src/main/resources/META-INF/native-image/reflect-config.json</buildArg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
@@ -13,6 +13,7 @@
package io.qrun.qctl.core;
import io.qrun.qctl.shared.ExitCodes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import picocli.CommandLine;
@@ -33,12 +34,13 @@ import picocli.CommandLine.Spec;
@Command(
name = "qctl",
mixinStandardHelpOptions = true,
version = { "qctl 0.1.0" },
versionProvider = VersionProvider.class,
description = "qctl CLI",
subcommands = {
HelpCommand.class,
io.qrun.qctl.core.cli.AuthCommand.class,
io.qrun.qctl.core.cli.CacheCommand.class
io.qrun.qctl.core.cli.CacheCommand.class,
io.qrun.qctl.core.cli.DoctorCommand.class
})
public class Main implements Runnable
{
@@ -132,7 +134,7 @@ public class Main implements Runnable
catch(Exception e)
{
log.error("Startup failed", e);
System.exit(2);
System.exit(ExitCodes.USAGE);
}
}
}
@@ -0,0 +1,80 @@
/*
* 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.core;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
import picocli.CommandLine.IVersionProvider;
/*******************************************************************************
* Provides version information for the CLI from build-time properties.
*
* Reads version.properties populated by Maven resource filtering.
*
* @since 0.1.0
*******************************************************************************/
public class VersionProvider implements IVersionProvider
{
private static final String VERSION_PROPERTIES = "/version.properties";
/***************************************************************************
* Returns version strings for display by picocli --version flag.
*
* @return array of version strings
* @since 0.1.0
***************************************************************************/
@Override
public String[] getVersion()
{
Properties props = new Properties();
try(InputStream is = getClass().getResourceAsStream(VERSION_PROPERTIES))
{
if(is != null)
{
props.load(is);
}
}
catch(IOException e)
{
// Fall back to defaults
}
String version = props.getProperty("version", "unknown");
String buildTime = props.getProperty("build.time", "");
String gitCommit = props.getProperty("git.commit", "");
if(!gitCommit.isEmpty())
{
return new String[] {
"qctl " + version,
"Build: " + buildTime,
"Commit: " + gitCommit
};
}
else if(!buildTime.isEmpty())
{
return new String[] {
"qctl " + version,
"Build: " + buildTime
};
}
else
{
return new String[] { "qctl " + version };
}
}
}
@@ -0,0 +1,171 @@
/*
* 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.core.cli;
import java.util.List;
import io.qrun.qctl.core.doctor.DependencyChecker;
import io.qrun.qctl.core.doctor.DependencyStatus;
import io.qrun.qctl.shared.ExitCodes;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/*******************************************************************************
* Check system dependencies and report status.
*
* Verifies that required tools (Java, Maven, Git) are installed and meet
* version requirements. Reports optional tools (Docker) as well.
*
* @since 0.2.0
*******************************************************************************/
@Command(
name = "doctor",
description = "Check system dependencies and configuration"
)
public class DoctorCommand implements Runnable
{
private static final String ANSI_RESET = "\u001B[0m";
private static final String ANSI_GREEN = "\u001B[32m";
private static final String ANSI_RED = "\u001B[31m";
private static final String ANSI_YELLOW = "\u001B[33m";
private static final String ANSI_BOLD = "\u001B[1m";
private static final String ANSI_DIM = "\u001B[2m";
private static final String CHECK_MARK = "✓";
private static final String CROSS_MARK = "✗";
private static final String WARN_MARK = "!";
@Option(names = {"-v", "--verbose"}, description = "Show detailed information")
private boolean verbose;
/***************************************************************************
* Run the doctor command.
*
* @since 0.2.0
***************************************************************************/
@Override
public void run()
{
System.out.println();
System.out.println(ANSI_BOLD + "qctl doctor" + ANSI_RESET);
System.out.println(ANSI_DIM + "Checking system dependencies..." + ANSI_RESET);
System.out.println();
List<DependencyStatus> results = DependencyChecker.checkAll();
int requiredMissing = 0;
int optionalMissing = 0;
for(DependencyStatus status : results)
{
printStatus(status);
if(!status.isOk() && !status.isOptional())
{
requiredMissing++;
}
else if(status.isOptional())
{
optionalMissing++;
}
}
System.out.println();
if(requiredMissing > 0)
{
System.out.println(ANSI_RED + requiredMissing + " required "
+ (requiredMissing == 1 ? "dependency" : "dependencies")
+ " missing." + ANSI_RESET);
System.out.println("Run " + ANSI_BOLD + "qctl doctor -v" + ANSI_RESET + " for installation hints.");
System.exit(ExitCodes.VALIDATION);
}
else if(optionalMissing > 0)
{
System.out.println(ANSI_YELLOW + optionalMissing + " optional "
+ (optionalMissing == 1 ? "dependency" : "dependencies")
+ " missing." + ANSI_RESET);
if(verbose)
{
System.out.println("Optional dependencies enable additional features but are not required.");
}
}
else
{
System.out.println(ANSI_GREEN + "All dependencies satisfied!" + ANSI_RESET);
}
}
/***************************************************************************
* Print a single dependency status line.
*
* @param status dependency status to print
* @since 0.2.0
***************************************************************************/
private void printStatus(DependencyStatus status)
{
String icon;
String color;
switch(status.status())
{
case OK:
icon = ANSI_GREEN + CHECK_MARK + ANSI_RESET;
color = ANSI_RESET;
break;
case NOT_FOUND:
case VERSION_MISMATCH:
case ERROR:
icon = ANSI_RED + CROSS_MARK + ANSI_RESET;
color = ANSI_RED;
break;
case OPTIONAL_MISSING:
icon = ANSI_YELLOW + WARN_MARK + ANSI_RESET;
color = ANSI_YELLOW;
break;
default:
icon = "?";
color = ANSI_RESET;
}
StringBuilder line = new StringBuilder();
line.append(" ").append(icon).append(" ");
if(status.isOk())
{
line.append(ANSI_BOLD).append(status.name()).append(ANSI_RESET);
line.append(" ").append(status.version());
if(status.path() != null && verbose)
{
line.append(ANSI_DIM).append(" ").append(status.path()).append(ANSI_RESET);
}
}
else
{
line.append(color).append(ANSI_BOLD).append(status.name()).append(ANSI_RESET);
line.append(color).append(" ").append(status.getMessage()).append(ANSI_RESET);
}
System.out.println(line);
if(verbose && status.hint() != null && !status.isOk())
{
System.out.println(ANSI_DIM + " " + status.hint() + ANSI_RESET);
}
}
}
@@ -15,6 +15,7 @@ package io.qrun.qctl.core.cli.auth;
import io.qrun.qctl.core.auth.TokenStore;
import io.qrun.qctl.core.sys.SystemPaths;
import io.qrun.qctl.shared.ExitCodes;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
@@ -43,7 +44,7 @@ public class LoginCommand implements Runnable
catch(Exception e)
{
System.err.println("auth login error: " + e.getMessage());
System.exit(1);
System.exit(ExitCodes.AUTH);
}
}
@@ -15,6 +15,7 @@ package io.qrun.qctl.core.cli.auth;
import io.qrun.qctl.core.auth.TokenStore;
import io.qrun.qctl.core.sys.SystemPaths;
import io.qrun.qctl.shared.ExitCodes;
import picocli.CommandLine.Command;
@@ -32,7 +33,7 @@ public class LogoutCommand implements Runnable
catch(Exception e)
{
System.err.println("auth logout error: " + e.getMessage());
System.exit(1);
System.exit(ExitCodes.AUTH);
}
}
@@ -17,31 +17,18 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
/**
/*******************************************************************************
* Simple deep-merge utility for JSON objects.
*/
*
* Why: Config loading overlays several sources (defaults, files, env),
* and we need deterministic, recursive object merging semantics.
* @since 0.1.0
*******************************************************************************/
final class JsonMerge
{
/***************************************************************************
* Deep JSON merge helper.
*
* Why: Config loading overlays several sources (defaults, files, env),
* and we need deterministic, recursive object merging semantics.
* @since 0.1.0
***************************************************************************/
/**
* Deep-merges {@code override} into {@code base}. Objects are merged recursively; other types
* replace the base value.
*
* @param base
* base JSON (may be null)
* @param override
* override JSON
*
* @return merged JSON node
*/
/***************************************************************************
* Merges override into base using object-wise deep merge.
* Deep-merges override into base using object-wise deep merge.
* Objects are merged recursively; other types replace the base value.
*
* @param base base JSON (may be null)
* @param override override JSON (may be null)
@@ -0,0 +1,439 @@
/*
* 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.core.doctor;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/*******************************************************************************
* Checks for required external dependencies (Java, Maven, Git, Docker).
*
* Provides both individual checks and a comprehensive report.
*
* @since 0.2.0
*******************************************************************************/
public class DependencyChecker
{
private static final int DEFAULT_TIMEOUT_MS = 5000;
private static final int THREAD_JOIN_TIMEOUT = 1000;
private static final int ERROR_EXIT_CODE = -1;
private static final int MIN_JAVA_VERSION = 21;
private static final String JAVA_VERSION_PATTERN = "version \"?(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?";
private static final String MVN_VERSION_PATTERN = "Apache Maven (\\d+\\.\\d+\\.\\d+)";
private static final String GIT_VERSION_PATTERN = "git version (\\d+\\.\\d+\\.\\d+)";
/***************************************************************************
* Check if Java is installed and meets minimum version requirement.
*
* @return dependency status for Java
* @since 0.2.0
***************************************************************************/
public static DependencyStatus checkJava()
{
return checkJava(MIN_JAVA_VERSION);
}
/***************************************************************************
* Check if Java is installed and meets specified minimum version.
*
* @param minVersion minimum required Java version
* @return dependency status for Java
* @since 0.2.0
***************************************************************************/
public static DependencyStatus checkJava(int minVersion)
{
try
{
ProcessResult result = runCommand("java", "-version");
if(result.exitCode() != 0)
{
return DependencyStatus.notFound("Java", "java", "Install Java " + minVersion + "+ from https://adoptium.net/");
}
///////////////////////////////////////////////////////////////////
// Java prints version to stderr, so check both streams //
///////////////////////////////////////////////////////////////////
String output = result.stderr().isEmpty() ? result.stdout() : result.stderr();
Pattern pattern = Pattern.compile(JAVA_VERSION_PATTERN);
Matcher matcher = pattern.matcher(output);
if(matcher.find())
{
int majorVersion = Integer.parseInt(matcher.group(1));
String fullVersion = extractFullVersion(output, "java");
String path = findExecutablePath("java");
if(majorVersion >= minVersion)
{
return DependencyStatus.ok("Java", fullVersion, path);
}
else
{
return DependencyStatus.versionMismatch("Java", fullVersion, minVersion + "+", path,
"Upgrade to Java " + minVersion + "+ from https://adoptium.net/");
}
}
return DependencyStatus.notFound("Java", "java", "Install Java " + minVersion + "+ from https://adoptium.net/");
}
catch(Exception e)
{
return DependencyStatus.error("Java", e.getMessage());
}
}
/***************************************************************************
* Check if Maven is installed.
*
* @return dependency status for Maven
* @since 0.2.0
***************************************************************************/
public static DependencyStatus checkMaven()
{
try
{
ProcessResult result = runCommand("mvn", "-version");
if(result.exitCode() != 0)
{
return DependencyStatus.notFound("Maven", "mvn", "Install Maven from https://maven.apache.org/download.cgi");
}
Pattern pattern = Pattern.compile(MVN_VERSION_PATTERN);
Matcher matcher = pattern.matcher(result.stdout());
if(matcher.find())
{
String version = matcher.group(1);
String path = findExecutablePath("mvn");
return DependencyStatus.ok("Maven", version, path);
}
return DependencyStatus.notFound("Maven", "mvn", "Install Maven from https://maven.apache.org/download.cgi");
}
catch(Exception e)
{
return DependencyStatus.error("Maven", e.getMessage());
}
}
/***************************************************************************
* Check if Git is installed.
*
* @return dependency status for Git
* @since 0.2.0
***************************************************************************/
public static DependencyStatus checkGit()
{
try
{
ProcessResult result = runCommand("git", "--version");
if(result.exitCode() != 0)
{
return DependencyStatus.notFound("Git", "git", "Install Git from https://git-scm.com/downloads");
}
Pattern pattern = Pattern.compile(GIT_VERSION_PATTERN);
Matcher matcher = pattern.matcher(result.stdout());
if(matcher.find())
{
String version = matcher.group(1);
String path = findExecutablePath("git");
return DependencyStatus.ok("Git", version, path);
}
return DependencyStatus.notFound("Git", "git", "Install Git from https://git-scm.com/downloads");
}
catch(Exception e)
{
return DependencyStatus.error("Git", e.getMessage());
}
}
/***************************************************************************
* Check if Docker is installed (optional dependency).
*
* @return dependency status for Docker
* @since 0.2.0
***************************************************************************/
public static DependencyStatus checkDocker()
{
try
{
ProcessResult result = runCommand("docker", "--version");
if(result.exitCode() != 0)
{
return DependencyStatus.optional("Docker", "docker", "Install Docker from https://docs.docker.com/get-docker/");
}
///////////////////////////////////////////////////////////////////
// Docker version output: "Docker version 24.0.5, build ..." //
///////////////////////////////////////////////////////////////////
Pattern pattern = Pattern.compile("Docker version (\\d+\\.\\d+\\.\\d+)");
Matcher matcher = pattern.matcher(result.stdout());
if(matcher.find())
{
String version = matcher.group(1);
String path = findExecutablePath("docker");
return DependencyStatus.ok("Docker", version, path);
}
return DependencyStatus.optional("Docker", "docker", "Install Docker from https://docs.docker.com/get-docker/");
}
catch(Exception e)
{
return DependencyStatus.optional("Docker", "docker", "Install Docker from https://docs.docker.com/get-docker/");
}
}
/***************************************************************************
* Check all dependencies and return a comprehensive report.
*
* @return list of dependency statuses
* @since 0.2.0
***************************************************************************/
public static List<DependencyStatus> checkAll()
{
List<DependencyStatus> results = new ArrayList<>();
results.add(checkJava());
results.add(checkMaven());
results.add(checkGit());
results.add(checkDocker());
return results;
}
/***************************************************************************
* Require Java to be installed with minimum version. Throws if not met.
*
* @param minVersion minimum required Java version
* @throws DependencyException if Java is not installed or version too low
* @since 0.2.0
***************************************************************************/
public static void requireJava(int minVersion) throws DependencyException
{
DependencyStatus status = checkJava(minVersion);
if(!status.isOk())
{
throw new DependencyException(status);
}
}
/***************************************************************************
* Require Maven to be installed. Throws if not found.
*
* @throws DependencyException if Maven is not installed
* @since 0.2.0
***************************************************************************/
public static void requireMaven() throws DependencyException
{
DependencyStatus status = checkMaven();
if(!status.isOk())
{
throw new DependencyException(status);
}
}
/***************************************************************************
* Require Git to be installed. Throws if not found.
*
* @throws DependencyException if Git is not installed
* @since 0.2.0
***************************************************************************/
public static void requireGit() throws DependencyException
{
DependencyStatus status = checkGit();
if(!status.isOk())
{
throw new DependencyException(status);
}
}
/***************************************************************************
* Run a command and capture output.
*
* @param command command and arguments
* @return process result with exit code and output
* @since 0.2.0
***************************************************************************/
private static ProcessResult runCommand(String... command)
{
try
{
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(false);
Process process = pb.start();
StringBuilder stdout = new StringBuilder();
StringBuilder stderr = new StringBuilder();
Thread stdoutThread = new Thread(() -> readStream(process.getInputStream(), stdout));
Thread stderrThread = new Thread(() -> readStream(process.getErrorStream(), stderr));
stdoutThread.start();
stderrThread.start();
boolean completed = process.waitFor(DEFAULT_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
if(!completed)
{
process.destroyForcibly();
return new ProcessResult(ERROR_EXIT_CODE, "", "Command timed out");
}
stdoutThread.join(THREAD_JOIN_TIMEOUT);
stderrThread.join(THREAD_JOIN_TIMEOUT);
return new ProcessResult(process.exitValue(), stdout.toString(), stderr.toString());
}
catch(Exception e)
{
return new ProcessResult(ERROR_EXIT_CODE, "", e.getMessage());
}
}
/***************************************************************************
* Read an input stream into a StringBuilder.
*
* @param inputStream stream to read
* @param output builder to append to
* @since 0.2.0
***************************************************************************/
private static void readStream(java.io.InputStream inputStream, StringBuilder output)
{
try(BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)))
{
String line;
while((line = reader.readLine()) != null)
{
output.append(line).append("\n");
}
}
catch(Exception e)
{
// Ignore read errors
}
}
/***************************************************************************
* Find the path to an executable using 'which' (Unix) or 'where' (Windows).
*
* @param executable name of executable
* @return path or "unknown"
* @since 0.2.0
***************************************************************************/
private static String findExecutablePath(String executable)
{
try
{
String os = System.getProperty("os.name").toLowerCase();
String[] command = os.contains("win")
? new String[] { "where", executable }
: new String[] { "which", executable };
ProcessResult result = runCommand(command);
if(result.exitCode() == 0 && !result.stdout().isEmpty())
{
return result.stdout().trim().split("\n")[0];
}
}
catch(Exception e)
{
// Ignore
}
return "unknown";
}
/***************************************************************************
* Extract full version string from command output.
*
* @param output command output
* @param tool tool name for context
* @return version string or "unknown"
* @since 0.2.0
***************************************************************************/
private static String extractFullVersion(String output, String tool)
{
if("java".equals(tool))
{
////////////////////////////////////////////////////////////////
// Try to extract version like "21.0.5" or "21" //
////////////////////////////////////////////////////////////////
Pattern pattern = Pattern.compile("version \"?(\\d+(?:\\.\\d+)*(?:[._][\\w-]+)?)");
Matcher matcher = pattern.matcher(output);
if(matcher.find())
{
String version = matcher.group(1);
////////////////////////////////////////////////////////////
// Check for GraalVM or other JVM vendors //
////////////////////////////////////////////////////////////
if(output.contains("GraalVM"))
{
return version + " (GraalVM)";
}
else if(output.contains("Temurin"))
{
return version + " (Temurin)";
}
else if(output.contains("OpenJDK"))
{
return version + " (OpenJDK)";
}
return version;
}
}
return "unknown";
}
/***************************************************************************
* Result of running an external process.
*
* @param exitCode process exit code
* @param stdout standard output
* @param stderr standard error
* @since 0.2.0
***************************************************************************/
private record ProcessResult(int exitCode, String stdout, String stderr) {}
}
@@ -0,0 +1,70 @@
/*
* 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.core.doctor;
/*******************************************************************************
* Exception thrown when a required dependency is not satisfied.
*
* @since 0.2.0
*******************************************************************************/
public class DependencyException extends Exception
{
private final DependencyStatus status;
/***************************************************************************
* Create exception from dependency status.
*
* @param status the failing dependency status
* @since 0.2.0
***************************************************************************/
public DependencyException(DependencyStatus status)
{
super(status.getMessage());
this.status = status;
}
/***************************************************************************
* Get the dependency status that caused this exception.
*
* @return dependency status
* @since 0.2.0
***************************************************************************/
public DependencyStatus getStatus()
{
return status;
}
/***************************************************************************
* Get a user-friendly error message with installation hint.
*
* @return formatted error message
* @since 0.2.0
***************************************************************************/
public String getFormattedMessage()
{
StringBuilder sb = new StringBuilder();
sb.append(status.getMessage());
if(status.hint() != null && !status.hint().isEmpty())
{
sb.append("\n").append(status.hint());
}
return sb.toString();
}
}
@@ -0,0 +1,172 @@
/*
* 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.core.doctor;
/*******************************************************************************
* Status of a dependency check.
*
* @since 0.2.0
*******************************************************************************/
public record DependencyStatus(
String name,
Status status,
String version,
String path,
String requiredVersion,
String hint
)
{
/***************************************************************************
* Dependency check status values.
*
* @since 0.2.0
***************************************************************************/
public enum Status
{
OK,
NOT_FOUND,
VERSION_MISMATCH,
OPTIONAL_MISSING,
ERROR
}
/***************************************************************************
* Check if dependency is satisfied.
*
* @return true if OK
* @since 0.2.0
***************************************************************************/
public boolean isOk()
{
return status == Status.OK;
}
/***************************************************************************
* Check if this is an optional dependency.
*
* @return true if optional
* @since 0.2.0
***************************************************************************/
public boolean isOptional()
{
return status == Status.OPTIONAL_MISSING;
}
/***************************************************************************
* Create status for a found and valid dependency.
*
* @param name dependency name
* @param version found version
* @param path executable path
* @return OK status
* @since 0.2.0
***************************************************************************/
public static DependencyStatus ok(String name, String version, String path)
{
return new DependencyStatus(name, Status.OK, version, path, null, null);
}
/***************************************************************************
* Create status for a missing required dependency.
*
* @param name dependency name
* @param executable executable name
* @param hint installation hint
* @return NOT_FOUND status
* @since 0.2.0
***************************************************************************/
public static DependencyStatus notFound(String name, String executable, String hint)
{
return new DependencyStatus(name, Status.NOT_FOUND, null, null, null, hint);
}
/***************************************************************************
* Create status for a version mismatch.
*
* @param name dependency name
* @param foundVersion version that was found
* @param requiredVersion version that is required
* @param path executable path
* @param hint upgrade hint
* @return VERSION_MISMATCH status
* @since 0.2.0
***************************************************************************/
public static DependencyStatus versionMismatch(String name, String foundVersion,
String requiredVersion, String path, String hint)
{
return new DependencyStatus(name, Status.VERSION_MISMATCH, foundVersion, path, requiredVersion, hint);
}
/***************************************************************************
* Create status for a missing optional dependency.
*
* @param name dependency name
* @param executable executable name
* @param hint installation hint
* @return OPTIONAL_MISSING status
* @since 0.2.0
***************************************************************************/
public static DependencyStatus optional(String name, String executable, String hint)
{
return new DependencyStatus(name, Status.OPTIONAL_MISSING, null, null, null, hint);
}
/***************************************************************************
* Create status for an error during check.
*
* @param name dependency name
* @param errorMessage error message
* @return ERROR status
* @since 0.2.0
***************************************************************************/
public static DependencyStatus error(String name, String errorMessage)
{
return new DependencyStatus(name, Status.ERROR, null, null, null, errorMessage);
}
/***************************************************************************
* Get a user-friendly message for this status.
*
* @return status message
* @since 0.2.0
***************************************************************************/
public String getMessage()
{
return switch(status)
{
case OK -> name + " " + version;
case NOT_FOUND -> name + " not found";
case VERSION_MISMATCH -> name + " " + version + " found, but " + requiredVersion + " required";
case OPTIONAL_MISSING -> name + " not found (optional)";
case ERROR -> name + " check failed: " + hint;
};
}
}
@@ -13,9 +13,12 @@
package io.qrun.qctl.core.sys;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
@@ -26,6 +29,8 @@ import java.nio.file.Paths;
*/
public final class SystemPaths
{
private static final Logger LOG = LoggerFactory.getLogger(SystemPaths.class);
/***************************************************************************
* Non-instantiable utility class.
*
@@ -97,15 +102,22 @@ public final class SystemPaths
/** Ensures the given directory exists, creating it if needed. */
/***************************************************************************
* Ensures the given directory exists, creating it if needed.
*
* @param p directory path to create
* @return the same path for fluent usage
* @since 0.1.0
***************************************************************************/
public static Path ensureDir(Path p)
{
try
{
Files.createDirectories(p);
}
catch(Exception expected)
catch(IOException e)
{
LOG.debug("Failed to create directory {}: {}", p, e.getMessage());
}
return p;
}
@@ -0,0 +1,3 @@
version=${project.version}
build.time=${maven.build.timestamp}
git.commit=${git.commit.id.abbrev}
+72 -1
View File
@@ -6,11 +6,46 @@
<parent>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<version>${revision}</version>
</parent>
<artifactId>qctl-integration-tests</artifactId>
<name>qctl-integration-tests</name>
<description>Integration and E2E tests for qctl CLI</description>
<dependencies>
<!-- Command modules under test -->
<dependency>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-core</artifactId>
<version>${revision}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-qqq</artifactId>
<version>${revision}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-qbit</artifactId>
<version>${revision}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-qrun</artifactId>
<version>${revision}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-qstudio</artifactId>
<version>${revision}</version>
<scope>test</scope>
</dependency>
<!-- Test frameworks -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -34,5 +69,41 @@
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- Picocli for command parsing -->
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>${picocli.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Run integration tests with failsafe -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
<configuration>
<systemPropertyVariables>
<qctl.native.binary>${project.basedir}/../qctl-cli/target/qctl</qctl.native.binary>
</systemPropertyVariables>
<includes>
<include>**/*IT.java</include>
<include>**/*E2ETest.java</include>
</includes>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,323 @@
/*
* 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.e2e;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import io.qrun.qctl.e2e.harness.CommandTestHarness;
import io.qrun.qctl.e2e.harness.TestTemplateRegistry;
import io.qrun.qctl.qqq.registry.TemplateRegistryFactory;
import io.qrun.qctl.shared.ExitCodes;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Comprehensive E2E tests for the qqq init command using fixture templates.
*
* Tests command-line flags, project generation, and error handling scenarios.
* Uses TestTemplateRegistry to provide fully offline, deterministic tests.
*
* @since 0.2.0
*******************************************************************************/
class InitCommandE2ETest
{
private CommandTestHarness harness;
private TestTemplateRegistry registry;
private InputStream originalIn;
@TempDir
Path tempDir;
@BeforeEach
void setUp() throws IOException
{
harness = new CommandTestHarness();
registry = new TestTemplateRegistry();
registry.loadFromResources("test-minimal");
registry.loadFromResources("test-with-prompts");
TemplateRegistryFactory.setRegistry(registry);
//////////////////////////////////////////////////////////////////////////
// Provide empty input to prevent hanging on unexpected prompts //
//////////////////////////////////////////////////////////////////////////
originalIn = System.in;
System.setIn(new ByteArrayInputStream("\n\n\n".getBytes(StandardCharsets.UTF_8)));
}
@AfterEach
void tearDown()
{
TemplateRegistryFactory.reset();
System.setIn(originalIn);
}
/***************************************************************************
* Test happy path: init with minimal template creates project.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_minimalTemplate_createsProject()
{
Path outputDir = tempDir.resolve("my-project");
harness.execute("qqq", "init", "test-minimal", "--no-prompt",
"-o", outputDir.toString());
//////////////////////////////////////////////////////////////////////////
// For debugging - print actual output if test fails //
//////////////////////////////////////////////////////////////////////////
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout()).contains("Project initialized successfully");
assertThat(outputDir).exists();
assertThat(outputDir.resolve("README.md")).exists();
}
/***************************************************************************
* Test --dry-run flag shows what would be created without creating files.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_dryRun_showsFilesWithoutCreating()
{
Path outputDir = tempDir.resolve("dry-run-test");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--dry-run",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout()).contains("[DRY-RUN]");
assertThat(harness.getStdout()).contains("README.md");
assertThat(outputDir).doesNotExist();
}
/***************************************************************************
* Test directory conflict without --force shows error.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_existingDir_showsConflictError() throws IOException
{
Path outputDir = tempDir.resolve("existing-project");
Files.createDirectories(outputDir);
harness.execute("qqq", "init", "test-minimal", "--no-prompt",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.CONFLICT);
//////////////////////////////////////////////////////////////////////////
// Error message goes to stderr, suggestion goes to stdout //
//////////////////////////////////////////////////////////////////////////
assertThat(harness.getStderr()).contains("target directory already exists");
assertThat(harness.getStdout()).contains("--force");
}
/***************************************************************************
* Test --force flag overwrites existing directory.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_forceFlag_overwritesExistingDir() throws IOException
{
Path outputDir = tempDir.resolve("force-test");
Files.createDirectories(outputDir);
Files.writeString(outputDir.resolve("existing.txt"), "should be preserved");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--force",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout()).contains("Overwriting existing directory");
assertThat(outputDir.resolve("README.md")).exists();
}
/***************************************************************************
* Test --merge flag merges with existing directory.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_mergeFlag_mergesWithExistingDir() throws IOException
{
Path outputDir = tempDir.resolve("merge-test");
Files.createDirectories(outputDir);
Files.writeString(outputDir.resolve("existing.txt"), "should be preserved");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--merge",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout()).contains("Merging with existing directory");
assertThat(outputDir.resolve("existing.txt")).exists();
assertThat(outputDir.resolve("README.md")).exists();
}
/***************************************************************************
* Test --var flag sets template variables.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_varFlag_setsTemplateVariables()
{
Path outputDir = tempDir.resolve("var-test");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=custom-name",
"--var", "packageName=com.acme",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout()).contains("Project initialized successfully");
}
/***************************************************************************
* Test non-existent template shows error with suggestions.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_nonExistentTemplate_showsError()
{
Path outputDir = tempDir.resolve("error-test");
harness.execute("qqq", "init", "not-a-real-template", "--no-prompt",
"-o", outputDir.toString());
//////////////////////////////////////////////////////////////////////////
// Should show error - template not found //
//////////////////////////////////////////////////////////////////////////
assertThat(harness.getStdout() + harness.getStderr())
.containsIgnoringCase("not found")
.contains("not-a-real-template");
}
/***************************************************************************
* Test --verbose flag includes extra error details.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_verboseFlag_showsDetailedErrors()
{
Path outputDir = tempDir.resolve("verbose-test");
harness.execute("qqq", "init", "not-a-real-template", "--no-prompt", "-v",
"-o", outputDir.toString());
//////////////////////////////////////////////////////////////////////////
// Verbose output should include stack trace or extra context //
//////////////////////////////////////////////////////////////////////////
String output = harness.getStdout() + harness.getStderr();
assertThat(output).containsIgnoringCase("not found");
}
/***************************************************************************
* Test init with multiple --var flags.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_multipleVars_allApplied()
{
Path outputDir = tempDir.resolve("multi-var-test");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=my-app",
"--var", "packageName=org.example",
"--var", "includeTests=false",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
}
/***************************************************************************
* Test --skip-hooks flag prevents post-gen hooks from running.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_skipHooks_noHooksExecuted()
{
Path outputDir = tempDir.resolve("skip-hooks-test");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--skip-hooks",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout()).contains("Project initialized successfully");
}
/***************************************************************************
* Test combined flags work together.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_combinedFlags_dryRunWithForce() throws IOException
{
Path outputDir = tempDir.resolve("combined-test");
Files.createDirectories(outputDir);
harness.execute("qqq", "init", "test-minimal", "--no-prompt",
"--dry-run", "--force",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout()).contains("[DRY-RUN]");
//////////////////////////////////////////////////////////////////////////
// In dry-run mode, existing files should not be modified //
//////////////////////////////////////////////////////////////////////////
assertThat(outputDir.resolve("README.md")).doesNotExist();
}
}
@@ -0,0 +1,938 @@
/*
* 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.e2e;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import io.qrun.qctl.e2e.harness.CommandTestHarness;
import io.qrun.qctl.e2e.harness.TestTemplateRegistry;
import io.qrun.qctl.qqq.registry.TemplateRegistryFactory;
import io.qrun.qctl.shared.ExitCodes;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Full E2E tests that verify actual template rendering and output content.
*
* These tests go beyond smoke tests to verify:
* - Variable substitution in generated files
* - Computed variable evaluation
* - Error message quality (suggestions, line numbers)
* - Generated project validity
*
* @since 0.2.0
*******************************************************************************/
class InitCommandFullE2ETest
{
private CommandTestHarness harness;
private TestTemplateRegistry registry;
private InputStream originalIn;
@TempDir
Path tempDir;
@BeforeEach
void setUp() throws IOException
{
harness = new CommandTestHarness();
registry = new TestTemplateRegistry();
registry.loadFromResources("test-minimal");
registry.loadFromResources("test-with-prompts");
registry.loadFromResources("test-variable-typo");
registry.loadFromResources("test-full-substitution");
registry.loadFromResources("test-computed-vars");
registry.loadFromResources("test-transforms");
TemplateRegistryFactory.setRegistry(registry);
originalIn = System.in;
System.setIn(new ByteArrayInputStream("\n\n\n".getBytes(StandardCharsets.UTF_8)));
}
@AfterEach
void tearDown()
{
TemplateRegistryFactory.reset();
System.setIn(originalIn);
}
/***************************************************************************
* Test that template variables are actually substituted in file content.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_variableSubstitution_replacesVariablesInContent() throws IOException
{
Path outputDir = tempDir.resolve("var-substitution-test");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=MyAwesomeProject",
"--var", "packageName=com.example.awesome",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify file content has substituted values, not raw variables //
//////////////////////////////////////////////////////////////////////////
Path readmePath = outputDir.resolve("README.md");
assertThat(readmePath).exists();
String content = Files.readString(readmePath);
assertThat(content)
.as("README.md should contain substituted project name")
.contains("MyAwesomeProject")
.doesNotContain("$projectName");
assertThat(content)
.as("README.md should contain substituted package name")
.contains("com.example.awesome")
.doesNotContain("$packageName");
}
/***************************************************************************
* Test that computed variables are evaluated and available.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_computedVariables_evaluatesExpressions() throws IOException
{
Path outputDir = tempDir.resolve("computed-var-test");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=TestProject",
"--var", "packageName=org.example.test",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// The computed variable packagePath should be org/example/test //
// We'd need a template file that uses $packagePath to verify this //
//////////////////////////////////////////////////////////////////////////
assertThat(harness.getStdout()).contains("Project initialized successfully");
}
/***************************************************************************
* Test that undefined variable errors show "Did you mean?" suggestions.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_undefinedVariable_showsDidYouMeanSuggestion() throws IOException
{
Path outputDir = tempDir.resolve("typo-test");
//////////////////////////////////////////////////////////////////////////
// test-variable-typo has $projetcName (typo) instead of $projectName //
//////////////////////////////////////////////////////////////////////////
harness.execute("qqq", "init", "test-variable-typo", "--no-prompt",
"--var", "projectName=MyProject",
"--var", "packageName=com.example",
"-o", outputDir.toString());
//////////////////////////////////////////////////////////////////////////
// Should fail with render error and show suggestion //
//////////////////////////////////////////////////////////////////////////
String output = harness.getStdout() + harness.getStderr();
assertThat(output)
.as("Error should mention the undefined variable")
.containsIgnoringCase("projetcName");
//////////////////////////////////////////////////////////////////////////
// The "Did you mean?" feature should suggest the correct variable //
//////////////////////////////////////////////////////////////////////////
assertThat(output)
.as("Error should suggest similar variable name")
.containsIgnoringCase("projectName");
}
/***************************************************************************
* Test that --dry-run shows accurate file list without creating.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_dryRun_showsAccurateFileList()
{
Path outputDir = tempDir.resolve("dry-run-list-test");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt", "--dry-run",
"--var", "projectName=DryRunProject",
"--var", "packageName=com.dryrun",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout())
.contains("[DRY-RUN]")
.contains("README.md");
//////////////////////////////////////////////////////////////////////////
// Verify no files were actually created //
//////////////////////////////////////////////////////////////////////////
assertThat(outputDir).doesNotExist();
}
/***************************************************************************
* Test merge mode preserves existing files and adds new ones.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_mergeMode_preservesExistingFiles() throws IOException
{
Path outputDir = tempDir.resolve("merge-content-test");
Files.createDirectories(outputDir);
//////////////////////////////////////////////////////////////////////////
// Create an existing file that should be preserved //
//////////////////////////////////////////////////////////////////////////
Path existingFile = outputDir.resolve("existing-config.txt");
Files.writeString(existingFile, "Original content that should not change");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--merge",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify existing file content is unchanged //
//////////////////////////////////////////////////////////////////////////
assertThat(Files.readString(existingFile))
.isEqualTo("Original content that should not change");
//////////////////////////////////////////////////////////////////////////
// Verify new template files were added //
//////////////////////////////////////////////////////////////////////////
assertThat(outputDir.resolve("README.md")).exists();
}
/***************************************************************************
* Test that multiple --var flags all get applied.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_multipleVars_allSubstituted() throws IOException
{
Path outputDir = tempDir.resolve("multi-var-content-test");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=FirstVar",
"--var", "packageName=second.var.package",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
String content = Files.readString(outputDir.resolve("README.md"));
//////////////////////////////////////////////////////////////////////////
// Both variables should be substituted //
//////////////////////////////////////////////////////////////////////////
assertThat(content).contains("FirstVar");
assertThat(content).contains("second.var.package");
}
/***************************************************************************
* Test force mode actually overwrites existing file content.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_forceMode_overwritesFileContent() throws IOException
{
Path outputDir = tempDir.resolve("force-content-test");
Files.createDirectories(outputDir);
//////////////////////////////////////////////////////////////////////////
// Create a README.md with different content //
//////////////////////////////////////////////////////////////////////////
Path readmePath = outputDir.resolve("README.md");
Files.writeString(readmePath, "Old content that should be replaced");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt", "--force",
"--var", "projectName=NewProject",
"--var", "packageName=com.new.project",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify content was replaced with template output //
//////////////////////////////////////////////////////////////////////////
String newContent = Files.readString(readmePath);
assertThat(newContent)
.doesNotContain("Old content")
.contains("NewProject");
}
/***************************************************************************
* Test full substitution across multiple files with pom.xml variables.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_fullSubstitution_allVariablesInAllFiles() throws IOException
{
Path outputDir = tempDir.resolve("full-sub-test");
harness.execute("qqq", "init", "test-full-substitution", "--no-prompt",
"--var", "projectName=AwesomeApp",
"--var", "packageName=io.awesome.app",
"--var", "groupId=io.awesome",
"--var", "artifactId=awesome-app",
"--var", "version=2.0.0",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify README.md has all variables substituted //
//////////////////////////////////////////////////////////////////////////
String readme = Files.readString(outputDir.resolve("README.md"));
assertThat(readme)
.contains("AwesomeApp")
.contains("io.awesome.app")
.contains("io.awesome")
.contains("awesome-app")
.contains("2.0.0")
.doesNotContain("$projectName")
.doesNotContain("$packageName")
.doesNotContain("$groupId")
.doesNotContain("$artifactId")
.doesNotContain("$version");
//////////////////////////////////////////////////////////////////////////
// Verify pom.xml has all variables substituted //
//////////////////////////////////////////////////////////////////////////
String pom = Files.readString(outputDir.resolve("pom.xml"));
assertThat(pom)
.contains("<groupId>io.awesome</groupId>")
.contains("<artifactId>awesome-app</artifactId>")
.contains("<version>2.0.0</version>")
.contains("<name>AwesomeApp</name>")
.doesNotContain("$groupId")
.doesNotContain("$artifactId")
.doesNotContain("$version");
}
/***************************************************************************
* Test computed variables are evaluated and substituted.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_computedVars_evaluatesAndSubstitutes() throws IOException
{
Path outputDir = tempDir.resolve("computed-test");
harness.execute("qqq", "init", "test-computed-vars", "--no-prompt",
"--var", "packageName=com.test.example",
"--var", "projectName=my-cool-project",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify README.md has computed variables substituted //
//////////////////////////////////////////////////////////////////////////
String readme = Files.readString(outputDir.resolve("README.md"));
assertThat(readme)
.as("Package path should be computed from packageName")
.contains("com/test/example")
.doesNotContain("$packagePath");
assertThat(readme)
.as("Kebab case should be computed from projectName")
.contains("my-cool-project")
.doesNotContain("$projectNameKebab");
assertThat(readme)
.as("Pascal case should be computed from projectName (capitalizes first letter)")
.contains("My-cool-project")
.doesNotContain("$projectNamePascal");
}
/***************************************************************************
* Test that $packagePath in directory names gets substituted by Velocity.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_packagePathSubstitution_createsCorrectDirectories() throws IOException
{
Path outputDir = tempDir.resolve("transform-test");
harness.execute("qqq", "init", "test-transforms", "--no-prompt",
"--var", "packageName=org.acme.test",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify $packagePath was substituted to org/acme/test by Velocity //
//////////////////////////////////////////////////////////////////////////
Path expectedJavaDir = outputDir.resolve("src/main/java/org/acme/test");
assertThat(expectedJavaDir)
.as("Directory should be created with $packagePath substituted to org/acme/test")
.exists()
.isDirectory();
Path appJava = expectedJavaDir.resolve("App.java");
assertThat(appJava)
.as("App.java should exist in transformed directory")
.exists();
//////////////////////////////////////////////////////////////////////////
// Verify literal $packagePath directory does not exist //
//////////////////////////////////////////////////////////////////////////
Path placeholderDir = outputDir.resolve("src/main/java/$packagePath");
assertThat(placeholderDir)
.as("Literal $packagePath directory should not exist after substitution")
.doesNotExist();
}
/***************************************************************************
* Test delete transform removes specified files.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_deleteTransform_removesFiles() throws IOException
{
Path outputDir = tempDir.resolve("delete-transform-test");
harness.execute("qqq", "init", "test-transforms", "--no-prompt",
"--var", "packageName=com.example",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify .gitkeep files were deleted by the delete transform //
//////////////////////////////////////////////////////////////////////////
Path gitkeepPath = outputDir.resolve("src/main/java/com/example/.gitkeep");
assertThat(gitkeepPath)
.as(".gitkeep should be deleted by transform")
.doesNotExist();
//////////////////////////////////////////////////////////////////////////
// Verify other files still exist //
//////////////////////////////////////////////////////////////////////////
assertThat(outputDir.resolve("src/main/java/com/example/App.java"))
.as("App.java should still exist")
.exists();
}
/***************************************************************************
* Test that provided values are used and other vars can be set together.
*
* Note: --no-prompt mode currently requires all vars to be provided via
* --var flags; defaults from template.yaml are not applied. This tests
* that explicitly provided values work correctly.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_providedValues_usedCorrectly() throws IOException
{
Path outputDir = tempDir.resolve("provided-values-test");
//////////////////////////////////////////////////////////////////////////
// Provide custom projectName, use defaults for others //
//////////////////////////////////////////////////////////////////////////
harness.execute("qqq", "init", "test-full-substitution", "--no-prompt",
"--var", "projectName=CustomProject",
"--var", "packageName=com.custom",
"--var", "groupId=com.custom",
"--var", "artifactId=custom-app",
"--var", "version=2.0.0",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify all provided values are used correctly //
//////////////////////////////////////////////////////////////////////////
String readme = Files.readString(outputDir.resolve("README.md"));
assertThat(readme)
.as("Custom project name should be used")
.contains("CustomProject");
assertThat(readme)
.as("Custom package name should be used")
.contains("com.custom");
String pom = Files.readString(outputDir.resolve("pom.xml"));
assertThat(pom)
.as("Custom groupId should be used")
.contains("<groupId>com.custom</groupId>");
assertThat(pom)
.as("Custom version should be used")
.contains("<version>2.0.0</version>");
}
/***************************************************************************
* Test App.java in transformed directory has correct package statement.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_javaFile_hasCorrectPackageStatement() throws IOException
{
Path outputDir = tempDir.resolve("java-package-test");
harness.execute("qqq", "init", "test-transforms", "--no-prompt",
"--var", "packageName=io.qrun.myapp",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify App.java has correct package statement //
//////////////////////////////////////////////////////////////////////////
Path appJava = outputDir.resolve("src/main/java/io/qrun/myapp/App.java");
assertThat(appJava).exists();
String content = Files.readString(appJava);
assertThat(content)
.as("App.java should have correct package statement")
.contains("package io.qrun.myapp;")
.doesNotContain("$packageName");
}
/***************************************************************************
* Test pom.xml is valid XML after substitution.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_generatedPom_isValidXml() throws Exception
{
Path outputDir = tempDir.resolve("valid-xml-test");
harness.execute("qqq", "init", "test-full-substitution", "--no-prompt",
"--var", "projectName=XmlTest",
"--var", "packageName=com.xml.test",
"--var", "groupId=com.xml",
"--var", "artifactId=xml-test",
"--var", "version=1.0.0",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Parse pom.xml to verify it's valid XML //
//////////////////////////////////////////////////////////////////////////
Path pomPath = outputDir.resolve("pom.xml");
javax.xml.parsers.DocumentBuilderFactory factory =
javax.xml.parsers.DocumentBuilderFactory.newInstance();
javax.xml.parsers.DocumentBuilder builder = factory.newDocumentBuilder();
//////////////////////////////////////////////////////////////////////////
// This will throw an exception if XML is invalid //
//////////////////////////////////////////////////////////////////////////
org.w3c.dom.Document doc = builder.parse(pomPath.toFile());
assertThat(doc.getDocumentElement().getNodeName())
.as("Root element should be 'project'")
.isEqualTo("project");
}
/***************************************************************************
* Test behavior when requesting a template that doesn't exist.
*
* With TestTemplateRegistry, non-existent templates cause the system
* to fall back to the first available template in --no-prompt mode.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_unknownTemplate_fallsBackToFirst() throws IOException
{
Path outputDir = tempDir.resolve("unknown-template-test");
//////////////////////////////////////////////////////////////////////////
// With --no-prompt, requesting unknown template uses first available //
//////////////////////////////////////////////////////////////////////////
harness.execute("qqq", "init", "non-existent-template", "--no-prompt",
"-o", outputDir.toString());
//////////////////////////////////////////////////////////////////////////
// Should complete successfully using first available template //
//////////////////////////////////////////////////////////////////////////
assertThat(harness.getExitCode())
.as("Should succeed with fallback template")
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Output directory should exist with files from fallback template //
//////////////////////////////////////////////////////////////////////////
assertThat(outputDir).exists().isDirectory();
}
/***************************************************************************
* Test merge mode skips files that already exist and are identical.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_mergeMode_skipsIdenticalFiles() throws IOException
{
Path outputDir = tempDir.resolve("merge-identical-test");
//////////////////////////////////////////////////////////////////////////
// First run to create files //
//////////////////////////////////////////////////////////////////////////
harness.execute("qqq", "init", "test-minimal", "--no-prompt",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Second run with merge should skip existing files //
//////////////////////////////////////////////////////////////////////////
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--merge",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout())
.as("Should show skip status for existing files")
.contains("[skip]");
}
/***************************************************************************
* Test merge with --include pattern only overwrites matched files.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_mergeWithInclude_onlyOverwritesMatched() throws IOException
{
Path outputDir = tempDir.resolve("merge-include-test");
Files.createDirectories(outputDir);
//////////////////////////////////////////////////////////////////////////
// Create existing README.md with custom content //
//////////////////////////////////////////////////////////////////////////
Path readme = outputDir.resolve("README.md");
Files.writeString(readme, "Original README content");
//////////////////////////////////////////////////////////////////////////
// Create existing other.txt that should not be touched //
//////////////////////////////////////////////////////////////////////////
Path other = outputDir.resolve("other.txt");
Files.writeString(other, "Other content");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--merge",
"--include", "README.md",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// README.md should be overwritten (included in --include) //
//////////////////////////////////////////////////////////////////////////
assertThat(Files.readString(readme))
.as("README.md should be overwritten")
.doesNotContain("Original README content");
//////////////////////////////////////////////////////////////////////////
// other.txt should be untouched //
//////////////////////////////////////////////////////////////////////////
assertThat(Files.readString(other))
.as("other.txt should be preserved")
.isEqualTo("Other content");
}
/***************************************************************************
* Test merge with --exclude pattern skips matched files.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_mergeWithExclude_skipsMatched() throws IOException
{
Path outputDir = tempDir.resolve("merge-exclude-test");
Files.createDirectories(outputDir);
//////////////////////////////////////////////////////////////////////////
// Create existing README.md that should be skipped //
//////////////////////////////////////////////////////////////////////////
Path readme = outputDir.resolve("README.md");
Files.writeString(readme, "Preserved README");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--merge",
"--exclude", "README.md",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// README.md should be preserved (excluded from merge) //
//////////////////////////////////////////////////////////////////////////
assertThat(Files.readString(readme))
.as("README.md should be preserved due to --exclude")
.isEqualTo("Preserved README");
}
/***************************************************************************
* Test that unicode characters in variable values are handled correctly.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_unicodeInVariables_handledCorrectly() throws IOException
{
Path outputDir = tempDir.resolve("unicode-test");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=Projet-Franca\u0327ais",
"--var", "packageName=com.example.unicode",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
String readme = Files.readString(outputDir.resolve("README.md"));
assertThat(readme)
.as("Unicode characters should be preserved")
.contains("Projet-Franca\u0327ais");
}
/***************************************************************************
* Test that special characters in package names work correctly.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_specialCharsInPackage_escapedCorrectly() throws IOException
{
Path outputDir = tempDir.resolve("special-chars-test");
//////////////////////////////////////////////////////////////////////////
// Package names with underscores are valid Java package names //
//////////////////////////////////////////////////////////////////////////
harness.execute("qqq", "init", "test-computed-vars", "--no-prompt",
"--var", "projectName=test_project",
"--var", "packageName=com.example_company.test_app",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify directory structure is created correctly //
//////////////////////////////////////////////////////////////////////////
Path packageDir = outputDir.resolve("src/main/java/com/example_company/test_app");
assertThat(packageDir)
.as("Package directory with underscores should be created")
.exists()
.isDirectory();
}
/***************************************************************************
* Test deeply nested directories are created correctly.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_deeplyNestedPackage_createsAllDirectories() throws IOException
{
Path outputDir = tempDir.resolve("deep-nest-test");
harness.execute("qqq", "init", "test-computed-vars", "--no-prompt",
"--var", "projectName=deep-test",
"--var", "packageName=com.very.deeply.nested.package.structure",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify deeply nested directory structure is created //
//////////////////////////////////////////////////////////////////////////
Path deepDir = outputDir.resolve(
"src/main/java/com/very/deeply/nested/package/structure");
assertThat(deepDir)
.as("Deep directory structure should be created")
.exists()
.isDirectory();
assertThat(deepDir.resolve("App.java"))
.as("App.java should exist in deep directory")
.exists();
}
/***************************************************************************
* Test that computed variable appears in directory path.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_computedVar_usedInDirectoryPath() throws IOException
{
Path outputDir = tempDir.resolve("computed-dir-test");
harness.execute("qqq", "init", "test-full-substitution", "--no-prompt",
"--var", "projectName=ComputedDirTest",
"--var", "packageName=org.test.computed",
"--var", "groupId=org.test",
"--var", "artifactId=computed-test",
"--var", "version=1.0.0",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// packagePath computed from packageName should create org/test/computed//
//////////////////////////////////////////////////////////////////////////
Path javaDir = outputDir.resolve("src/main/java/org/test/computed");
assertThat(javaDir)
.as("Directory path should use computed packagePath")
.exists()
.isDirectory();
}
/***************************************************************************
* Test verbose mode shows additional information.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_verboseMode_showsExtraInfo()
{
Path outputDir = tempDir.resolve("verbose-test");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--verbose",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verbose mode should complete successfully //
//////////////////////////////////////////////////////////////////////////
assertThat(harness.getStdout())
.contains("Project initialized successfully");
}
/***************************************************************************
* Test that empty project name is rejected.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_emptyVarValue_isHandled() throws IOException
{
Path outputDir = tempDir.resolve("empty-var-test");
//////////////////////////////////////////////////////////////////////////
// Empty string should be treated as a valid (empty) value //
//////////////////////////////////////////////////////////////////////////
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=",
"--var", "packageName=com.empty",
"-o", outputDir.toString());
//////////////////////////////////////////////////////////////////////////
// Command should complete (empty string is valid for non-required) //
// or fail validation if required //
//////////////////////////////////////////////////////////////////////////
String output = harness.getStdout() + harness.getStderr();
assertThat(output).isNotEmpty();
}
}
@@ -0,0 +1,132 @@
/*
* 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.e2e;
import io.qrun.qctl.e2e.harness.CommandTestHarness;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static io.qrun.qctl.e2e.harness.ExitCodeAssertions.assertSuccess;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Integration tests for the qqq init command.
*
* Tests command parsing, help text, and basic validation without
* spawning native processes or making network calls.
*
* @since 0.2.0
*******************************************************************************/
class InitCommandIT
{
private CommandTestHarness harness;
@BeforeEach
void setUp()
{
harness = new CommandTestHarness();
}
/***************************************************************************
* Test that --help displays init command usage.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_help_showsUsage()
{
harness.execute("qqq", "init", "--help");
assertSuccess(harness);
assertThat(harness.getStdout())
.contains("Initialize a new project from a template")
.contains("--output")
.contains("--force")
.contains("--dry-run")
.contains("--merge");
}
/***************************************************************************
* Test that qqq command group shows help.
*
* @since 0.2.0
***************************************************************************/
@Test
void testQqq_help_showsSubcommands()
{
harness.execute("qqq", "--help");
assertSuccess(harness);
assertThat(harness.getStdout())
.contains("init")
.contains("list");
}
/***************************************************************************
* Test that top-level --version works.
*
* @since 0.2.0
***************************************************************************/
@Test
void testVersion_showsVersionInfo()
{
harness.execute("--version");
assertSuccess(harness);
assertThat(harness.getStdout())
.containsPattern("qctl.*\\d+\\.\\d+\\.\\d+");
}
/***************************************************************************
* Test that unknown command shows error.
*
* @since 0.2.0
***************************************************************************/
@Test
void testUnknownCommand_showsError()
{
harness.execute("unknown-command");
assertThat(harness.getExitCode()).isNotZero();
assertThat(harness.getStderr())
.contains("unknown-command");
}
/***************************************************************************
* Test that qqq list runs and shows templates.
*
* @since 0.2.0
***************************************************************************/
@Test
void testList_showsTemplates()
{
harness.execute("qqq", "list");
assertSuccess(harness);
assertThat(harness.getStdout())
.containsIgnoringCase("Available Templates");
}
}
@@ -0,0 +1,375 @@
/*
* 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.e2e;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import io.qrun.qctl.shared.ExitCodes;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/*******************************************************************************
* End-to-end tests using the native qctl binary.
*
* These tests spawn the actual native binary and verify exit codes and output.
* Requires the native binary to be built (skipped if not present).
*
* @since 0.2.0
*******************************************************************************/
class NativeBinaryE2ETest
{
private static final String BINARY_PROPERTY = "qctl.native.binary";
private static final long TIMEOUT_SECONDS = 30;
private static Path binaryPath;
@TempDir
Path tempDir;
/***************************************************************************
* Check if native binary is available.
*
* @since 0.2.0
***************************************************************************/
@BeforeAll
static void checkBinaryExists()
{
String binaryPathStr = System.getProperty(BINARY_PROPERTY);
if(binaryPathStr != null)
{
binaryPath = Path.of(binaryPathStr);
}
else
{
//////////////////////////////////////////////////////////////////
// Try default location //
//////////////////////////////////////////////////////////////////
binaryPath = Path.of("../qctl-cli/target/qctl");
}
}
@BeforeEach
void assumeBinaryPresent()
{
assumeTrue(Files.exists(binaryPath) && Files.isExecutable(binaryPath),
"Native binary not found at " + binaryPath + ". Run 'mvn package -Pnative' to build.");
}
/***************************************************************************
* Test --version flag.
*
* @since 0.2.0
***************************************************************************/
@Test
void testVersion_showsVersionNumber() throws Exception
{
ProcessResult result = execute("--version");
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
assertThat(result.stdout).containsPattern("qctl.*\\d+\\.\\d+\\.\\d+");
}
/***************************************************************************
* Test --help flag.
*
* @since 0.2.0
***************************************************************************/
@Test
void testHelp_showsUsage() throws Exception
{
ProcessResult result = execute("--help");
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
assertThat(result.stdout)
.contains("qctl")
.contains("qqq")
.contains("qbit")
.contains("qrun");
}
/***************************************************************************
* Test qqq init --help.
*
* @since 0.2.0
***************************************************************************/
@Test
void testQqqInitHelp_showsUsage() throws Exception
{
ProcessResult result = execute("qqq", "init", "--help");
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
assertThat(result.stdout)
.contains("Initialize a new project")
.contains("--output")
.contains("--force")
.contains("--merge");
}
/***************************************************************************
* Test qqq list command.
*
* @since 0.2.0
***************************************************************************/
@Test
void testQqqList_showsTemplates() throws Exception
{
ProcessResult result = execute("qqq", "list");
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
assertThat(result.stdout)
.contains("Available Templates")
.contains("new-qqq-application");
}
/***************************************************************************
* Test unknown command shows error.
*
* @since 0.2.0
***************************************************************************/
@Test
void testUnknownCommand_showsError() throws Exception
{
ProcessResult result = execute("not-a-command");
assertThat(result.exitCode).isNotZero();
assertThat(result.stderr).contains("not-a-command");
}
/***************************************************************************
* Test init with non-existent template.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_nonExistentTemplate_showsError() throws Exception
{
ProcessResult result = execute("qqq", "init", "not-a-real-template", "--no-prompt",
"-o", tempDir.resolve("test-project").toString());
// Should show error with suggestions
assertThat(result.stdout + result.stderr)
.containsIgnoringCase("not found")
.contains("not-a-real-template");
}
/***************************************************************************
* Test init --dry-run shows preview without creating files.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_dryRun_showsPreview() throws Exception
{
Path outputDir = tempDir.resolve("dry-run-project");
ProcessResult result = execute("qqq", "init", "new-qqq-application",
"--no-prompt", "--dry-run",
"-o", outputDir.toString());
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
assertThat(result.stdout).contains("[DRY-RUN]");
assertThat(outputDir).doesNotExist();
}
/***************************************************************************
* Test init succeeds with new-qqq-application template.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_defaultTemplate_createsProject() throws Exception
{
Path outputDir = tempDir.resolve("default-template-project");
ProcessResult result = execute("qqq", "init", "new-qqq-application",
"--no-prompt",
"--var", "applicationName=test-app",
"--var", "groupId=com.test",
"--var", "artifactId=test-app",
"-o", outputDir.toString());
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
assertThat(result.stdout).contains("Project initialized successfully");
assertThat(outputDir).exists();
}
/***************************************************************************
* Test init with --force overwrites existing directory.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_force_overwritesExisting() throws Exception
{
Path outputDir = tempDir.resolve("force-project");
Files.createDirectories(outputDir);
Files.writeString(outputDir.resolve("existing.txt"), "test");
ProcessResult result = execute("qqq", "init", "new-qqq-application",
"--no-prompt", "--force",
"--var", "applicationName=test-app",
"--var", "groupId=com.test",
"--var", "artifactId=test-app",
"-o", outputDir.toString());
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
assertThat(result.stdout).contains("Overwriting");
}
/***************************************************************************
* Test init without --force fails on existing directory.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_existingDir_showsConflict() throws Exception
{
Path outputDir = tempDir.resolve("existing-project");
Files.createDirectories(outputDir);
ProcessResult result = execute("qqq", "init", "new-qqq-application",
"--no-prompt",
"-o", outputDir.toString());
assertThat(result.exitCode).isEqualTo(ExitCodes.CONFLICT);
assertThat(result.stderr).contains("target directory already exists");
}
/***************************************************************************
* Execute the native binary with arguments.
*
* @param args command-line arguments
* @return process result with stdout, stderr, and exit code
* @throws IOException if execution fails
* @throws InterruptedException if wait is interrupted
* @since 0.2.0
***************************************************************************/
private ProcessResult execute(String... args) throws IOException, InterruptedException
{
List<String> command = new ArrayList<>();
command.add(binaryPath.toAbsolutePath().toString());
command.addAll(List.of(args));
ProcessBuilder pb = new ProcessBuilder(command);
pb.environment().put("QCTL_REGISTRY_TYPE", "mock");
pb.directory(tempDir.toFile());
Process process = pb.start();
StringBuilder stdout = new StringBuilder();
StringBuilder stderr = new StringBuilder();
try(BufferedReader outReader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
BufferedReader errReader = new BufferedReader(
new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8)))
{
Thread outThread = new Thread(() ->
{
try
{
String line;
while((line = outReader.readLine()) != null)
{
stdout.append(line).append("\n");
}
}
catch(IOException e)
{
// Ignore
}
});
Thread errThread = new Thread(() ->
{
try
{
String line;
while((line = errReader.readLine()) != null)
{
stderr.append(line).append("\n");
}
}
catch(IOException e)
{
// Ignore
}
});
outThread.start();
errThread.start();
boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
if(!finished)
{
process.destroyForcibly();
throw new IOException("Process timed out after " + TIMEOUT_SECONDS + " seconds");
}
outThread.join(1000);
errThread.join(1000);
}
return new ProcessResult(stdout.toString(), stderr.toString(), process.exitValue());
}
/***************************************************************************
* Result of a process execution.
*
* @since 0.2.0
***************************************************************************/
record ProcessResult(String stdout, String stderr, int exitCode)
{
}
}
@@ -0,0 +1,189 @@
/*
* 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.e2e.harness;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import io.qrun.qctl.core.Main;
import io.qrun.qctl.shared.spi.CommandPlugin;
import picocli.CommandLine;
/*******************************************************************************
* Test harness for executing qctl commands in-process.
*
* Captures stdout, stderr, and exit codes for assertion. This enables fast,
* deterministic E2E tests without spawning separate processes.
*
* @since 0.2.0
*******************************************************************************/
public class CommandTestHarness
{
private final ByteArrayOutputStream stdoutStream = new ByteArrayOutputStream();
private final ByteArrayOutputStream stderrStream = new ByteArrayOutputStream();
private int exitCode = -1;
/***************************************************************************
* Execute a qctl command with the given arguments.
*
* @param args command-line arguments (e.g., "qqq", "init", "my-template")
* @return this harness for fluent assertions
* @since 0.2.0
***************************************************************************/
public CommandTestHarness execute(String... args)
{
PrintStream origOut = System.out;
PrintStream origErr = System.err;
try
{
System.setOut(new PrintStream(stdoutStream, true, StandardCharsets.UTF_8));
System.setErr(new PrintStream(stderrStream, true, StandardCharsets.UTF_8));
CommandLine cmd = buildCommandLine();
exitCode = cmd.execute(args);
}
finally
{
System.setOut(origOut);
System.setErr(origErr);
}
return this;
}
/***************************************************************************
* Build CommandLine with all SPI-discovered plugins.
*
* @return configured CommandLine instance
* @since 0.2.0
***************************************************************************/
private CommandLine buildCommandLine()
{
CommandLine cmd = new CommandLine(new Main());
//////////////////////////////////////////////////////////////////////////
// Discover and register subcommands via SPI //
//////////////////////////////////////////////////////////////////////////
try
{
java.util.ServiceLoader<CommandPlugin> loader =
java.util.ServiceLoader.load(CommandPlugin.class);
for(CommandPlugin plugin : loader)
{
Object command = plugin.getCommand();
CommandLine sub = new CommandLine(command);
String name = sub.getCommandSpec().name();
cmd.addSubcommand(name, command);
}
}
catch(java.util.ServiceConfigurationError | NoClassDefFoundError e)
{
//////////////////////////////////////////////////////////////////
// Log but continue - some plugins may not be available //
//////////////////////////////////////////////////////////////////
System.err.println("warning: some command plugins could not be loaded: " + e.getMessage());
}
return cmd;
}
/***************************************************************************
* Get the captured stdout content.
*
* @return stdout as string
* @since 0.2.0
***************************************************************************/
public String getStdout()
{
return stdoutStream.toString(StandardCharsets.UTF_8);
}
/***************************************************************************
* Get the captured stderr content.
*
* @return stderr as string
* @since 0.2.0
***************************************************************************/
public String getStderr()
{
return stderrStream.toString(StandardCharsets.UTF_8);
}
/***************************************************************************
* Get the exit code from the last command execution.
*
* @return exit code (0 = success)
* @since 0.2.0
***************************************************************************/
public int getExitCode()
{
return exitCode;
}
/***************************************************************************
* Check if stdout contains a specific string.
*
* @param expected the string to search for
* @return true if stdout contains the string
* @since 0.2.0
***************************************************************************/
public boolean stdoutContains(String expected)
{
return getStdout().contains(expected);
}
/***************************************************************************
* Check if stderr contains a specific string.
*
* @param expected the string to search for
* @return true if stderr contains the string
* @since 0.2.0
***************************************************************************/
public boolean stderrContains(String expected)
{
return getStderr().contains(expected);
}
/***************************************************************************
* Reset the harness for reuse.
*
* @return this harness for fluent chaining
* @since 0.2.0
***************************************************************************/
public CommandTestHarness reset()
{
stdoutStream.reset();
stderrStream.reset();
exitCode = -1;
return this;
}
}
@@ -0,0 +1,131 @@
/*
* 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.e2e.harness;
import io.qrun.qctl.shared.ExitCodes;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* AssertJ-style assertions for CommandTestHarness exit codes.
*
* Provides readable assertions for common exit code scenarios.
*
* @since 0.2.0
*******************************************************************************/
public final class ExitCodeAssertions
{
/***************************************************************************
* Private constructor - utility class.
*
* @since 0.2.0
***************************************************************************/
private ExitCodeAssertions()
{
}
/***************************************************************************
* Assert the command executed successfully (exit code 0).
*
* @param harness the test harness
* @since 0.2.0
***************************************************************************/
public static void assertSuccess(CommandTestHarness harness)
{
assertThat(harness.getExitCode())
.as("Expected success (exit code 0), got %d.\nStdout: %s\nStderr: %s",
harness.getExitCode(), harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
}
/***************************************************************************
* Assert the command failed with a generic error (exit code 1).
*
* @param harness the test harness
* @since 0.2.0
***************************************************************************/
public static void assertGenericError(CommandTestHarness harness)
{
assertThat(harness.getExitCode())
.as("Expected generic error (exit code 1)")
.isEqualTo(ExitCodes.GENERIC);
}
/***************************************************************************
* Assert the command failed with a usage error (exit code 2).
*
* @param harness the test harness
* @since 0.2.0
***************************************************************************/
public static void assertUsageError(CommandTestHarness harness)
{
assertThat(harness.getExitCode())
.as("Expected usage error (exit code 2)")
.isEqualTo(ExitCodes.USAGE);
}
/***************************************************************************
* Assert the command failed with a validation error (exit code 6).
*
* @param harness the test harness
* @since 0.2.0
***************************************************************************/
public static void assertValidationError(CommandTestHarness harness)
{
assertThat(harness.getExitCode())
.as("Expected validation error (exit code 6)")
.isEqualTo(ExitCodes.VALIDATION);
}
/***************************************************************************
* Assert the command failed with a conflict error (exit code 8).
*
* @param harness the test harness
* @since 0.2.0
***************************************************************************/
public static void assertConflictError(CommandTestHarness harness)
{
assertThat(harness.getExitCode())
.as("Expected conflict error (exit code 8)")
.isEqualTo(ExitCodes.CONFLICT);
}
/***************************************************************************
* Assert the command returned a specific exit code.
*
* @param harness the test harness
* @param expectedCode expected exit code
* @since 0.2.0
***************************************************************************/
public static void assertExitCode(CommandTestHarness harness, int expectedCode)
{
assertThat(harness.getExitCode())
.as("Expected exit code %d", expectedCode)
.isEqualTo(expectedCode);
}
}
@@ -0,0 +1,222 @@
/*
* 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.e2e.harness;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
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.template.TemplateManifest;
/*******************************************************************************
* Test template registry that loads fixtures from test resources.
*
* Provides fully offline, deterministic template loading for E2E tests.
* Templates are loaded from src/test/resources/fixtures/templates/.
*
* @since 0.2.0
*******************************************************************************/
public class TestTemplateRegistry implements TemplateRegistry
{
private static final String FIXTURES_DIR = "fixtures/templates";
private final Map<String, Path> templatePaths = new HashMap<>();
private final Map<String, TemplateInfo> templateInfos = new HashMap<>();
/***************************************************************************
* Register a fixture template by ID.
*
* @param id template ID
* @param fixtureDir path to fixture directory containing template.yaml
* @return this for fluent chaining
* @throws IOException if template cannot be loaded
* @since 0.2.0
***************************************************************************/
public TestTemplateRegistry addFixture(String id, Path fixtureDir) throws IOException
{
if(!Files.exists(fixtureDir))
{
throw new IOException("Fixture directory does not exist: " + fixtureDir);
}
templatePaths.put(id, fixtureDir);
Path manifestPath = fixtureDir.resolve("template.yaml");
if(Files.exists(manifestPath))
{
TemplateManifest manifest = TemplateManifest.load(fixtureDir);
templateInfos.put(id, createTemplateInfo(id, manifest, fixtureDir));
}
else
{
//////////////////////////////////////////////////////////////////
// Create minimal info for templates without manifests //
//////////////////////////////////////////////////////////////////
templateInfos.put(id, createMinimalInfo(id, fixtureDir));
}
return this;
}
/***************************************************************************
* Load a fixture from classpath resources.
*
* @param id template ID (also the directory name under fixtures/templates/)
* @return this for fluent chaining
* @throws IOException if fixture cannot be loaded
* @since 0.2.0
***************************************************************************/
public TestTemplateRegistry loadFromResources(String id) throws IOException
{
try
{
java.net.URL url = getClass().getClassLoader().getResource(FIXTURES_DIR + "/" + id);
if(url == null)
{
throw new IOException("Fixture not found in resources: " + id);
}
Path fixtureDir = Path.of(url.toURI());
return addFixture(id, fixtureDir);
}
catch(URISyntaxException e)
{
throw new IOException("Invalid fixture path: " + id, e);
}
}
/***************************************************************************
* Create TemplateInfo from manifest.
*
* @param id template ID
* @param manifest the loaded manifest
* @param fixtureDir the fixture directory path
* @return TemplateInfo record
* @since 0.2.0
***************************************************************************/
private TemplateInfo createTemplateInfo(String id, TemplateManifest manifest, Path fixtureDir)
{
return new TemplateInfo(
id,
manifest.name() != null ? manifest.name() : id,
manifest.version() != null ? manifest.version() : "1.0.0",
manifest.description() != null ? manifest.description() : "Test fixture template",
fixtureDir.toUri().toString(),
manifest.getEffectiveSchemaVersion(),
manifest.minimumQctlVersion(),
List.of("test", "fixture"),
"test",
manifest.prompts() != null ? manifest.prompts() : List.of(),
manifest.computed() != null ? manifest.computed() : List.of(),
manifest.transforms() != null ? manifest.transforms() : List.of(),
manifest.postGen() != null ? manifest.postGen() : List.of(),
manifest.ignore() != null ? manifest.ignore() : List.of()
);
}
/***************************************************************************
* Create minimal TemplateInfo for fixtures without manifest.
*
* @param id template ID
* @param fixtureDir the fixture directory path
* @return TemplateInfo record
* @since 0.2.0
***************************************************************************/
private TemplateInfo createMinimalInfo(String id, Path fixtureDir)
{
return new TemplateInfo(
id,
id,
"1.0.0",
"Test fixture template (no manifest)",
fixtureDir.toUri().toString(),
1,
null,
List.of("test"),
"test",
List.of(),
List.of(),
List.of(),
List.of(),
List.of()
);
}
@Override
public List<TemplateInfo> listTemplates()
{
return new ArrayList<>(templateInfos.values());
}
@Override
public Optional<TemplateInfo> getTemplate(String id)
{
return Optional.ofNullable(templateInfos.get(id));
}
@Override
public Path downloadTemplate(String id, String version) throws IOException
{
Path fixturePath = templatePaths.get(id);
if(fixturePath == null)
{
throw new IOException("Fixture template not found: " + id);
}
return fixturePath;
}
@Override
public void refresh()
{
// No-op for test registry
}
/***************************************************************************
* Clear all registered fixtures.
*
* @return this for fluent chaining
* @since 0.2.0
***************************************************************************/
public TestTemplateRegistry clear()
{
templatePaths.clear();
templateInfos.clear();
return this;
}
}
@@ -0,0 +1,292 @@
/*
* 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.e2e.harness;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import java.util.function.Function;
import io.qrun.qctl.qqq.template.ConsoleUI;
/*******************************************************************************
* Test-friendly ConsoleUI that queues inputs and captures outputs.
*
* Allows tests to pre-populate input responses and verify prompt sequences
* without actual user interaction.
*
* @since 0.2.0
*******************************************************************************/
public class TestableConsoleUI extends ConsoleUI
{
private final Queue<String> queuedInputs = new LinkedList<>();
private final List<String> promptHistory = new ArrayList<>();
private final List<String> outputHistory = new ArrayList<>();
/***************************************************************************
* Queue an input value to be returned by the next prompt.
*
* @param input the input value
* @return this for fluent chaining
* @since 0.2.0
***************************************************************************/
public TestableConsoleUI queueInput(String input)
{
queuedInputs.add(input);
return this;
}
/***************************************************************************
* Queue multiple input values in order.
*
* @param inputs the input values
* @return this for fluent chaining
* @since 0.2.0
***************************************************************************/
public TestableConsoleUI queueInputs(String... inputs)
{
for(String input : inputs)
{
queuedInputs.add(input);
}
return this;
}
/***************************************************************************
* Get the list of prompts that were displayed.
*
* @return list of prompt labels
* @since 0.2.0
***************************************************************************/
public List<String> getPromptHistory()
{
return new ArrayList<>(promptHistory);
}
/***************************************************************************
* Get the list of outputs (headers, info, success, etc).
*
* @return list of output messages
* @since 0.2.0
***************************************************************************/
public List<String> getOutputHistory()
{
return new ArrayList<>(outputHistory);
}
/***************************************************************************
* Reset all queued inputs and histories.
*
* @return this for fluent chaining
* @since 0.2.0
***************************************************************************/
public TestableConsoleUI reset()
{
queuedInputs.clear();
promptHistory.clear();
outputHistory.clear();
return this;
}
/***************************************************************************
* Check if any queued inputs remain unused.
*
* @return true if inputs remain
* @since 0.2.0
***************************************************************************/
public boolean hasUnusedInputs()
{
return !queuedInputs.isEmpty();
}
/***************************************************************************
* Get next queued input or empty string if none.
*
* @return queued input
* @since 0.2.0
***************************************************************************/
private String nextInput()
{
return queuedInputs.poll();
}
//////////////////////////////////////////////////////////////////////////
// Overridden methods to capture and simulate //
//////////////////////////////////////////////////////////////////////////
@Override
public void header(String title)
{
outputHistory.add("HEADER: " + title);
}
@Override
public void subtitle(String text)
{
outputHistory.add("SUBTITLE: " + text);
}
@Override
public void info(String text)
{
outputHistory.add("INFO: " + text);
}
@Override
public void success(String text)
{
outputHistory.add("SUCCESS: " + text);
}
@Override
public void successBold(String text)
{
outputHistory.add("SUCCESS_BOLD: " + text);
}
@Override
public void error(String text)
{
outputHistory.add("ERROR: " + text);
}
@Override
public void validationError(String text)
{
outputHistory.add("VALIDATION_ERROR: " + text);
}
@Override
public void validationSuccess()
{
outputHistory.add("VALIDATION_SUCCESS");
}
@Override
public void warning(String text)
{
outputHistory.add("WARNING: " + text);
}
@Override
public void println()
{
outputHistory.add("PRINTLN:");
}
@Override
public void println(String text)
{
outputHistory.add("PRINTLN: " + text);
}
@Override
public String promptText(String label, String defaultValue)
{
promptHistory.add(label);
String input = nextInput();
if(input == null || input.isEmpty())
{
return defaultValue != null ? defaultValue : "";
}
return input;
}
@Override
public void showValue(String label, String value)
{
outputHistory.add("SHOW_VALUE: " + label + "=" + value);
}
@Override
public void showValue(String label, String value, String annotation)
{
outputHistory.add("SHOW_VALUE: " + label + "=" + value + " (" + annotation + ")");
}
@Override
public <T> T promptSelect(String label,
List<T> items,
Function<T, String> displayFn,
Function<T, String> descriptionFn,
int defaultIndex)
{
promptHistory.add(label);
String input = nextInput();
int selectedIndex = defaultIndex;
if(input != null && !input.isEmpty())
{
try
{
int parsed = Integer.parseInt(input.trim()) - 1;
if(parsed >= 0 && parsed < items.size())
{
selectedIndex = parsed;
}
}
catch(NumberFormatException e)
{
// Use default
}
}
return items.get(selectedIndex);
}
}
@@ -0,0 +1,29 @@
schemaVersion: 2
id: test-computed-vars
name: Computed Variables Test Template
version: 1.0.0
description: Template for testing computed variable evaluation
prompts:
- name: packageName
message: Package name
type: text
defaultValue: com.example.app
required: true
- name: projectName
message: Project name
type: text
defaultValue: my-project
required: true
computed:
- name: packagePath
expression: "$str.replace($packageName, '.', '/')"
- name: projectNameKebab
expression: "$str.kebab($projectName)"
- name: projectNamePascal
expression: "$str.pascal($projectName)"
transforms: []
postGen: []
ignore: []
@@ -0,0 +1,8 @@
# $projectName
## Computed Variables Test
- Package Name: $packageName
- Package Path: $packagePath
- Kebab Case: $projectNameKebab
- Pascal Case: $projectNamePascal
@@ -0,0 +1,15 @@
package $packageName;
/**
* Main application class.
* Project: $projectName
* Project (kebab): $projectNameKebab
* Project (pascal): $projectNamePascal
*/
public class App
{
public static void main(String[] args)
{
System.out.println("Hello from $projectName!");
}
}
@@ -0,0 +1,40 @@
schemaVersion: 2
id: test-full-substitution
name: Full Substitution Test Template
version: 1.0.0
description: Template for testing variable substitution in file content
prompts:
- name: projectName
message: Project name
type: text
defaultValue: my-project
required: true
- name: packageName
message: Package name
type: text
defaultValue: com.example
required: true
- name: groupId
message: Maven group ID
type: text
defaultValue: com.example
required: true
- name: artifactId
message: Maven artifact ID
type: text
defaultValue: my-app
required: true
- name: version
message: Project version
type: text
defaultValue: 1.0.0-SNAPSHOT
required: true
computed:
- name: packagePath
expression: "$str.replace($packageName, '.', '/')"
transforms: []
postGen: []
ignore: []
@@ -0,0 +1,9 @@
# $projectName
A project using package `$packageName`.
## Maven Coordinates
- Group ID: $groupId
- Artifact ID: $artifactId
- Version: $version
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>$groupId</groupId>
<artifactId>$artifactId</artifactId>
<version>$version</version>
<packaging>jar</packaging>
<name>$projectName</name>
<description>Generated project for $packageName</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
@@ -0,0 +1,12 @@
package $packageName;
/**
* Main application class for $projectName.
*/
public class App
{
public static void main(String[] args)
{
System.out.println("Hello from $projectName!");
}
}
@@ -0,0 +1,10 @@
schemaVersion: 2
id: test-minimal
name: Minimal Test Template
version: 1.0.0
description: Minimal template for fast E2E tests
prompts: []
computed: []
transforms: []
postGen: []
ignore: []
@@ -0,0 +1,3 @@
# Minimal Test Project
This is a minimal test template with no variables.
@@ -0,0 +1,15 @@
schemaVersion: 2
id: test-transform-error
name: Transform Error Test Template
version: 1.0.0
description: Template with invalid transform type
prompts: []
computed: []
transforms:
- type: bogus_transform_type
pattern: "**/*.txt"
postGen: []
ignore: []
@@ -0,0 +1 @@
Test file for transform error testing.
@@ -0,0 +1,23 @@
schemaVersion: 2
id: test-transforms
name: Transform Test Template
version: 1.0.0
description: Template for testing directory and file transforms
prompts:
- name: packageName
message: Package name
type: text
defaultValue: com.example
required: true
computed:
- name: packagePath
expression: "$str.replace($packageName, '.', '/')"
transforms:
- type: delete
pattern: "**/.gitkeep"
postGen: []
ignore: []
@@ -0,0 +1,3 @@
# Transform Test
Package: $packageName
@@ -0,0 +1 @@
# This file should be deleted by the delete transform
@@ -0,0 +1,9 @@
package $packageName;
public class App
{
public static void main(String[] args)
{
System.out.println("Hello!");
}
}
@@ -0,0 +1,22 @@
schemaVersion: 2
id: test-variable-typo
name: Variable Typo Test Template
version: 1.0.0
description: Template with intentional variable typo for error testing
prompts:
- name: projectName
message: Project name
type: text
defaultValue: my-project
required: true
- name: packageName
message: Package name
type: text
defaultValue: com.example
required: true
computed: []
transforms: []
postGen: []
ignore: []
@@ -0,0 +1,6 @@
# $projetcName
This template has a typo in the variable name (projetcName instead of projectName).
It should trigger the "Did you mean?" suggestion.
Package: $pacakgeName
@@ -0,0 +1,30 @@
schemaVersion: 2
id: test-with-prompts
name: Test Template with Prompts
version: 1.0.0
description: Template for testing interactive prompts
prompts:
- name: projectName
message: Project name
type: text
defaultValue: my-project
required: true
- name: packageName
message: Package name
type: text
defaultValue: com.example
required: true
- name: includeTests
message: Include tests?
type: confirm
defaultValue: "true"
required: false
computed:
- name: packagePath
expression: "$str.replace($packageName, '.', '/')"
transforms: []
postGen: []
ignore: []
@@ -0,0 +1,3 @@
# $projectName
Package: $packageName
@@ -0,0 +1,11 @@
schemaVersion: 2
id: test-yaml-error
name: YAML Error Test Template
version: 1.0.0
description: Template with intentional YAML syntax error
prompts:
- name: projectName
message: Project name
- this_is_invalid # Intentional YAML error at line 9
type: text
+1 -1
View File
@@ -6,7 +6,7 @@
<parent>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<version>${revision}</version>
</parent>
<artifactId>qctl-qbit</artifactId>
<name>qctl-qbit</name>
@@ -16,6 +16,7 @@ package io.qrun.qctl.qbit;
import java.nio.file.Path;
import io.qrun.qctl.qbit.lock.Lockfile;
import io.qrun.qctl.qbit.lock.LockfileIO;
import io.qrun.qctl.shared.ExitCodes;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
@@ -64,7 +65,7 @@ public class ResolveCommand implements Runnable
catch(Exception e)
{
System.err.println("qbit resolve error: " + e.getMessage());
System.exit(1);
System.exit(ExitCodes.GENERIC);
}
}
}
+52 -1
View File
@@ -6,7 +6,7 @@
<parent>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<version>${revision}</version>
</parent>
<artifactId>qctl-qqq</artifactId>
<name>qctl-qqq</name>
@@ -21,5 +21,56 @@
<artifactId>qctl-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-shared</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>${velocity.version}</version>
</dependency>
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli-codegen</artifactId>
<version>${picocli.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>info.picocli</groupId>
<artifactId>picocli-codegen</artifactId>
<version>${picocli.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,521 @@
/*
* 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;
import java.io.IOException;
import java.io.InputStream;
import java.net.UnknownHostException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.Callable;
import io.qrun.qctl.core.doctor.DependencyChecker;
import io.qrun.qctl.core.doctor.DependencyException;
import io.qrun.qctl.qqq.error.ErrorFormatter;
import io.qrun.qctl.qqq.error.SuggestionEngine;
import io.qrun.qctl.qqq.error.TemplateError;
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.MergeOptions;
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.TransformExecutor;
import io.qrun.qctl.shared.ExitCodes;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/*******************************************************************************
* Initialize a new project from a template.
*
* Supports template IDs from hub, local paths, git URLs, or github shorthand.
* Renders Velocity templates, prompts for variables, and runs post-gen hooks.
*
* @since 0.1.0
*******************************************************************************/
@Command(name = "init", description = "Initialize a new project from a template",
mixinStandardHelpOptions = true)
public class InitCommand implements Callable<Integer>
{
private static final String DEFAULT_TEMPLATE = "new-qqq-application";
private static final String DEFAULT_PROJECT_DIR = "my-qqq-app";
@Parameters(
index = "0",
arity = "0..1",
description = "Template name (default: ${DEFAULT-VALUE})",
defaultValue = DEFAULT_TEMPLATE)
String templateName;
@Option(names = {"-o", "--output"}, description = "Target directory for the new project")
Path targetDir;
@Option(names = "--template-version", description = "Template version (semver tag)")
String version;
@Option(names = "--var", description = "Set a template variable (key=value)")
Map<String, String> variables = new LinkedHashMap<>();
@Option(names = "--force", description = "Overwrite target directory if it exists")
boolean force;
@Option(names = "--dry-run", description = "Preview changes without writing files")
boolean dryRun;
@Option(names = "--skip-hooks", description = "Skip post-generation hooks")
boolean skipHooks;
@Option(names = "--no-prompt", description = "Disable interactive prompts (use defaults)")
boolean noPrompt;
@Option(names = {"-v", "--verbose"}, description = "Show detailed error information")
boolean verbose;
@Option(names = "--merge", description = "Merge with existing directory (skip existing files)")
boolean merge;
@Option(names = "--include", description = "Include patterns for merge (overwrite matching files)",
split = ",")
List<String> includePatterns = new ArrayList<>();
@Option(names = "--exclude", description = "Exclude patterns for merge (protect matching files)",
split = ",")
List<String> excludePatterns = new ArrayList<>();
@Option(names = "--interactive", description = "Prompt for each conflicting file in merge mode")
boolean interactive;
@Option(names = "--no-backup", description = "Disable backup creation when overwriting files")
boolean noBackup;
/***************************************************************************
* Default constructor for picocli.
*
* @since 0.1.0
***************************************************************************/
public InitCommand()
{
}
@Override
public Integer call()
{
ConsoleUI ui = new ConsoleUI();
try
{
ui.header("qctl qqq init");
ui.subtitle("Initialize a new QQQ project");
ui.println();
// Step 1: Template selection from registry
TemplateRegistry registry = TemplateRegistryFactory.getRegistry();
TemplateInfo templateInfo = selectTemplate(registry, ui);
// Step 2: Get target directory
Path effectiveTargetDir = getTargetDirectory(ui);
// Check target directory
if(Files.exists(effectiveTargetDir) && !force && !merge)
{
ui.error("target directory already exists: " + effectiveTargetDir);
ui.println("Use --force to overwrite or --merge to merge with existing.");
return ExitCodes.CONFLICT;
}
// Step 3: Download template from registry
ui.println();
ui.info("Downloading template...");
Path templatePath = registry.downloadTemplate(templateInfo.id(), version);
// Get manifest from template info
TemplateManifest manifest = templateInfo.toManifest();
// Check minimum qctl version
if(manifest.minimumQctlVersion() != null)
{
String currentVersion = getVersionFromProperties();
if(!isVersionSatisfied(currentVersion, manifest.minimumQctlVersion()))
{
ui.error("Template requires qctl " + manifest.minimumQctlVersion() + " or later");
ui.println("Current version: " + currentVersion);
ui.println("Please upgrade qctl to use this template.");
return ExitCodes.VALIDATION;
}
}
// Step 4: Collect variables (prompts + CLI overrides)
Map<String, String> allVars = new LinkedHashMap<>();
if(!noPrompt && manifest.prompts() != null && !manifest.prompts().isEmpty())
{
PromptRunner promptRunner = new PromptRunner();
allVars.putAll(promptRunner.run(manifest.prompts(), variables));
}
allVars.putAll(variables);
// Step 5: Evaluate computed variables
TemplateEngine engine = new TemplateEngine();
allVars = new LinkedHashMap<>(engine.evaluateComputed(manifest.computed(), allVars));
///////////////////////////////////////////////////////////////////////
// Build merge options //
///////////////////////////////////////////////////////////////////////
MergeOptions mergeOptions = merge
? new MergeOptions(true, includePatterns, excludePatterns, interactive, !noBackup)
: MergeOptions.disabled();
if(dryRun)
{
ui.println();
ui.println("[DRY-RUN] Would create files:");
engine.renderDryRun(templatePath, effectiveTargetDir, allVars, manifest, mergeOptions);
}
else
{
if(force && Files.exists(effectiveTargetDir))
{
ui.info("Overwriting existing directory...");
}
if(merge && Files.exists(effectiveTargetDir))
{
ui.info("Merging with existing directory...");
}
Files.createDirectories(effectiveTargetDir);
int fileCount = engine.render(templatePath, effectiveTargetDir, allVars, manifest,
mergeOptions, ui);
ui.println();
ui.success("Created " + fileCount + " files in " + effectiveTargetDir);
// Run transforms (rename/delete)
if(manifest.transforms() != null && !manifest.transforms().isEmpty())
{
ui.info("Applying transforms...");
TransformExecutor transformExecutor = new TransformExecutor();
transformExecutor.execute(effectiveTargetDir, manifest.transforms(), allVars);
}
// Run post-gen hooks
if(!skipHooks && manifest.postGen() != null)
{
//////////////////////////////////////////////////////////////////
// Pre-flight dependency checks before running hooks //
//////////////////////////////////////////////////////////////////
try
{
checkHookDependencies(manifest.postGen(), ui);
}
catch(DependencyException e)
{
ui.warning("Skipping post-gen hooks: " + e.getMessage());
ui.println("Run 'qctl doctor' for installation help.");
skipHooks = true;
}
if(!skipHooks)
{
PostGenHookRunner hookRunner = new PostGenHookRunner();
hookRunner.run(effectiveTargetDir, manifest.postGen());
}
}
}
ui.println();
ui.successBold("Project initialized successfully!");
ui.println();
ui.println("Next steps:");
ui.println(" cd " + effectiveTargetDir);
ui.println(" mvn clean verify");
return ExitCodes.SUCCESS;
}
catch(TemplateRenderException e)
{
ErrorFormatter formatter = new ErrorFormatter(verbose);
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.RENDER_ERROR,
e.getMessage(),
"Check template syntax and ensure all variables are defined"
);
System.err.print(formatter.format(error));
return error.getExitCode();
}
catch(IOException e)
{
ErrorFormatter formatter = new ErrorFormatter(verbose);
String hint = e instanceof UnknownHostException
? "Check your network connection and try again"
: null;
System.err.print(formatter.format(e, hint));
return ExitCodes.GENERIC;
}
catch(TemplateError e)
{
ErrorFormatter formatter = new ErrorFormatter(verbose);
System.err.print(formatter.format(e));
return e.getExitCode();
}
catch(Exception e)
{
ErrorFormatter formatter = new ErrorFormatter(verbose);
System.err.print(formatter.format(e, null));
return ExitCodes.GENERIC;
}
}
/***************************************************************************
* Select template - use provided name or prompt interactively.
*
* @param registry template registry
* @param ui console UI
* @return selected template info
* @throws IOException if registry cannot be fetched
* @since 0.1.0
***************************************************************************/
private TemplateInfo selectTemplate(TemplateRegistry registry, ConsoleUI ui) throws IOException
{
List<TemplateInfo> templates = registry.listTemplates();
// If template name provided and found, use it
Optional<TemplateInfo> entry = registry.getTemplate(templateName);
if(entry.isPresent())
{
TemplateInfo selected = entry.get();
ui.showValue("Template", selected.name(), selected.id());
return selected;
}
/////////////////////////////////////////////////////////////////////////
// Template not found - show error with suggestions //
/////////////////////////////////////////////////////////////////////////
List<String> templateIds = templates.stream().map(TemplateInfo::id).toList();
SuggestionEngine suggestionEngine = new SuggestionEngine();
List<String> suggestions = suggestionEngine.findSimilar(templateName, templateIds);
ui.error("Template '" + templateName + "' not found.");
if(!suggestions.isEmpty())
{
ui.println();
ui.println("Did you mean?");
for(String suggestion : suggestions)
{
ui.println(" " + suggestion);
}
}
ui.println();
// Find default index
int defaultIndex = 0;
for(int i = 0; i < templates.size(); i++)
{
if(DEFAULT_TEMPLATE.equals(templates.get(i).id()))
{
defaultIndex = i;
break;
}
}
return ui.promptSelect(
"Select a template:",
templates,
TemplateInfo::name,
TemplateInfo::description,
defaultIndex
);
}
/***************************************************************************
* Get target directory - use provided value or prompt interactively.
*
* @param ui console UI
* @return target directory path
* @since 0.1.0
***************************************************************************/
private Path getTargetDirectory(ConsoleUI ui)
{
if(targetDir != null)
{
ui.showValue("Project directory", targetDir.toString());
return targetDir;
}
if(noPrompt)
{
ui.showValue("Project directory", DEFAULT_PROJECT_DIR);
return Path.of(DEFAULT_PROJECT_DIR);
}
String input = ui.promptText("Project directory", DEFAULT_PROJECT_DIR);
return Path.of(input);
}
/***************************************************************************
* Get version from version.properties resource file.
*
* @return version string from properties or "0.0.0-dev" if not found
* @since 0.2.0
***************************************************************************/
private String getVersionFromProperties()
{
Properties props = new Properties();
try(InputStream is = getClass().getResourceAsStream("/version.properties"))
{
if(is != null)
{
props.load(is);
}
}
catch(IOException e)
{
// Fall back to default
}
return props.getProperty("version", "0.0.0-dev");
}
/***************************************************************************
* Check if current version satisfies the minimum version requirement.
*
* @param current current version string (e.g., "0.2.0")
* @param minimum minimum required version (e.g., "0.1.0")
* @return true if current >= minimum
* @since 0.2.0
***************************************************************************/
private boolean isVersionSatisfied(String current, String minimum)
{
int[] currentParts = parseVersion(current);
int[] minimumParts = parseVersion(minimum);
for(int i = 0; i < 3; i++)
{
if(currentParts[i] > minimumParts[i])
{
return true;
}
if(currentParts[i] < minimumParts[i])
{
return false;
}
}
return true;
}
/***************************************************************************
* Parse a version string into major, minor, patch components.
*
* @param version version string (e.g., "1.2.3" or "1.2.3-dev")
* @return array of [major, minor, patch]
* @since 0.2.0
***************************************************************************/
private int[] parseVersion(String version)
{
int[] parts = {0, 0, 0};
if(version == null || version.isEmpty())
{
return parts;
}
/////////////////////////////////////////////////////////////////////////
// Strip any suffix after dash (e.g., "1.2.3-dev" -> "1.2.3") //
/////////////////////////////////////////////////////////////////////////
String cleanVersion = version.contains("-") ? version.split("-")[0] : version;
String[] segments = cleanVersion.split("\\.");
for(int i = 0; i < Math.min(segments.length, 3); i++)
{
try
{
parts[i] = Integer.parseInt(segments[i]);
}
catch(NumberFormatException e)
{
parts[i] = 0;
}
}
return parts;
}
/***************************************************************************
* Check dependencies required by post-gen hooks before running them.
*
* Examines hook commands and verifies required tools are installed:
* - git commands require Git
* - mvn commands require Java 21+ and Maven
*
* @param hooks list of post-gen hooks to check
* @param ui console UI for status messages
* @throws DependencyException if a required dependency is missing
* @since 0.2.0
***************************************************************************/
private void checkHookDependencies(List<TemplateManifest.PostGenHook> hooks, ConsoleUI ui)
throws DependencyException
{
boolean needsGit = false;
boolean needsMaven = false;
for(TemplateManifest.PostGenHook hook : hooks)
{
if(hook.command() == null)
{
continue;
}
String cmd = hook.command().trim().toLowerCase();
if(cmd.startsWith("git "))
{
needsGit = true;
}
if(cmd.startsWith("mvn ") || cmd.startsWith("./mvnw ") || cmd.contains("maven"))
{
needsMaven = true;
}
}
if(needsGit)
{
DependencyChecker.requireGit();
}
if(needsMaven)
{
DependencyChecker.requireJava(21);
DependencyChecker.requireMaven();
}
}
}
@@ -0,0 +1,112 @@
/*
* 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;
import java.io.IOException;
import java.net.UnknownHostException;
import java.util.List;
import java.util.concurrent.Callable;
import io.qrun.qctl.qqq.error.ErrorFormatter;
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 registry.
*
* @since 0.1.0
*******************************************************************************/
@Command(name = "list", description = "List available project templates")
public class ListCommand implements Callable<Integer>
{
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;
@Option(names = {"-v", "--verbose"}, description = "Show detailed error information")
boolean verbose;
/***************************************************************************
* Default constructor for picocli.
*
* @since 0.1.0
***************************************************************************/
public ListCommand()
{
}
@Override
public Integer call()
{
try
{
System.out.println("Fetching templates from registry...\n");
TemplateRegistry registry = TemplateRegistryFactory.getRegistry();
if(refresh)
{
registry.refresh();
}
List<TemplateInfo> templates = registry.listTemplates();
if(templates.isEmpty())
{
System.out.println("No templates available.");
return ExitCodes.SUCCESS;
}
System.out.println(ANSI_BOLD + "Available Templates:" + ANSI_RESET + "\n");
for(TemplateInfo template : templates)
{
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())
{
System.out.println(" Tags: " + String.join(", ", template.tags()));
}
System.out.println();
}
System.out.println("Use: qctl qqq init <template-id> -o <target-dir>");
return ExitCodes.SUCCESS;
}
catch(IOException e)
{
ErrorFormatter formatter = new ErrorFormatter(verbose);
String hint = e instanceof UnknownHostException
? "Check your network connection and try again"
: "Run with --refresh to bypass cache";
System.err.print(formatter.format(e, hint));
return ExitCodes.NETWORK;
}
}
}
@@ -13,10 +13,16 @@
package io.qrun.qctl.qqq;
import io.qrun.qctl.core.VersionProvider;
import picocli.CommandLine.Command;
@Command(name = "qqq", description = "Scaffolding commands", mixinStandardHelpOptions = true)
@Command(
name = "qqq",
description = "Scaffolding commands",
mixinStandardHelpOptions = true,
versionProvider = VersionProvider.class,
subcommands = { InitCommand.class, ListCommand.class })
public class QqqCommand implements Runnable
{
/***************************************************************************
@@ -0,0 +1,143 @@
/*
* 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.error;
import java.io.PrintWriter;
import java.io.StringWriter;
/*******************************************************************************
* Formats errors with consistent styling and recovery suggestions.
*
* @since 0.2.0
*******************************************************************************/
public class ErrorFormatter
{
private static final String ANSI_RED = "\u001B[31m";
private static final String ANSI_YELLOW = "\u001B[33m";
private static final String ANSI_CYAN = "\u001B[36m";
private static final String ANSI_DIM = "\u001B[2m";
private static final String ANSI_RESET = "\u001B[0m";
private final boolean verbose;
/***************************************************************************
* Create an error formatter.
*
* @param verbose whether to include stack traces
* @since 0.2.0
***************************************************************************/
public ErrorFormatter(boolean verbose)
{
this.verbose = verbose;
}
/***************************************************************************
* Format a template error for display.
*
* @param error the template error
* @return formatted error message
* @since 0.2.0
***************************************************************************/
public String format(TemplateError error)
{
StringBuilder sb = new StringBuilder();
// Error message
sb.append(ANSI_RED).append("error: ").append(ANSI_RESET);
sb.append(error.getMessage()).append("\n");
// Suggestions (did you mean?)
if(error.hasSuggestions())
{
sb.append("\n");
sb.append(ANSI_YELLOW).append("Did you mean?").append(ANSI_RESET).append("\n");
for(String suggestion : error.getSuggestions())
{
sb.append(" ").append(ANSI_CYAN).append(suggestion).append(ANSI_RESET).append("\n");
}
}
// Hint
if(error.getHint() != null)
{
sb.append("\n");
sb.append(ANSI_DIM).append(error.getHint()).append(ANSI_RESET).append("\n");
}
// Stack trace (verbose mode)
if(verbose && error.getCause() != null)
{
sb.append("\n");
sb.append(ANSI_DIM).append("Stack trace:").append(ANSI_RESET).append("\n");
sb.append(getStackTrace(error.getCause()));
}
return sb.toString();
}
/***************************************************************************
* Format a generic exception.
*
* @param error the exception
* @param hint optional hint for recovery
* @return formatted error message
* @since 0.2.0
***************************************************************************/
public String format(Exception error, String hint)
{
StringBuilder sb = new StringBuilder();
sb.append(ANSI_RED).append("error: ").append(ANSI_RESET);
sb.append(error.getMessage()).append("\n");
if(hint != null)
{
sb.append("\n");
sb.append(ANSI_DIM).append(hint).append(ANSI_RESET).append("\n");
}
if(verbose)
{
sb.append("\n");
sb.append(ANSI_DIM).append("Stack trace:").append(ANSI_RESET).append("\n");
sb.append(getStackTrace(error));
}
return sb.toString();
}
/***************************************************************************
* Get stack trace as string.
*
* @param throwable the exception
* @return stack trace string
* @since 0.2.0
***************************************************************************/
private String getStackTrace(Throwable throwable)
{
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
throwable.printStackTrace(pw);
return sw.toString();
}
}
@@ -0,0 +1,147 @@
/*
* 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.error;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/*******************************************************************************
* Provides fuzzy matching suggestions for typos and similar names.
*
* Uses Levenshtein distance and prefix matching to suggest alternatives.
*
* @since 0.2.0
*******************************************************************************/
public class SuggestionEngine
{
private static final int MAX_SUGGESTIONS = 3;
private static final int MAX_DISTANCE = 3;
/***************************************************************************
* Find similar strings from a list of candidates.
*
* @param input the user's input
* @param candidates list of valid options
* @return list of similar options, sorted by relevance
* @since 0.2.0
***************************************************************************/
public List<String> findSimilar(String input, List<String> candidates)
{
if(input == null || input.isEmpty() || candidates == null || candidates.isEmpty())
{
return List.of();
}
String lowerInput = input.toLowerCase();
List<ScoredCandidate> scored = new ArrayList<>();
for(String candidate : candidates)
{
int score = calculateScore(lowerInput, candidate.toLowerCase());
if(score <= MAX_DISTANCE)
{
scored.add(new ScoredCandidate(candidate, score));
}
}
scored.sort(Comparator.comparingInt(ScoredCandidate::score));
return scored.stream()
.limit(MAX_SUGGESTIONS)
.map(ScoredCandidate::value)
.toList();
}
/***************************************************************************
* Calculate similarity score (lower is better).
*
* Combines Levenshtein distance with prefix bonus.
*
* @param input user input (lowercase)
* @param candidate candidate value (lowercase)
* @return similarity score
* @since 0.2.0
***************************************************************************/
private int calculateScore(String input, String candidate)
{
int distance = levenshteinDistance(input, candidate);
// Bonus for prefix match (reduce distance)
if(candidate.startsWith(input) || input.startsWith(candidate))
{
distance = Math.max(0, distance - 1);
}
// Bonus for containing the input
if(candidate.contains(input))
{
distance = Math.max(0, distance - 1);
}
return distance;
}
/***************************************************************************
* Calculate Levenshtein distance between two strings.
*
* @param s1 first string
* @param s2 second string
* @return edit distance
* @since 0.2.0
***************************************************************************/
private int levenshteinDistance(String s1, String s2)
{
int[][] dp = new int[s1.length() + 1][s2.length() + 1];
for(int i = 0; i <= s1.length(); i++)
{
dp[i][0] = i;
}
for(int j = 0; j <= s2.length(); j++)
{
dp[0][j] = j;
}
for(int i = 1; i <= s1.length(); i++)
{
for(int j = 1; j <= s2.length(); j++)
{
int cost = s1.charAt(i - 1) == s2.charAt(j - 1) ? 0 : 1;
dp[i][j] = Math.min(
Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1),
dp[i - 1][j - 1] + cost
);
}
}
return dp[s1.length()][s2.length()];
}
/***************************************************************************
* Scored candidate for sorting.
***************************************************************************/
private record ScoredCandidate(String value, int score)
{
}
}
@@ -0,0 +1,209 @@
/*
* 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.error;
import java.util.List;
import io.qrun.qctl.shared.ExitCodes;
/*******************************************************************************
* Structured error with category, message, and recovery suggestions.
*
* @since 0.2.0
*******************************************************************************/
public class TemplateError extends RuntimeException
{
private final ErrorCategory category;
private final List<String> suggestions;
private final String hint;
/***************************************************************************
* Error categories mapping to exit codes.
*
* @since 0.2.0
***************************************************************************/
public enum ErrorCategory
{
TEMPLATE_NOT_FOUND(ExitCodes.NOT_FOUND),
VARIABLE_NOT_FOUND(ExitCodes.VALIDATION),
MANIFEST_INVALID(ExitCodes.VALIDATION),
RENDER_ERROR(ExitCodes.VALIDATION),
TRANSFORM_ERROR(ExitCodes.VALIDATION),
NETWORK_ERROR(ExitCodes.NETWORK),
VERSION_MISMATCH(ExitCodes.VALIDATION),
DIRECTORY_EXISTS(ExitCodes.CONFLICT),
IO_ERROR(ExitCodes.GENERIC);
private final int exitCode;
/*************************************************************************
* Create an error category with exit code.
*
* @param exitCode the exit code for this category
* @since 0.2.0
*************************************************************************/
ErrorCategory(int exitCode)
{
this.exitCode = exitCode;
}
/*************************************************************************
* Get the exit code for this category.
*
* @return exit code
* @since 0.2.0
*************************************************************************/
public int getExitCode()
{
return exitCode;
}
}
/***************************************************************************
* Create a template error.
*
* @param category error category
* @param message error message
* @param suggestions list of suggestions (e.g., similar names)
* @param hint actionable hint for recovery
* @since 0.2.0
***************************************************************************/
public TemplateError(ErrorCategory category, String message, List<String> suggestions, String hint)
{
super(message);
this.category = category;
this.suggestions = suggestions != null ? suggestions : List.of();
this.hint = hint;
}
/***************************************************************************
* Create a template error without suggestions.
*
* @param category error category
* @param message error message
* @param hint actionable hint
* @since 0.2.0
***************************************************************************/
public TemplateError(ErrorCategory category, String message, String hint)
{
this(category, message, null, hint);
}
/***************************************************************************
* Create a template error with only message.
*
* @param category error category
* @param message error message
* @since 0.2.0
***************************************************************************/
public TemplateError(ErrorCategory category, String message)
{
this(category, message, null, null);
}
/***************************************************************************
* Create a template error wrapping a cause.
*
* @param category error category
* @param message error message
* @param cause the underlying cause
* @since 0.2.0
***************************************************************************/
public TemplateError(ErrorCategory category, String message, Throwable cause)
{
super(message, cause);
this.category = category;
this.suggestions = List.of();
this.hint = null;
}
/***************************************************************************
* Get the error category.
*
* @return error category
* @since 0.2.0
***************************************************************************/
public ErrorCategory getCategory()
{
return category;
}
/***************************************************************************
* Get the exit code for this error.
*
* @return exit code
* @since 0.2.0
***************************************************************************/
public int getExitCode()
{
return category.getExitCode();
}
/***************************************************************************
* Get suggestions for recovery.
*
* @return list of suggestions
* @since 0.2.0
***************************************************************************/
public List<String> getSuggestions()
{
return suggestions;
}
/***************************************************************************
* Get actionable hint.
*
* @return hint or null
* @since 0.2.0
***************************************************************************/
public String getHint()
{
return hint;
}
/***************************************************************************
* Check if error has suggestions.
*
* @return true if suggestions available
* @since 0.2.0
***************************************************************************/
public boolean hasSuggestions()
{
return !suggestions.isEmpty();
}
}
@@ -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;
}
}
@@ -0,0 +1,35 @@
/*
* 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.template;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/*******************************************************************************
* A computed variable definition for template manifests.
*
* Computed variables are derived from other variables using Velocity expressions.
* They are evaluated in order after prompts are collected.
*
* @param name variable name to create
* @param expression Velocity expression to evaluate
* @since 0.2.0
*******************************************************************************/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ComputedVariable(
String name,
String expression
)
{
}
@@ -0,0 +1,344 @@
/*
* 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.template;
import java.io.Console;
import java.util.List;
import java.util.Scanner;
import java.util.function.Function;
/*******************************************************************************
* Console UI utilities for interactive prompts.
*
* Provides consistent styling and input handling for CLI interactions.
* Falls back to stdin when console is unavailable.
*
* @since 0.1.0
*******************************************************************************/
public class ConsoleUI
{
private static final String ANSI_BOLD = "\u001B[1m";
private static final String ANSI_CYAN = "\u001B[36m";
private static final String ANSI_RESET = "\u001B[0m";
private static final String ANSI_DIM = "\u001B[2m";
private static final String ANSI_GREEN = "\u001B[32m";
private static final String ANSI_RED = "\u001B[31m";
private static final String ANSI_YELLOW = "\u001B[33m";
private final Console console;
private final Scanner scanner;
/***************************************************************************
* Create a new ConsoleUI.
*
* @since 0.1.0
***************************************************************************/
public ConsoleUI()
{
this.console = System.console();
this.scanner = new Scanner(System.in);
}
/***************************************************************************
* Print a header line.
*
* @param title the title text
* @since 0.1.0
***************************************************************************/
public void header(String title)
{
System.out.println();
System.out.println(ANSI_BOLD + title + ANSI_RESET);
}
/***************************************************************************
* Print a subtitle/description.
*
* @param text the subtitle text
* @since 0.1.0
***************************************************************************/
public void subtitle(String text)
{
System.out.println(ANSI_DIM + text + ANSI_RESET);
}
/***************************************************************************
* Print an info message.
*
* @param text the message text
* @since 0.1.0
***************************************************************************/
public void info(String text)
{
System.out.println(ANSI_DIM + text + ANSI_RESET);
}
/***************************************************************************
* Print a success message.
*
* @param text the message text
* @since 0.1.0
***************************************************************************/
public void success(String text)
{
System.out.println(ANSI_GREEN + text + ANSI_RESET);
}
/***************************************************************************
* Print a bold success message.
*
* @param text the message text
* @since 0.1.0
***************************************************************************/
public void successBold(String text)
{
System.out.println(ANSI_GREEN + ANSI_BOLD + text + ANSI_RESET);
}
/***************************************************************************
* Print an error message.
*
* @param text the message text
* @since 0.1.0
***************************************************************************/
public void error(String text)
{
System.err.println("error: " + text);
}
/***************************************************************************
* Print a validation error message inline.
*
* @param text the error message
* @since 0.2.0
***************************************************************************/
public void validationError(String text)
{
System.out.println(" " + ANSI_RED + "✗ " + text + ANSI_RESET);
}
/***************************************************************************
* Print a validation success indicator.
*
* @since 0.2.0
***************************************************************************/
public void validationSuccess()
{
System.out.println(" " + ANSI_GREEN + "✓" + ANSI_RESET);
}
/***************************************************************************
* Print a warning message.
*
* @param text the warning message
* @since 0.2.0
***************************************************************************/
public void warning(String text)
{
System.out.println(ANSI_YELLOW + "warning: " + text + ANSI_RESET);
}
/***************************************************************************
* Print a blank line.
*
* @since 0.1.0
***************************************************************************/
public void println()
{
System.out.println();
}
/***************************************************************************
* Print text.
*
* @param text the text to print
* @since 0.1.0
***************************************************************************/
public void println(String text)
{
System.out.println(text);
}
/***************************************************************************
* Prompt for text input.
*
* @param label the prompt label
* @param defaultValue default value (may be null)
* @return user input or default
* @since 0.1.0
***************************************************************************/
public String promptText(String label, String defaultValue)
{
StringBuilder sb = new StringBuilder();
sb.append(ANSI_CYAN).append("? ").append(ANSI_RESET);
sb.append(ANSI_BOLD).append(label).append(ANSI_RESET);
if(defaultValue != null)
{
sb.append(" ").append(ANSI_DIM).append("(").append(defaultValue).append(")").append(ANSI_RESET);
}
sb.append(": ");
System.out.print(sb);
String input = readLine();
if(input == null || input.isBlank())
{
return defaultValue != null ? defaultValue : "";
}
return input.trim();
}
/***************************************************************************
* Display a value that was pre-set (not prompted).
*
* @param label the label
* @param value the value
* @since 0.1.0
***************************************************************************/
public void showValue(String label, String value)
{
System.out.println(ANSI_CYAN + "? " + ANSI_RESET + ANSI_BOLD + label + ANSI_RESET + ": " + value);
}
/***************************************************************************
* Display a value with a dimmed annotation.
*
* @param label the label
* @param value the value
* @param annotation additional info in parentheses
* @since 0.1.0
***************************************************************************/
public void showValue(String label, String value, String annotation)
{
System.out.println(ANSI_CYAN + "? " + ANSI_RESET + ANSI_BOLD + label + ANSI_RESET
+ ": " + value + " " + ANSI_DIM + "(" + annotation + ")" + ANSI_RESET);
}
/***************************************************************************
* Prompt for selection from a list.
*
* @param <T> the type of items
* @param label the prompt label
* @param items the list of items
* @param displayFn function to get display text for each item
* @param descriptionFn function to get description for each item (may return null)
* @param defaultIndex default selection index (0-based)
* @return selected item
* @since 0.1.0
***************************************************************************/
public <T> T promptSelect(String label,
List<T> items,
Function<T, String> displayFn,
Function<T, String> descriptionFn,
int defaultIndex)
{
System.out.println(ANSI_CYAN + "? " + ANSI_RESET + ANSI_BOLD + label + ANSI_RESET);
for(int i = 0; i < items.size(); i++)
{
T item = items.get(i);
String display = displayFn.apply(item);
String desc = descriptionFn != null ? descriptionFn.apply(item) : null;
boolean isDefault = (i == defaultIndex);
StringBuilder sb = new StringBuilder();
sb.append(" ").append(i + 1).append(") ").append(display);
if(desc != null)
{
sb.append(ANSI_DIM).append(" - ").append(desc).append(ANSI_RESET);
}
if(isDefault)
{
sb.append(" (default)");
}
System.out.println(sb);
}
System.out.print(" Enter number [1-" + items.size() + "]: ");
String input = readLine();
int selectedIndex = defaultIndex;
if(input != null && !input.isBlank())
{
try
{
int parsed = Integer.parseInt(input.trim()) - 1;
if(parsed >= 0 && parsed < items.size())
{
selectedIndex = parsed;
}
}
catch(NumberFormatException e)
{
// Use default
}
}
return items.get(selectedIndex);
}
/***************************************************************************
* Read a line from console or stdin.
*
* @return user input line
* @since 0.1.0
***************************************************************************/
private String readLine()
{
if(console != null)
{
return console.readLine();
}
else if(scanner.hasNextLine())
{
return scanner.nextLine();
}
return "";
}
}
@@ -0,0 +1,364 @@
/*
* 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.template;
import java.util.ArrayList;
import java.util.List;
/*******************************************************************************
* Generates unified diff output for file comparison.
*
* @since 0.2.0
*******************************************************************************/
public class FileDiffer
{
private static final String ANSI_RED = "\u001B[31m";
private static final String ANSI_GREEN = "\u001B[32m";
private static final String ANSI_CYAN = "\u001B[36m";
private static final String ANSI_RESET = "\u001B[0m";
private static final int CONTEXT_LINES = 3;
/***************************************************************************
* Generate a unified diff between two strings.
*
* @param existing the existing file content
* @param updated the new file content
* @param filename the filename for the header
* @return formatted diff output
* @since 0.2.0
***************************************************************************/
public String diff(String existing, String updated, String filename)
{
String[] oldLines = existing.split("\n", -1);
String[] newLines = updated.split("\n", -1);
List<DiffLine> diffLines = computeDiff(oldLines, newLines);
return formatDiff(diffLines, filename, oldLines.length, newLines.length);
}
/***************************************************************************
* Compute the diff between two arrays of lines.
*
* @param oldLines original lines
* @param newLines new lines
* @return list of diff lines
* @since 0.2.0
***************************************************************************/
private List<DiffLine> computeDiff(String[] oldLines, String[] newLines)
{
List<DiffLine> result = new ArrayList<>();
/////////////////////////////////////////////////////////////////////////
// Simple line-by-line diff using longest common subsequence //
/////////////////////////////////////////////////////////////////////////
int[][] lcs = computeLCS(oldLines, newLines);
int i = oldLines.length;
int j = newLines.length;
List<DiffLine> tempResult = new ArrayList<>();
while(i > 0 || j > 0)
{
if(i > 0 && j > 0 && oldLines[i - 1].equals(newLines[j - 1]))
{
tempResult.add(new DiffLine(DiffType.CONTEXT, oldLines[i - 1], i, j));
i--;
j--;
}
else if(j > 0 && (i == 0 || lcs[i][j - 1] >= lcs[i - 1][j]))
{
tempResult.add(new DiffLine(DiffType.ADD, newLines[j - 1], -1, j));
j--;
}
else
{
tempResult.add(new DiffLine(DiffType.DELETE, oldLines[i - 1], i, -1));
i--;
}
}
/////////////////////////////////////////////////////////////////////////
// Reverse to get correct order //
/////////////////////////////////////////////////////////////////////////
for(int k = tempResult.size() - 1; k >= 0; k--)
{
result.add(tempResult.get(k));
}
return result;
}
/***************************************************************************
* Compute the longest common subsequence matrix.
*
* @param oldLines original lines
* @param newLines new lines
* @return LCS matrix
* @since 0.2.0
***************************************************************************/
private int[][] computeLCS(String[] oldLines, String[] newLines)
{
int m = oldLines.length;
int n = newLines.length;
int[][] lcs = new int[m + 1][n + 1];
for(int i = 1; i <= m; i++)
{
for(int j = 1; j <= n; j++)
{
if(oldLines[i - 1].equals(newLines[j - 1]))
{
lcs[i][j] = lcs[i - 1][j - 1] + 1;
}
else
{
lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]);
}
}
}
return lcs;
}
/***************************************************************************
* Format the diff lines into unified diff output.
*
* @param diffLines the diff lines
* @param filename the filename
* @param oldCount original line count
* @param newCount new line count
* @return formatted diff string
* @since 0.2.0
***************************************************************************/
private String formatDiff(List<DiffLine> diffLines, String filename,
int oldCount, int newCount)
{
StringBuilder sb = new StringBuilder();
/////////////////////////////////////////////////////////////////////////
// Header //
/////////////////////////////////////////////////////////////////////////
sb.append(ANSI_RED).append("--- existing ").append(filename)
.append(ANSI_RESET).append("\n");
sb.append(ANSI_GREEN).append("+++ template ").append(filename)
.append(ANSI_RESET).append("\n");
/////////////////////////////////////////////////////////////////////////
// Find hunks (groups of changes with context) //
/////////////////////////////////////////////////////////////////////////
List<Hunk> hunks = findHunks(diffLines);
for(Hunk hunk : hunks)
{
sb.append(ANSI_CYAN)
.append("@@ -").append(hunk.oldStart).append(",").append(hunk.oldCount)
.append(" +").append(hunk.newStart).append(",").append(hunk.newCount)
.append(" @@").append(ANSI_RESET).append("\n");
for(DiffLine line : hunk.lines)
{
switch(line.type)
{
case ADD:
sb.append(ANSI_GREEN).append("+").append(line.content)
.append(ANSI_RESET).append("\n");
break;
case DELETE:
sb.append(ANSI_RED).append("-").append(line.content)
.append(ANSI_RESET).append("\n");
break;
case CONTEXT:
sb.append(" ").append(line.content).append("\n");
break;
default:
break;
}
}
}
if(hunks.isEmpty())
{
sb.append("(no differences)\n");
}
return sb.toString();
}
/***************************************************************************
* Find hunks (groups of changes with surrounding context).
*
* @param diffLines all diff lines
* @return list of hunks
* @since 0.2.0
***************************************************************************/
private List<Hunk> findHunks(List<DiffLine> diffLines)
{
List<Hunk> hunks = new ArrayList<>();
List<Integer> changeIndices = new ArrayList<>();
/////////////////////////////////////////////////////////////////////////
// Find all change indices //
/////////////////////////////////////////////////////////////////////////
for(int i = 0; i < diffLines.size(); i++)
{
if(diffLines.get(i).type != DiffType.CONTEXT)
{
changeIndices.add(i);
}
}
if(changeIndices.isEmpty())
{
return hunks;
}
/////////////////////////////////////////////////////////////////////////
// Group changes into hunks //
/////////////////////////////////////////////////////////////////////////
int hunkStart = Math.max(0, changeIndices.get(0) - CONTEXT_LINES);
int hunkEnd = Math.min(diffLines.size() - 1, changeIndices.get(0) + CONTEXT_LINES);
for(int i = 1; i < changeIndices.size(); i++)
{
int changeIdx = changeIndices.get(i);
int changeStart = changeIdx - CONTEXT_LINES;
if(changeStart <= hunkEnd + 1)
{
hunkEnd = Math.min(diffLines.size() - 1, changeIdx + CONTEXT_LINES);
}
else
{
hunks.add(createHunk(diffLines, hunkStart, hunkEnd));
hunkStart = Math.max(0, changeIdx - CONTEXT_LINES);
hunkEnd = Math.min(diffLines.size() - 1, changeIdx + CONTEXT_LINES);
}
}
hunks.add(createHunk(diffLines, hunkStart, hunkEnd));
return hunks;
}
/***************************************************************************
* Create a hunk from a range of diff lines.
*
* @param diffLines all diff lines
* @param start start index
* @param end end index
* @return the hunk
* @since 0.2.0
***************************************************************************/
private Hunk createHunk(List<DiffLine> diffLines, int start, int end)
{
List<DiffLine> hunkLines = new ArrayList<>();
int oldStart = 1;
int oldCount = 0;
int newStart = 1;
int newCount = 0;
boolean foundOldStart = false;
boolean foundNewStart = false;
for(int i = start; i <= end && i < diffLines.size(); i++)
{
DiffLine line = diffLines.get(i);
hunkLines.add(line);
switch(line.type)
{
case CONTEXT:
if(!foundOldStart && line.oldLine > 0)
{
oldStart = line.oldLine;
foundOldStart = true;
}
if(!foundNewStart && line.newLine > 0)
{
newStart = line.newLine;
foundNewStart = true;
}
oldCount++;
newCount++;
break;
case DELETE:
if(!foundOldStart && line.oldLine > 0)
{
oldStart = line.oldLine;
foundOldStart = true;
}
oldCount++;
break;
case ADD:
if(!foundNewStart && line.newLine > 0)
{
newStart = line.newLine;
foundNewStart = true;
}
newCount++;
break;
default:
break;
}
}
return new Hunk(hunkLines, oldStart, oldCount, newStart, newCount);
}
/***************************************************************************
* Types of diff lines.
***************************************************************************/
private enum DiffType
{
CONTEXT,
ADD,
DELETE
}
/***************************************************************************
* A single line in the diff.
***************************************************************************/
private record DiffLine(DiffType type, String content, int oldLine, int newLine)
{
}
/***************************************************************************
* A hunk (group of changes with context).
***************************************************************************/
private record Hunk(List<DiffLine> lines, int oldStart, int oldCount,
int newStart, int newCount)
{
}
}
@@ -0,0 +1,147 @@
/*
* 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.template;
import java.util.List;
/*******************************************************************************
* Configuration options for merge mode during template rendering.
*
* @param enabled whether merge mode is active
* @param includePatterns glob patterns for files to include (overwrite if exist)
* @param excludePatterns glob patterns for files to exclude (never overwrite)
* @param interactive whether to prompt for each conflicting file
* @param createBackups whether to create backups before overwriting
* @since 0.2.0
*******************************************************************************/
public record MergeOptions(
boolean enabled,
List<String> includePatterns,
List<String> excludePatterns,
boolean interactive,
boolean createBackups
)
{
/***************************************************************************
* Create disabled merge options (normal overwrite behavior).
*
* @return disabled merge options
* @since 0.2.0
***************************************************************************/
public static MergeOptions disabled()
{
return new MergeOptions(false, List.of(), List.of(), false, false);
}
/***************************************************************************
* Create default merge options (skip existing, create backups).
*
* @return default merge options
* @since 0.2.0
***************************************************************************/
public static MergeOptions defaults()
{
return new MergeOptions(true, List.of(), List.of(), false, true);
}
/***************************************************************************
* Check if a file path matches the include patterns.
*
* @param relativePath the relative file path to check
* @return true if the file matches include patterns (or no patterns defined)
* @since 0.2.0
***************************************************************************/
public boolean matchesInclude(String relativePath)
{
if(includePatterns == null || includePatterns.isEmpty())
{
return true;
}
return matchesAnyPattern(relativePath, includePatterns);
}
/***************************************************************************
* Check if a file path matches the exclude patterns.
*
* @param relativePath the relative file path to check
* @return true if the file matches exclude patterns
* @since 0.2.0
***************************************************************************/
public boolean matchesExclude(String relativePath)
{
if(excludePatterns == null || excludePatterns.isEmpty())
{
return false;
}
return matchesAnyPattern(relativePath, excludePatterns);
}
/***************************************************************************
* Check if a file path matches any of the given glob patterns.
*
* @param relativePath the relative file path to check
* @param patterns the glob patterns to match against
* @return true if the file matches any pattern
* @since 0.2.0
***************************************************************************/
private boolean matchesAnyPattern(String relativePath, List<String> patterns)
{
for(String pattern : patterns)
{
if(matchesGlob(relativePath, pattern))
{
return true;
}
}
return false;
}
/***************************************************************************
* Match a path against a glob pattern.
*
* @param path the path to check
* @param pattern the glob pattern
* @return true if the path matches
* @since 0.2.0
***************************************************************************/
private boolean matchesGlob(String path, String pattern)
{
/////////////////////////////////////////////////////////////////////////
// Convert glob to regex: //
// ** matches any path segments //
// * matches any characters except path separator //
// ? matches single character //
/////////////////////////////////////////////////////////////////////////
String regex = pattern
.replace(".", "\\.")
.replace("**", "<<<DOUBLESTAR>>>")
.replace("*", "[^/]*")
.replace("<<<DOUBLESTAR>>>", ".*")
.replace("?", ".");
return path.matches(regex);
}
}
@@ -0,0 +1,266 @@
/*
* 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.template;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/*******************************************************************************
* Handles file conflict resolution during merge operations.
*
* @since 0.2.0
*******************************************************************************/
public class MergeStrategy
{
private static final DateTimeFormatter BACKUP_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss");
private final MergeOptions options;
private final ConsoleUI ui;
private final FileDiffer differ;
private Boolean overwriteAll = null;
private Boolean skipAll = null;
/***************************************************************************
* Create a merge strategy with the given options.
*
* @param options merge configuration
* @param ui console UI for interactive prompts
* @since 0.2.0
***************************************************************************/
public MergeStrategy(MergeOptions options, ConsoleUI ui)
{
this.options = options;
this.ui = ui;
this.differ = new FileDiffer();
}
/***************************************************************************
* Determine the action for a file during merge.
*
* @param relativePath relative path of the file
* @param targetPath full target path
* @param newContent the new content to be written
* @return the merge action to take
* @throws IOException if file operations fail
* @since 0.2.0
***************************************************************************/
public MergeAction determineAction(String relativePath, Path targetPath, String newContent)
throws IOException
{
/////////////////////////////////////////////////////////////////////////
// If merge mode is disabled, always write //
/////////////////////////////////////////////////////////////////////////
if(!options.enabled())
{
return MergeAction.WRITE;
}
/////////////////////////////////////////////////////////////////////////
// Check exclude patterns first //
/////////////////////////////////////////////////////////////////////////
if(options.matchesExclude(relativePath))
{
return MergeAction.SKIP;
}
/////////////////////////////////////////////////////////////////////////
// If file doesn't exist, always write //
/////////////////////////////////////////////////////////////////////////
if(!Files.exists(targetPath))
{
return MergeAction.WRITE;
}
/////////////////////////////////////////////////////////////////////////
// File exists - check include patterns //
/////////////////////////////////////////////////////////////////////////
boolean hasIncludePatterns = options.includePatterns() != null
&& !options.includePatterns().isEmpty();
if(hasIncludePatterns)
{
if(options.matchesInclude(relativePath))
{
return handleConflict(relativePath, targetPath, newContent);
}
else
{
return MergeAction.SKIP;
}
}
/////////////////////////////////////////////////////////////////////////
// No include patterns - skip existing files by default //
/////////////////////////////////////////////////////////////////////////
return MergeAction.SKIP;
}
/***************************************************************************
* Handle a file conflict (file exists and should be considered).
*
* @param relativePath relative path of the file
* @param targetPath full target path
* @param newContent the new content
* @return the merge action
* @throws IOException if file operations fail
* @since 0.2.0
***************************************************************************/
private MergeAction handleConflict(String relativePath, Path targetPath, String newContent)
throws IOException
{
/////////////////////////////////////////////////////////////////////////
// Check if user chose "all" option previously //
/////////////////////////////////////////////////////////////////////////
if(Boolean.TRUE.equals(overwriteAll))
{
return MergeAction.OVERWRITE;
}
if(Boolean.TRUE.equals(skipAll))
{
return MergeAction.SKIP;
}
/////////////////////////////////////////////////////////////////////////
// Interactive mode - prompt user //
/////////////////////////////////////////////////////////////////////////
if(options.interactive())
{
return promptForAction(relativePath, targetPath, newContent);
}
/////////////////////////////////////////////////////////////////////////
// Non-interactive with include pattern - overwrite //
/////////////////////////////////////////////////////////////////////////
return MergeAction.OVERWRITE;
}
/***************************************************************************
* Prompt user for action on conflicting file.
*
* @param relativePath relative path of the file
* @param targetPath full target path
* @param newContent the new content
* @return the merge action
* @throws IOException if file operations fail
* @since 0.2.0
***************************************************************************/
private MergeAction promptForAction(String relativePath, Path targetPath, String newContent)
throws IOException
{
String existingContent = Files.readString(targetPath);
while(true)
{
ui.println();
ui.warning("File exists: " + relativePath);
ui.println(" [o] Overwrite");
ui.println(" [s] Skip");
ui.println(" [d] Diff (show changes)");
ui.println(" [a] Overwrite all remaining");
ui.println(" [n] Skip all remaining");
ui.println();
String choice = ui.promptText("Choice", "s").toLowerCase();
switch(choice)
{
case "o":
return MergeAction.OVERWRITE;
case "s":
return MergeAction.SKIP;
case "d":
String diff = differ.diff(existingContent, newContent, relativePath);
ui.println();
ui.println(diff);
break;
case "a":
overwriteAll = true;
return MergeAction.OVERWRITE;
case "n":
skipAll = true;
return MergeAction.SKIP;
default:
ui.error("Invalid choice. Please enter o, s, d, a, or n.");
}
}
}
/***************************************************************************
* Create a backup of the existing file before overwriting.
*
* @param targetPath the file to backup
* @throws IOException if backup fails
* @since 0.2.0
***************************************************************************/
public void createBackup(Path targetPath) throws IOException
{
if(!options.createBackups() || !Files.exists(targetPath))
{
return;
}
String timestamp = LocalDateTime.now().format(BACKUP_FORMATTER);
String backupName = targetPath.getFileName().toString() + "." + timestamp + ".bak";
Path backupPath = targetPath.resolveSibling(backupName);
Files.copy(targetPath, backupPath, StandardCopyOption.REPLACE_EXISTING);
}
/***************************************************************************
* Actions that can be taken for a file during merge.
*
* @since 0.2.0
***************************************************************************/
public enum MergeAction
{
/*************************************************************************
* Write the file (new file, no conflict).
*************************************************************************/
WRITE,
/*************************************************************************
* Overwrite existing file.
*************************************************************************/
OVERWRITE,
/*************************************************************************
* Skip the file (keep existing).
*************************************************************************/
SKIP
}
}
@@ -0,0 +1,131 @@
/*
* 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.template;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
/*******************************************************************************
* Runs post-generation hooks after template rendering.
*
* Executes commands like mvn compile, git init, npm install.
*
* @since 0.1.0
*******************************************************************************/
public class PostGenHookRunner
{
private static final String ANSI_BOLD = "\u001B[1m";
private static final String ANSI_GREEN = "\u001B[32m";
private static final String ANSI_RED = "\u001B[31m";
private static final String ANSI_RESET = "\u001B[0m";
/***************************************************************************
* Run post-generation hooks.
*
* @param projectDir generated project directory
* @param hooks list of hooks to run
* @throws IOException if a required hook fails
* @since 0.1.0
***************************************************************************/
public void run(Path projectDir, List<TemplateManifest.PostGenHook> hooks)
throws IOException
{
System.out.println("\n" + ANSI_BOLD + "Running post-generation hooks:" + ANSI_RESET);
for(TemplateManifest.PostGenHook hook : hooks)
{
runHook(projectDir, hook);
}
}
/***************************************************************************
* Run a single hook.
*
* @param projectDir project directory
* @param hook hook definition
* @throws IOException if required hook fails
* @since 0.1.0
***************************************************************************/
private void runHook(Path projectDir, TemplateManifest.PostGenHook hook)
throws IOException
{
String name = hook.name() != null ? hook.name() : hook.command();
boolean optional = hook.optional() != null && hook.optional();
System.out.println("\n " + ANSI_BOLD + name + ANSI_RESET);
System.out.println(" $ " + hook.command());
Path workDir = projectDir;
if(hook.workDir() != null && !hook.workDir().isBlank())
{
workDir = projectDir.resolve(hook.workDir());
}
try
{
ProcessBuilder pb = createProcessBuilder(hook.command());
pb.directory(workDir.toFile());
pb.inheritIO();
Process process = pb.start();
int exitCode = process.waitFor();
if(exitCode == 0)
{
System.out.println(" " + ANSI_GREEN + "OK" + ANSI_RESET);
}
else
{
System.out.println(" " + ANSI_RED + "FAILED (exit " + exitCode + ")" + ANSI_RESET);
if(!optional)
{
throw new IOException("Hook failed: " + name);
}
}
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw new IOException("Hook interrupted: " + name, e);
}
}
/***************************************************************************
* Create a ProcessBuilder for a command.
*
* @param command command string
* @return process builder
* @since 0.1.0
***************************************************************************/
private ProcessBuilder createProcessBuilder(String command)
{
String os = System.getProperty("os.name").toLowerCase();
if(os.contains("win"))
{
return new ProcessBuilder("cmd", "/c", command);
}
else
{
return new ProcessBuilder("sh", "-c", command);
}
}
}
@@ -0,0 +1,189 @@
/*
* 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.template;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/*******************************************************************************
* Runs interactive prompts to collect template variables.
*
* Uses ConsoleUI for all interactive input. Supports text input and selections.
* Validates input against prompt rules and re-prompts on failure.
*
* @since 0.1.0
*******************************************************************************/
public class PromptRunner
{
private final ConsoleUI ui;
private final PromptValidator validator;
/***************************************************************************
* Create a new PromptRunner with default ConsoleUI.
*
* @since 0.1.0
***************************************************************************/
public PromptRunner()
{
this.ui = new ConsoleUI();
this.validator = new PromptValidator();
}
/***************************************************************************
* Create a new PromptRunner with provided ConsoleUI.
*
* @param ui the console UI to use
* @since 0.1.0
***************************************************************************/
public PromptRunner(ConsoleUI ui)
{
this.ui = ui;
this.validator = new PromptValidator();
}
/***************************************************************************
* Run prompts and collect user input.
*
* @param prompts list of prompt definitions
* @param overrides CLI-provided variable overrides
* @return collected variables
* @throws IOException if prompt interaction fails
* @since 0.1.0
***************************************************************************/
public Map<String, String> run(List<TemplateManifest.Prompt> prompts,
Map<String, String> overrides) throws IOException
{
Map<String, String> results = new LinkedHashMap<>();
ui.println();
ui.header("Configure your project:");
ui.println();
for(TemplateManifest.Prompt prompt : prompts)
{
// Check if provided via CLI
if(overrides.containsKey(prompt.name()))
{
String value = overrides.get(prompt.name());
String label = prompt.message() != null ? prompt.message() : prompt.name();
PromptValidator.ValidationResult result = validator.validate(prompt, value);
if(result.valid())
{
ui.showValue(label, value);
results.put(prompt.name(), value);
}
else
{
ui.showValue(label, value);
ui.validationError(result.errorMessage());
ui.warning("Invalid --var value, prompting interactively");
String corrected = promptForValue(prompt);
results.put(prompt.name(), corrected);
}
continue;
}
String value = promptForValue(prompt);
results.put(prompt.name(), value);
}
return results;
}
/***************************************************************************
* Prompt for a single value with validation.
*
* Re-prompts on validation failure until valid input is provided.
*
* @param prompt prompt definition
* @return validated user input value
* @since 0.1.0
***************************************************************************/
private String promptForValue(TemplateManifest.Prompt prompt)
{
String type = prompt.type() != null ? prompt.type() : "text";
String message = prompt.message() != null ? prompt.message() : prompt.name();
if("select".equals(type) && prompt.choices() != null)
{
return promptSelect(message, prompt.choices(), prompt.defaultValue());
}
boolean hasValidation = prompt.isRequired() || prompt.validation() != null;
while(true)
{
String value = ui.promptText(message, prompt.defaultValue());
PromptValidator.ValidationResult result = validator.validate(prompt, value);
if(result.valid())
{
if(hasValidation)
{
ui.validationSuccess();
}
return value;
}
ui.validationError(result.errorMessage());
}
}
/***************************************************************************
* Prompt for selection from choices.
*
* @param message the prompt message
* @param choices list of choices
* @param defaultValue default choice
* @return selected value
* @since 0.1.0
***************************************************************************/
private String promptSelect(String message, List<String> choices, String defaultValue)
{
int defaultIndex = 0;
if(defaultValue != null)
{
for(int i = 0; i < choices.size(); i++)
{
if(defaultValue.equals(choices.get(i)))
{
defaultIndex = i;
break;
}
}
}
return ui.promptSelect(
message,
choices,
s -> s,
s -> null,
defaultIndex
);
}
}
@@ -0,0 +1,45 @@
/*
* 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.template;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/*******************************************************************************
* Validation rules for template prompts.
*
* Supports string validation (pattern, length) and integer validation (range).
*
* @param pattern regex pattern for string validation
* @param minLength minimum string length
* @param maxLength maximum string length
* @param min minimum value for integers
* @param max maximum value for integers
* @param values allowed values (enum validation)
* @param message custom error message
* @since 0.2.0
*******************************************************************************/
@JsonIgnoreProperties(ignoreUnknown = true)
public record PromptValidation(
String pattern,
Integer minLength,
Integer maxLength,
Integer min,
Integer max,
List<String> values,
String message
)
{
}
@@ -0,0 +1,235 @@
/*
* 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.template;
import java.util.Optional;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
/*******************************************************************************
* Validates prompt values against validation rules.
*
* Supports pattern matching, length constraints, numeric ranges, and enum values.
*
* @since 0.2.0
*******************************************************************************/
public class PromptValidator
{
/***************************************************************************
* Validation result containing success status and error message.
*
* @param valid true if validation passed
* @param errorMessage error message if validation failed
* @since 0.2.0
***************************************************************************/
public record ValidationResult(boolean valid, String errorMessage)
{
/*************************************************************************
* Create a successful validation result.
*
* @return success result
* @since 0.2.0
*************************************************************************/
public static ValidationResult success()
{
return new ValidationResult(true, null);
}
/*************************************************************************
* Create a failed validation result.
*
* @param message the error message
* @return failure result
* @since 0.2.0
*************************************************************************/
public static ValidationResult failure(String message)
{
return new ValidationResult(false, message);
}
}
/***************************************************************************
* Validate a value against prompt rules.
*
* @param prompt the prompt definition with validation rules
* @param value the value to validate
* @return validation result
* @since 0.2.0
***************************************************************************/
public ValidationResult validate(TemplateManifest.Prompt prompt, String value)
{
// Check required
if(prompt.isRequired() && (value == null || value.isBlank()))
{
return ValidationResult.failure("This field is required");
}
// If empty and not required, skip further validation
if(value == null || value.isBlank())
{
return ValidationResult.success();
}
// Get validation rules
PromptValidation validation = prompt.validation();
if(validation == null)
{
return ValidationResult.success();
}
// Determine type
String type = prompt.type() != null ? prompt.type().toLowerCase() : "text";
// Type-specific validation
return switch(type)
{
case "integer", "number" -> validateInteger(validation, value);
default -> validateString(validation, value);
};
}
/***************************************************************************
* Validate a string value.
*
* @param validation validation rules
* @param value the value to validate
* @return validation result
* @since 0.2.0
***************************************************************************/
private ValidationResult validateString(PromptValidation validation, String value)
{
// Pattern validation
if(validation.pattern() != null)
{
Optional<ValidationResult> patternResult = validatePattern(validation.pattern(), value, validation.message());
if(patternResult.isPresent())
{
return patternResult.get();
}
}
// Length validation
if(validation.minLength() != null && value.length() < validation.minLength())
{
String message = validation.message() != null
? validation.message()
: "Must be at least " + validation.minLength() + " characters";
return ValidationResult.failure(message);
}
if(validation.maxLength() != null && value.length() > validation.maxLength())
{
String message = validation.message() != null
? validation.message()
: "Must be at most " + validation.maxLength() + " characters";
return ValidationResult.failure(message);
}
// Enum validation
if(validation.values() != null && !validation.values().isEmpty())
{
if(!validation.values().contains(value))
{
String message = validation.message() != null
? validation.message()
: "Must be one of: " + String.join(", ", validation.values());
return ValidationResult.failure(message);
}
}
return ValidationResult.success();
}
/***************************************************************************
* Validate an integer value.
*
* @param validation validation rules
* @param value the value to validate
* @return validation result
* @since 0.2.0
***************************************************************************/
private ValidationResult validateInteger(PromptValidation validation, String value)
{
// Parse as integer
int intValue;
try
{
intValue = Integer.parseInt(value.trim());
}
catch(NumberFormatException e)
{
return ValidationResult.failure("Must be a valid integer");
}
// Min validation
if(validation.min() != null && intValue < validation.min())
{
String message = validation.message() != null
? validation.message()
: "Must be at least " + validation.min();
return ValidationResult.failure(message);
}
// Max validation
if(validation.max() != null && intValue > validation.max())
{
String message = validation.message() != null
? validation.message()
: "Must be at most " + validation.max();
return ValidationResult.failure(message);
}
return ValidationResult.success();
}
/***************************************************************************
* Validate against a regex pattern.
*
* @param pattern the regex pattern
* @param value the value to validate
* @param customMessage custom error message
* @return validation result if failed, empty if passed
* @since 0.2.0
***************************************************************************/
private Optional<ValidationResult> validatePattern(String pattern, String value, String customMessage)
{
try
{
Pattern regex = Pattern.compile(pattern);
if(!regex.matcher(value).matches())
{
String message = customMessage != null
? customMessage
: "Must match pattern: " + pattern;
return Optional.of(ValidationResult.failure(message));
}
}
catch(PatternSyntaxException e)
{
return Optional.of(ValidationResult.failure("Invalid validation pattern: " + pattern));
}
return Optional.empty();
}
}
@@ -0,0 +1,157 @@
/*
* 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.template;
/*******************************************************************************
* Velocity tool for string transformations.
*
* Provides methods for common case conversions used in templates.
* Exposed in Velocity context as $str.
*
* @since 0.2.0
*******************************************************************************/
public class StringTool
{
/***************************************************************************
* Convert string to lowercase.
*
* @param value input string
* @return lowercase string, or empty string if null/empty
* @since 0.2.0
***************************************************************************/
public String lower(String value)
{
if(value == null || value.isEmpty())
{
return "";
}
return value.toLowerCase();
}
/***************************************************************************
* Convert string to uppercase.
*
* @param value input string
* @return uppercase string, or empty string if null/empty
* @since 0.2.0
***************************************************************************/
public String upper(String value)
{
if(value == null || value.isEmpty())
{
return "";
}
return value.toUpperCase();
}
/***************************************************************************
* Convert string to camelCase (first letter lowercase).
*
* @param value input string
* @return camelCase string, or empty string if null/empty
* @since 0.2.0
***************************************************************************/
public String camel(String value)
{
if(value == null || value.isEmpty())
{
return "";
}
return Character.toLowerCase(value.charAt(0)) + value.substring(1);
}
/***************************************************************************
* Convert string to PascalCase (first letter uppercase).
*
* @param value input string
* @return PascalCase string, or empty string if null/empty
* @since 0.2.0
***************************************************************************/
public String pascal(String value)
{
if(value == null || value.isEmpty())
{
return "";
}
return Character.toUpperCase(value.charAt(0)) + value.substring(1);
}
/***************************************************************************
* Convert camelCase to kebab-case.
*
* @param value input string in camelCase or PascalCase
* @return kebab-case string, or empty string if null/empty
* @since 0.2.0
***************************************************************************/
public String kebab(String value)
{
if(value == null || value.isEmpty())
{
return "";
}
return value.replaceAll("([a-z])([A-Z])", "$1-$2").toLowerCase();
}
/***************************************************************************
* Convert camelCase to snake_case.
*
* @param value input string in camelCase or PascalCase
* @return snake_case string, or empty string if null/empty
* @since 0.2.0
***************************************************************************/
public String snake(String value)
{
if(value == null || value.isEmpty())
{
return "";
}
return value.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
}
/***************************************************************************
* Replace all occurrences of target with replacement.
*
* @param value input string
* @param target string to find
* @param replacement string to replace with
* @return string with replacements, or empty if input is null/empty
* @since 0.2.0
***************************************************************************/
public String replace(String value, String target, String replacement)
{
if(value == null || value.isEmpty())
{
return "";
}
if(target == null || replacement == null)
{
return value;
}
return value.replace(target, replacement);
}
}
@@ -0,0 +1,774 @@
/*
* 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.template;
import java.io.IOException;
import java.io.StringWriter;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import io.qrun.qctl.qqq.error.SuggestionEngine;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import org.apache.velocity.exception.MethodInvocationException;
import org.apache.velocity.exception.ParseErrorException;
import org.apache.velocity.exception.ResourceNotFoundException;
import org.apache.velocity.runtime.RuntimeConstants;
/*******************************************************************************
* Renders templates using Apache Velocity.
*
* Supports path substitution ($packagePath/src) and content templating with
* full Velocity syntax including control flow and macros.
*
* @since 0.1.0
*******************************************************************************/
public class TemplateEngine
{
private static final String TEMPLATE_DIR = "template";
private static final Set<String> BINARY_EXTENSIONS = Set.of(
".jar", ".class", ".png", ".jpg", ".jpeg", ".gif", ".ico",
".zip", ".tar", ".gz", ".war", ".ear", ".pdf"
);
/////////////////////////////////////////////////////////////////////////////
// Files that should be copied verbatim without template processing //
/////////////////////////////////////////////////////////////////////////////
private static final Set<String> VERBATIM_FILENAMES = Set.of(
"mvnw", "mvnw.cmd", "gradlew", "gradlew.bat"
);
/////////////////////////////////////////////////////////////////////////////
// Pattern to detect undefined Velocity references ($var or ${var}) //
/////////////////////////////////////////////////////////////////////////////
private static final Pattern UNDEFINED_REF_PATTERN =
Pattern.compile("\\$(!)?\\{?([a-zA-Z][a-zA-Z0-9_]*)\\}?");
private final VelocityEngine velocityEngine;
private final StringTool stringTool;
private final SuggestionEngine suggestionEngine;
/***************************************************************************
* Create a new template engine.
*
* @since 0.1.0
***************************************************************************/
public TemplateEngine()
{
this.velocityEngine = createVelocityEngine();
this.stringTool = new StringTool();
this.suggestionEngine = new SuggestionEngine();
}
/***************************************************************************
* Create and configure the Velocity engine.
*
* @return configured VelocityEngine
* @since 0.2.0
***************************************************************************/
private VelocityEngine createVelocityEngine()
{
Properties props = new Properties();
props.setProperty(RuntimeConstants.INPUT_ENCODING, "UTF-8");
props.setProperty(RuntimeConstants.RESOURCE_LOADERS, "string");
props.setProperty("resource.loader.string.class",
"org.apache.velocity.runtime.resource.loader.StringResourceLoader");
VelocityEngine engine = new VelocityEngine();
engine.init(props);
return engine;
}
/***************************************************************************
* Render templates to the target directory.
*
* @param templateDir template source directory
* @param targetDir target output directory
* @param variables template variables
* @param manifest template manifest
* @return number of files created
* @throws IOException if file operations fail
* @throws TemplateRenderException if template rendering fails
* @since 0.1.0
***************************************************************************/
public int render(Path templateDir, Path targetDir, Map<String, String> variables,
TemplateManifest manifest) throws IOException, TemplateRenderException
{
return render(templateDir, targetDir, variables, manifest, MergeOptions.disabled(), null);
}
/***************************************************************************
* Render templates with merge support.
*
* @param templateDir template source directory
* @param targetDir target output directory
* @param variables template variables
* @param manifest template manifest
* @param mergeOptions merge configuration
* @param ui console UI for interactive prompts (may be null)
* @return number of files created/modified
* @throws IOException if file operations fail
* @throws TemplateRenderException if template rendering fails
* @since 0.2.0
***************************************************************************/
public int render(Path templateDir, Path targetDir, Map<String, String> variables,
TemplateManifest manifest, MergeOptions mergeOptions, ConsoleUI ui)
throws IOException, TemplateRenderException
{
Path sourceDir = resolveTemplateSource(templateDir);
Set<String> ignorePatterns = manifest.ignore() != null
? new HashSet<>(manifest.ignore())
: new HashSet<>();
ignorePatterns.add(".git");
ignorePatterns.add("template.yaml");
ConsoleUI effectiveUi = ui != null ? ui : new ConsoleUI();
MergeStrategy mergeStrategy = new MergeStrategy(mergeOptions, effectiveUi);
AtomicInteger fileCount = new AtomicInteger();
AtomicReference<Exception> renderError = new AtomicReference<>();
Files.walkFileTree(sourceDir, new SimpleFileVisitor<>()
{
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
throws IOException
{
if(shouldIgnore(sourceDir.relativize(dir).toString(), ignorePatterns))
{
return FileVisitResult.SKIP_SUBTREE;
}
try
{
Path relativePath = sourceDir.relativize(dir);
String renderedPath = renderPath(relativePath.toString(), variables);
Path targetPath = targetDir.resolve(renderedPath);
Files.createDirectories(targetPath);
}
catch(TemplateRenderException e)
{
renderError.set(e);
return FileVisitResult.TERMINATE;
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException
{
Path relativePath = sourceDir.relativize(file);
if(shouldIgnore(relativePath.toString(), ignorePatterns))
{
return FileVisitResult.CONTINUE;
}
try
{
String renderedPath = renderPath(relativePath.toString(), variables);
Path targetPath = targetDir.resolve(renderedPath);
//////////////////////////////////////////////////////////////////
// Prepare content for merge decision //
//////////////////////////////////////////////////////////////////
String rendered;
if(isBinaryFile(file) || isVerbatimFile(file))
{
rendered = null;
}
else
{
String content = Files.readString(file);
rendered = renderContent(content, variables);
}
//////////////////////////////////////////////////////////////////
// Check merge strategy //
//////////////////////////////////////////////////////////////////
MergeStrategy.MergeAction action = mergeStrategy.determineAction(
renderedPath, targetPath, rendered);
switch(action)
{
case SKIP:
effectiveUi.println(" [skip] " + renderedPath);
return FileVisitResult.CONTINUE;
case OVERWRITE:
mergeStrategy.createBackup(targetPath);
effectiveUi.println(" [overwrite] " + renderedPath);
break;
case WRITE:
default:
effectiveUi.println(" " + renderedPath);
break;
}
//////////////////////////////////////////////////////////////////
// Write the file //
//////////////////////////////////////////////////////////////////
if(isBinaryFile(file) || isVerbatimFile(file))
{
Files.copy(file, targetPath,
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
else
{
Files.writeString(targetPath, rendered);
}
fileCount.incrementAndGet();
}
catch(TemplateRenderException e)
{
renderError.set(e);
return FileVisitResult.TERMINATE;
}
return FileVisitResult.CONTINUE;
}
});
if(renderError.get() != null)
{
Exception e = renderError.get();
if(e instanceof TemplateRenderException tre)
{
throw tre;
}
throw new TemplateRenderException("Template rendering failed", e);
}
return fileCount.get();
}
/***************************************************************************
* Preview template rendering without writing files.
*
* @param templateDir template source directory
* @param targetDir target output directory
* @param variables template variables
* @param manifest template manifest
* @throws IOException if file operations fail
* @throws TemplateRenderException if path rendering fails
* @since 0.1.0
***************************************************************************/
public void renderDryRun(Path templateDir, Path targetDir, Map<String, String> variables,
TemplateManifest manifest) throws IOException, TemplateRenderException
{
renderDryRun(templateDir, targetDir, variables, manifest, MergeOptions.disabled());
}
/***************************************************************************
* Preview template rendering with merge support.
*
* @param templateDir template source directory
* @param targetDir target output directory
* @param variables template variables
* @param manifest template manifest
* @param mergeOptions merge configuration
* @throws IOException if file operations fail
* @throws TemplateRenderException if path rendering fails
* @since 0.2.0
***************************************************************************/
public void renderDryRun(Path templateDir, Path targetDir, Map<String, String> variables,
TemplateManifest manifest, MergeOptions mergeOptions)
throws IOException, TemplateRenderException
{
Path sourceDir = resolveTemplateSource(templateDir);
Set<String> ignorePatterns = manifest.ignore() != null
? new HashSet<>(manifest.ignore())
: new HashSet<>();
ignorePatterns.add(".git");
ignorePatterns.add("template.yaml");
AtomicReference<TemplateRenderException> renderError = new AtomicReference<>();
Files.walkFileTree(sourceDir, new SimpleFileVisitor<>()
{
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
{
if(shouldIgnore(sourceDir.relativize(dir).toString(), ignorePatterns))
{
return FileVisitResult.SKIP_SUBTREE;
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
{
Path relativePath = sourceDir.relativize(file);
if(shouldIgnore(relativePath.toString(), ignorePatterns))
{
return FileVisitResult.CONTINUE;
}
try
{
String renderedPath = renderPath(relativePath.toString(), variables);
Path targetPath = targetDir.resolve(renderedPath);
//////////////////////////////////////////////////////////////////
// Check merge status for preview //
//////////////////////////////////////////////////////////////////
String status = "";
if(mergeOptions.enabled())
{
if(mergeOptions.matchesExclude(renderedPath))
{
status = "[skip] ";
}
else if(Files.exists(targetPath))
{
boolean hasIncludes = mergeOptions.includePatterns() != null
&& !mergeOptions.includePatterns().isEmpty();
if(hasIncludes && mergeOptions.matchesInclude(renderedPath))
{
status = "[overwrite] ";
}
else
{
status = "[skip] ";
}
}
}
System.out.println(" " + status + targetPath);
}
catch(TemplateRenderException e)
{
renderError.set(e);
return FileVisitResult.TERMINATE;
}
return FileVisitResult.CONTINUE;
}
});
if(renderError.get() != null)
{
throw renderError.get();
}
}
/***************************************************************************
* Render a path string with Velocity variable substitution.
*
* @param path path string with $var placeholders
* @param variables template variables
* @return rendered path
* @throws TemplateRenderException if rendering fails
* @since 0.1.0
***************************************************************************/
public String renderPath(String path, Map<String, String> variables)
throws TemplateRenderException
{
/////////////////////////////////////////////////////////////////////////
// Convert __VARNAME__ placeholders to $VARNAME for Velocity. //
// This allows templates to use __VARNAME__ in paths, which is safer //
// for shells and build tools that might interpret $ as a variable. //
/////////////////////////////////////////////////////////////////////////
String normalizedPath = path.replaceAll("__([a-zA-Z][a-zA-Z0-9]*)__", "\\$$1");
return renderContent(normalizedPath, variables);
}
/***************************************************************************
* Render content using Velocity with string variables.
*
* @param content template content
* @param variables template variables (String values)
* @return rendered content
* @throws TemplateRenderException if rendering fails
* @since 0.1.0
***************************************************************************/
public String renderContent(String content, Map<String, String> variables)
throws TemplateRenderException
{
VelocityContext context = createContext(variables);
return evaluateTemplate(content, context);
}
/***************************************************************************
* Render content using Velocity with object variables.
*
* Supports complex objects like lists for #foreach directives.
*
* @param content template content
* @param variables template variables (Object values)
* @return rendered content
* @throws TemplateRenderException if rendering fails
* @since 0.2.0
***************************************************************************/
public String renderContentWithObjects(String content, Map<String, Object> variables)
throws TemplateRenderException
{
VelocityContext context = new VelocityContext();
context.put("str", stringTool);
for(Map.Entry<String, Object> entry : variables.entrySet())
{
context.put(entry.getKey(), entry.getValue());
}
return evaluateTemplate(content, context);
}
/***************************************************************************
* Evaluate computed variables and merge with input variables.
*
* Computed variables are evaluated in order. Each computed variable
* can reference previously defined variables (prompts or earlier computed).
* If a computed variable references an undefined variable, rendering fails.
*
* @param computed list of computed variable definitions (may be null)
* @param variables input variables from prompts
* @return merged map containing original and computed variables
* @throws TemplateRenderException if evaluation fails
* @since 0.2.0
***************************************************************************/
public Map<String, String> evaluateComputed(List<ComputedVariable> computed,
Map<String, String> variables)
throws TemplateRenderException
{
if(computed == null || computed.isEmpty())
{
return variables;
}
Map<String, String> result = new HashMap<>(variables);
for(ComputedVariable cv : computed)
{
String value = renderContent(cv.expression(), result);
result.put(cv.name(), value);
}
return result;
}
/***************************************************************************
* Create a Velocity context with string variables and tools.
*
* @param variables template variables
* @return configured VelocityContext
* @since 0.2.0
***************************************************************************/
private VelocityContext createContext(Map<String, String> variables)
{
VelocityContext context = new VelocityContext();
context.put("str", stringTool);
for(Map.Entry<String, String> entry : variables.entrySet())
{
context.put(entry.getKey(), entry.getValue());
}
return context;
}
/***************************************************************************
* Evaluate a template string with the given context.
*
* @param template template string
* @param context Velocity context
* @return rendered string
* @throws TemplateRenderException if rendering fails
* @since 0.2.0
***************************************************************************/
private String evaluateTemplate(String template, VelocityContext context)
throws TemplateRenderException
{
try
{
StringWriter writer = new StringWriter();
boolean result = velocityEngine.evaluate(context, writer, "template", template);
if(!result)
{
throw new TemplateRenderException("Template evaluation failed");
}
String output = writer.toString();
/////////////////////////////////////////////////////////////////////
// Check for undefined references that weren't resolved //
/////////////////////////////////////////////////////////////////////
checkForUndefinedReferences(template, output, context);
return output;
}
catch(ParseErrorException e)
{
throw new TemplateRenderException("Template syntax error: " + e.getMessage(), e);
}
catch(MethodInvocationException e)
{
throw new TemplateRenderException("Template method error: " + e.getMessage(), e);
}
catch(ResourceNotFoundException e)
{
throw new TemplateRenderException("Template resource not found: " + e.getMessage(), e);
}
}
/***************************************************************************
* Check for undefined variable references in the output.
*
* If a variable reference like $name appears in both input and output
* unchanged, it means the variable was not defined.
*
* @param template original template
* @param output rendered output
* @param context Velocity context
* @throws TemplateRenderException if undefined references found
* @since 0.2.0
***************************************************************************/
private void checkForUndefinedReferences(String template, String output,
VelocityContext context)
throws TemplateRenderException
{
Matcher matcher = UNDEFINED_REF_PATTERN.matcher(output);
while(matcher.find())
{
String varName = matcher.group(2);
/////////////////////////////////////////////////////////////////////
// Skip if it's a tool reference (like $str) //
/////////////////////////////////////////////////////////////////////
if("str".equals(varName))
{
continue;
}
/////////////////////////////////////////////////////////////////////
// Skip if it looks like a Maven property (${name.something}) //
// These are intentionally left as literals in templates //
/////////////////////////////////////////////////////////////////////
int endPos = matcher.end();
if(endPos < output.length() && output.charAt(endPos) == '.')
{
continue;
}
/////////////////////////////////////////////////////////////////////
// If the variable is not in context, it's undefined //
/////////////////////////////////////////////////////////////////////
if(context.get(varName) == null)
{
/////////////////////////////////////////////////////////////////
// Check if this exact reference was in the original template //
/////////////////////////////////////////////////////////////////
String fullRef = matcher.group(0);
if(template.contains(fullRef))
{
//////////////////////////////////////////////////////////////
// Collect available variables and find similar ones //
//////////////////////////////////////////////////////////////
List<String> available = collectAvailableVariables(context);
List<String> suggestions = suggestionEngine.findSimilar(varName, available);
throw new TemplateRenderException(varName, suggestions, available);
}
}
}
}
/***************************************************************************
* Collect all available variable names from the context.
*
* @param context Velocity context
* @return list of variable names
* @since 0.2.0
***************************************************************************/
private List<String> collectAvailableVariables(VelocityContext context)
{
List<String> variables = new ArrayList<>();
for(Object key : context.getKeys())
{
String name = key.toString();
/////////////////////////////////////////////////////////////////////
// Skip internal tools like $str //
/////////////////////////////////////////////////////////////////////
if(!"str".equals(name))
{
variables.add(name);
}
}
return variables;
}
/***************************************************************************
* Resolve the template source directory.
*
* @param templateDir template root directory
* @return source directory for templates
* @since 0.1.0
***************************************************************************/
private Path resolveTemplateSource(Path templateDir)
{
Path templateSubdir = templateDir.resolve(TEMPLATE_DIR);
if(Files.isDirectory(templateSubdir))
{
return templateSubdir;
}
return templateDir;
}
/***************************************************************************
* Check if a file should be ignored.
*
* @param path relative path
* @param patterns ignore patterns
* @return true if file should be ignored
* @since 0.1.0
***************************************************************************/
private boolean shouldIgnore(String path, Set<String> patterns)
{
for(String pattern : patterns)
{
if(path.equals(pattern) || path.startsWith(pattern + "/"))
{
return true;
}
if(pattern.startsWith("*."))
{
String ext = pattern.substring(1);
if(path.endsWith(ext))
{
return true;
}
}
}
return false;
}
/***************************************************************************
* Check if a file is binary.
*
* @param file file path
* @return true if file is binary
* @since 0.1.0
***************************************************************************/
private boolean isBinaryFile(Path file)
{
String name = file.getFileName().toString().toLowerCase();
for(String ext : BINARY_EXTENSIONS)
{
if(name.endsWith(ext))
{
return true;
}
}
return false;
}
/***************************************************************************
* Check if a file should be copied verbatim without template processing.
*
* @param file file path
* @return true if file should be copied verbatim
* @since 0.2.0
***************************************************************************/
private boolean isVerbatimFile(Path file)
{
String name = file.getFileName().toString();
return VERBATIM_FILENAMES.contains(name);
}
/***************************************************************************
* Inner class to hold exception reference for use in visitor.
*
* @param <T> exception type
* @since 0.2.0
***************************************************************************/
private static class AtomicReference<T>
{
private T value;
/*************************************************************************
* Set the value.
*
* @param value value to set
* @since 0.2.0
*************************************************************************/
public void set(T value)
{
this.value = value;
}
/*************************************************************************
* Get the value.
*
* @return stored value
* @since 0.2.0
*************************************************************************/
public T get()
{
return value;
}
}
}
@@ -0,0 +1,182 @@
/*
* 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.template;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.core.JsonLocation;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
/*******************************************************************************
* Represents a template manifest (template.yaml).
*
* Schema v2 adds: schemaVersion, minimumQctlVersion, prompts[].required,
* postGen[].phase. Backward compatible with v1 manifests.
*
* @since 0.1.0
*******************************************************************************/
@JsonIgnoreProperties(ignoreUnknown = true)
public record TemplateManifest(
Integer schemaVersion,
String id,
String name,
String version,
String description,
String minimumQctlVersion,
List<Prompt> prompts,
List<ComputedVariable> computed,
List<Transform> transforms,
List<PostGenHook> postGen,
List<String> ignore
)
{
private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory());
private static final String MANIFEST_FILE = "template.yaml";
private static final int DEFAULT_SCHEMA_VERSION = 1;
/***************************************************************************
* Get effective schema version (defaults to 1 for backward compatibility).
*
* @return schema version
* @since 0.2.0
***************************************************************************/
public int getEffectiveSchemaVersion()
{
return schemaVersion != null ? schemaVersion : DEFAULT_SCHEMA_VERSION;
}
/***************************************************************************
* Load a template manifest from a directory.
*
* @param templateDir directory containing template.yaml
* @return parsed manifest
* @throws IOException if manifest cannot be read
* @since 0.1.0
***************************************************************************/
public static TemplateManifest load(Path templateDir) throws IOException
{
Path manifestPath = templateDir.resolve(MANIFEST_FILE);
if(!Files.exists(manifestPath))
{
throw new IOException("template.yaml not found in " + templateDir);
}
try
{
return YAML.readValue(manifestPath.toFile(), TemplateManifest.class);
}
catch(JsonProcessingException e)
{
throw new IOException(formatYamlError(e, manifestPath), e);
}
}
/***************************************************************************
* Format a YAML parsing error with line and column information.
*
* @param e the JSON processing exception
* @param manifestPath path to the manifest file
* @return formatted error message
* @since 0.2.0
***************************************************************************/
private static String formatYamlError(JsonProcessingException e, Path manifestPath)
{
JsonLocation location = e.getLocation();
StringBuilder sb = new StringBuilder();
sb.append("YAML parse error in ").append(manifestPath.getFileName());
if(location != null)
{
int line = location.getLineNr();
int col = location.getColumnNr();
sb.append(" (line ").append(line);
if(col >= 0)
{
sb.append(", column ").append(col);
}
sb.append(")");
}
//////////////////////////////////////////////////////////////////////////
// Extract the original error message without the location suffix //
//////////////////////////////////////////////////////////////////////////
String originalMessage = e.getOriginalMessage();
if(originalMessage != null && !originalMessage.isEmpty())
{
sb.append(": ").append(originalMessage);
}
return sb.toString();
}
/***************************************************************************
* A prompt definition for collecting user input.
*
* @since 0.1.0
***************************************************************************/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Prompt(
String name,
String message,
String type,
String defaultValue,
List<String> choices,
PromptValidation validation,
Boolean required
)
{
/*************************************************************************
* Check if this prompt is required.
*
* @return true if required (defaults to true if not specified)
* @since 0.2.0
*************************************************************************/
public boolean isRequired()
{
return required == null || required;
}
}
/***************************************************************************
* A post-generation hook definition.
*
* @since 0.1.0
***************************************************************************/
@JsonIgnoreProperties(ignoreUnknown = true)
public record PostGenHook(
String name,
String command,
String workDir,
Boolean optional,
String phase
)
{
}
}
@@ -0,0 +1,260 @@
/*
* 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.template;
import java.util.List;
/*******************************************************************************
* Exception thrown when template rendering fails.
*
* Provides context about the template error including location information
* when available from the underlying template engine.
*
* @since 0.2.0
*******************************************************************************/
public class TemplateRenderException extends Exception
{
private final String undefinedVariable;
private final List<String> suggestions;
private final List<String> availableVariables;
private final String filePath;
private final Integer lineNumber;
/***************************************************************************
* Create a new exception with a message.
*
* @param message error message
* @since 0.2.0
***************************************************************************/
public TemplateRenderException(String message)
{
super(message);
this.undefinedVariable = null;
this.suggestions = List.of();
this.availableVariables = List.of();
this.filePath = null;
this.lineNumber = null;
}
/***************************************************************************
* Create a new exception with a message and cause.
*
* @param message error message
* @param cause underlying cause
* @since 0.2.0
***************************************************************************/
public TemplateRenderException(String message, Throwable cause)
{
super(message, cause);
this.undefinedVariable = null;
this.suggestions = List.of();
this.availableVariables = List.of();
this.filePath = null;
this.lineNumber = null;
}
/***************************************************************************
* Create exception for undefined variable with suggestions.
*
* @param undefinedVariable the undefined variable name
* @param suggestions similar variable names
* @param availableVariables all available variable names
* @since 0.2.0
***************************************************************************/
public TemplateRenderException(String undefinedVariable, List<String> suggestions,
List<String> availableVariables)
{
super(buildMessage(undefinedVariable, suggestions, availableVariables));
this.undefinedVariable = undefinedVariable;
this.suggestions = suggestions != null ? suggestions : List.of();
this.availableVariables = availableVariables != null ? availableVariables : List.of();
this.filePath = null;
this.lineNumber = null;
}
/***************************************************************************
* Create exception with file path context.
*
* @param message error message
* @param filePath path to the file being processed
* @since 0.2.0
***************************************************************************/
public TemplateRenderException(String message, String filePath)
{
super(message + " (in " + filePath + ")");
this.undefinedVariable = null;
this.suggestions = List.of();
this.availableVariables = List.of();
this.filePath = filePath;
this.lineNumber = null;
}
/***************************************************************************
* Create exception with file path and line number.
*
* @param message error message
* @param filePath path to the file
* @param lineNumber line number where error occurred
* @since 0.2.0
***************************************************************************/
public TemplateRenderException(String message, String filePath, Integer lineNumber)
{
super(buildMessageWithLocation(message, filePath, lineNumber));
this.undefinedVariable = null;
this.suggestions = List.of();
this.availableVariables = List.of();
this.filePath = filePath;
this.lineNumber = lineNumber;
}
/***************************************************************************
* Build error message for undefined variable.
*
* @param varName undefined variable name
* @param suggestions similar variable names
* @param available all available variables
* @return formatted error message
* @since 0.2.0
***************************************************************************/
private static String buildMessage(String varName, List<String> suggestions,
List<String> available)
{
StringBuilder sb = new StringBuilder();
sb.append("Undefined variable: ").append(varName);
if(suggestions != null && !suggestions.isEmpty())
{
sb.append("\n\nDid you mean?");
for(String s : suggestions)
{
sb.append("\n - ").append(s);
}
}
if(available != null && !available.isEmpty())
{
sb.append("\n\nAvailable variables:");
for(String v : available)
{
sb.append("\n - ").append(v);
}
}
sb.append("\n\nTip: Variable names are case-sensitive");
return sb.toString();
}
/***************************************************************************
* Build error message with file location.
*
* @param message base message
* @param filePath file path
* @param lineNumber line number (may be null)
* @return formatted message with location
* @since 0.2.0
***************************************************************************/
private static String buildMessageWithLocation(String message, String filePath,
Integer lineNumber)
{
if(filePath == null)
{
return message;
}
if(lineNumber != null)
{
return message + " (" + filePath + ":" + lineNumber + ")";
}
return message + " (in " + filePath + ")";
}
/***************************************************************************
* Get the undefined variable name if this is an undefined variable error.
*
* @return variable name or null
* @since 0.2.0
***************************************************************************/
public String getUndefinedVariable()
{
return undefinedVariable;
}
/***************************************************************************
* Get suggestions for similar variable names.
*
* @return list of suggestions (may be empty)
* @since 0.2.0
***************************************************************************/
public List<String> getSuggestions()
{
return suggestions;
}
/***************************************************************************
* Get all available variable names.
*
* @return list of available variables (may be empty)
* @since 0.2.0
***************************************************************************/
public List<String> getAvailableVariables()
{
return availableVariables;
}
/***************************************************************************
* Get the file path where error occurred.
*
* @return file path or null
* @since 0.2.0
***************************************************************************/
public String getFilePath()
{
return filePath;
}
/***************************************************************************
* Get the line number where error occurred.
*
* @return line number or null
* @since 0.2.0
***************************************************************************/
public Integer getLineNumber()
{
return lineNumber;
}
}
@@ -0,0 +1,134 @@
/*
* 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.template;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/*******************************************************************************
* Resolves template sources to local paths.
*
* Supports: local paths, git URLs, github.com/org/repo shorthand.
*
* @since 0.1.0
*******************************************************************************/
public class TemplateResolver
{
private static final String GITHUB_PREFIX = "github.com/";
private static final String GIT_SUFFIX = ".git";
/***************************************************************************
* Resolve a template source to a local directory path.
*
* @param source template source (path, git URL, or github shorthand)
* @param version optional version (semver tag)
* @return local path to template directory
* @throws IOException if template cannot be resolved
* @since 0.1.0
***************************************************************************/
public Path resolve(String source, String version) throws IOException
{
// Local path
Path localPath = Path.of(source);
if(Files.isDirectory(localPath))
{
System.out.println("Using local template: " + localPath.toAbsolutePath());
return localPath.toAbsolutePath();
}
// Git URL or GitHub shorthand
String gitUrl = toGitUrl(source);
return cloneTemplate(gitUrl, version);
}
/***************************************************************************
* Convert source to a full git URL.
*
* @param source template source
* @return git clone URL
* @since 0.1.0
***************************************************************************/
private String toGitUrl(String source)
{
// Already a git URL
if(source.startsWith("git@") || source.startsWith("https://") || source.startsWith("git://"))
{
return source;
}
// GitHub shorthand: github.com/org/repo or just org/repo
if(source.startsWith(GITHUB_PREFIX))
{
String repo = source.substring(GITHUB_PREFIX.length());
return "https://github.com/" + repo + (repo.endsWith(GIT_SUFFIX) ? "" : GIT_SUFFIX);
}
// Assume GitHub shorthand if it looks like org/repo
if(source.matches("[\\w.-]+/[\\w.-]+"))
{
return "https://github.com/" + source + GIT_SUFFIX;
}
throw new IllegalArgumentException("Invalid template source: " + source);
}
/***************************************************************************
* Clone a git repository to a temporary directory.
*
* @param gitUrl git clone URL
* @param version optional version tag
* @return path to cloned template
* @throws IOException if clone fails
* @since 0.1.0
***************************************************************************/
private Path cloneTemplate(String gitUrl, String version) throws IOException
{
Path tempDir = Files.createTempDirectory("qctl-template-");
System.out.println("Cloning: " + gitUrl);
try
{
ProcessBuilder pb = new ProcessBuilder("git", "clone", "--depth", "1");
if(version != null && !version.isBlank())
{
pb.command().add("--branch");
pb.command().add(version.startsWith("v") ? version : "v" + version);
}
pb.command().add(gitUrl);
pb.command().add(tempDir.toString());
pb.inheritIO();
Process process = pb.start();
int exitCode = process.waitFor();
if(exitCode != 0)
{
throw new IOException("git clone failed with exit code " + exitCode);
}
return tempDir;
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw new IOException("git clone interrupted", e);
}
}
}
@@ -0,0 +1,163 @@
/*
* 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.template;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
/*******************************************************************************
* Fetches and manages the templates index from templates-hub.
*
* @since 0.1.0
*******************************************************************************/
public class TemplatesHub
{
private static final String HUB_URL =
"https://raw.githubusercontent.com/QRun-IO/templates-hub/main/templates.yaml";
private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory());
private static final int TIMEOUT_SECONDS = 30;
private TemplatesIndex cachedIndex;
/***************************************************************************
* Fetch the templates index from the hub.
*
* @return templates index
* @throws IOException if fetch fails
* @since 0.1.0
***************************************************************************/
public TemplatesIndex fetchIndex() throws IOException
{
if(cachedIndex != null)
{
return cachedIndex;
}
try
{
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(TIMEOUT_SECONDS))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(HUB_URL))
.GET()
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
if(response.statusCode() != 200)
{
throw new IOException("Failed to fetch templates index: HTTP " + response.statusCode());
}
cachedIndex = YAML.readValue(response.body(), TemplatesIndex.class);
return cachedIndex;
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw new IOException("Interrupted while fetching templates index", e);
}
}
/***************************************************************************
* Find a template by ID.
*
* @param templateId template ID
* @return template entry if found
* @throws IOException if fetch fails
* @since 0.1.0
***************************************************************************/
public Optional<TemplateEntry> findById(String templateId) throws IOException
{
TemplatesIndex index = fetchIndex();
if(index.templates() == null)
{
return Optional.empty();
}
return index.templates().stream()
.filter(t -> t.id().equals(templateId))
.findFirst();
}
/***************************************************************************
* Templates index from templates-hub.
*
* @since 0.1.0
***************************************************************************/
@JsonIgnoreProperties(ignoreUnknown = true)
public record TemplatesIndex(
String version,
String registry,
List<TemplateEntry> templates
)
{
}
/***************************************************************************
* A template entry from the index.
*
* @since 0.1.0
***************************************************************************/
@JsonIgnoreProperties(ignoreUnknown = true)
public record TemplateEntry(
Integer schemaVersion,
String id,
String name,
String description,
String repo,
String version,
String minimumQctlVersion,
List<String> tags,
String maintainer,
List<TemplateManifest.Prompt> prompts,
List<ComputedVariable> computed,
List<Transform> transforms,
List<TemplateManifest.PostGenHook> postGen,
List<String> ignore
)
{
/*************************************************************************
* Get the git clone URL for this template.
*
* @param registry base registry URL
* @return git clone URL
* @since 0.1.0
*************************************************************************/
public String getGitUrl(String registry)
{
return registry + "/" + repo + ".git";
}
}
}
@@ -0,0 +1,74 @@
/*
* 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.template;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/*******************************************************************************
* Transform definition for post-render file operations.
*
* Supports two transform types:
* - rename: Rename files matching a glob pattern
* - delete: Delete files matching a glob pattern
*
* @param type transform type (rename or delete)
* @param pattern glob pattern to match files (e.g., "**\/*.java")
* @param replacement replacement pattern for rename (uses $varName substitution)
* @since 0.2.0
*******************************************************************************/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Transform(
String type,
String pattern,
String replacement
)
{
/***************************************************************************
* Transform type constants.
***************************************************************************/
public static final String TYPE_RENAME = "rename";
public static final String TYPE_DELETE = "delete";
/***************************************************************************
* Validate this transform definition.
*
* @throws IllegalArgumentException if transform is invalid
* @since 0.2.0
***************************************************************************/
public void validate()
{
if(type == null || type.isBlank())
{
throw new IllegalArgumentException("Transform type is required");
}
if(!TYPE_RENAME.equals(type) && !TYPE_DELETE.equals(type))
{
throw new IllegalArgumentException("Invalid transform type: " + type + ". Must be 'rename' or 'delete'");
}
if(pattern == null || pattern.isBlank())
{
throw new IllegalArgumentException("Transform pattern is required");
}
if(TYPE_RENAME.equals(type) && (replacement == null || replacement.isBlank()))
{
throw new IllegalArgumentException("Replacement is required for rename transforms");
}
}
}
@@ -0,0 +1,219 @@
/*
* 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.template;
import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/*******************************************************************************
* Executes post-render transforms on generated files.
*
* Supports rename and delete operations using glob patterns.
*
* @since 0.2.0
*******************************************************************************/
public class TransformExecutor
{
/////////////////////////////////////////////////////////////////////////////
// Pattern for variable substitution in replacement strings: ${varName} //
/////////////////////////////////////////////////////////////////////////////
private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\{([a-zA-Z][a-zA-Z0-9_]*)}");
/***************************************************************************
* Execute a list of transforms on the target directory.
*
* @param targetDir target directory containing rendered files
* @param transforms list of transforms to execute (may be null)
* @param variables template variables for replacement substitution
* @throws IOException if file operations fail
* @throws TemplateRenderException if transform is invalid
* @since 0.2.0
***************************************************************************/
public void execute(Path targetDir, List<Transform> transforms, Map<String, String> variables)
throws IOException, TemplateRenderException
{
if(transforms == null || transforms.isEmpty())
{
return;
}
for(Transform transform : transforms)
{
executeTransform(targetDir, transform, variables);
}
}
/***************************************************************************
* Execute a single transform.
*
* @param targetDir target directory
* @param transform transform to execute
* @param variables template variables
* @throws IOException if file operations fail
* @throws TemplateRenderException if transform is invalid
* @since 0.2.0
***************************************************************************/
private void executeTransform(Path targetDir, Transform transform, Map<String, String> variables)
throws IOException, TemplateRenderException
{
String type = transform.type();
try
{
if(Transform.TYPE_DELETE.equals(type))
{
executeDelete(targetDir, transform.pattern());
}
else if(Transform.TYPE_RENAME.equals(type))
{
executeRename(targetDir, transform.pattern(), transform.replacement(), variables);
}
else
{
throw new TemplateRenderException("Invalid transform type: " + type,
"manifest.yaml (transforms section)");
}
}
catch(IOException e)
{
throw new IOException("Transform failed for pattern '" + transform.pattern()
+ "' in " + targetDir + ": " + e.getMessage(), e);
}
}
/***************************************************************************
* Execute a delete transform.
*
* @param targetDir target directory
* @param pattern glob pattern to match files
* @throws IOException if file operations fail
* @since 0.2.0
***************************************************************************/
private void executeDelete(Path targetDir, String pattern) throws IOException
{
List<Path> matchingFiles = findMatchingFiles(targetDir, pattern);
for(Path file : matchingFiles)
{
Files.deleteIfExists(file);
}
}
/***************************************************************************
* Execute a rename transform.
*
* @param targetDir target directory
* @param pattern glob pattern to match files
* @param replacement replacement filename (may contain ${var} placeholders)
* @param variables template variables for substitution
* @throws IOException if file operations fail
* @since 0.2.0
***************************************************************************/
private void executeRename(Path targetDir, String pattern, String replacement,
Map<String, String> variables) throws IOException
{
List<Path> matchingFiles = findMatchingFiles(targetDir, pattern);
String resolvedReplacement = substituteVariables(replacement, variables);
for(Path file : matchingFiles)
{
Path newPath = file.getParent().resolve(resolvedReplacement);
Files.move(file, newPath);
}
}
/***************************************************************************
* Find all files matching a glob pattern.
*
* @param targetDir target directory to search
* @param pattern glob pattern
* @return list of matching file paths
* @throws IOException if traversal fails
* @since 0.2.0
***************************************************************************/
private List<Path> findMatchingFiles(Path targetDir, String pattern) throws IOException
{
List<Path> matches = new ArrayList<>();
PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern);
Files.walkFileTree(targetDir, new SimpleFileVisitor<>()
{
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
{
Path relativePath = targetDir.relativize(file);
if(matcher.matches(relativePath))
{
matches.add(file);
}
return FileVisitResult.CONTINUE;
}
});
return matches;
}
/***************************************************************************
* Substitute ${varName} placeholders in a string.
*
* @param template string with placeholders
* @param variables variable values
* @return string with placeholders replaced
* @since 0.2.0
***************************************************************************/
private String substituteVariables(String template, Map<String, String> variables)
{
if(template == null || variables == null || variables.isEmpty())
{
return template;
}
Matcher matcher = VARIABLE_PATTERN.matcher(template);
StringBuffer result = new StringBuffer();
while(matcher.find())
{
String varName = matcher.group(1);
String value = variables.getOrDefault(varName, matcher.group(0));
matcher.appendReplacement(result, Matcher.quoteReplacement(value));
}
matcher.appendTail(result);
return result.toString();
}
}
@@ -0,0 +1,7 @@
Args=--initialize-at-run-time=com.github.jknack.handlebars.internal.Files \
--initialize-at-run-time=com.github.jknack.handlebars.helper.DefaultHelperRegistry \
-H:IncludeResources=helpers.nashorn.js \
-H:IncludeResources=handlebars-v4.7.7.js \
-H:IncludeResources=com/github/jknack/handlebars/.* \
-H:ReflectionConfigurationResources=${.}/reflect-config.json \
-H:ResourceConfigurationResources=${.}/resource-config.json
@@ -0,0 +1,202 @@
[
{
"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,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true
},
{
"name": "io.qrun.qctl.qqq.template.TemplatesHub$TemplateEntry",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true
},
{
"name": "io.qrun.qctl.qqq.template.TemplateManifest",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true
},
{
"name": "io.qrun.qctl.qqq.template.TemplateManifest$Prompt",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true
},
{
"name": "io.qrun.qctl.qqq.template.TemplateManifest$PostGenHook",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true
},
{
"name": "io.qrun.qctl.qqq.template.ComputedVariable",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true
},
{
"name": "io.qrun.qctl.qqq.template.Transform",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true
},
{
"name": "io.qrun.qctl.qqq.template.StringTool",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true
},
{
"name": "org.apache.velocity.runtime.resource.ResourceManagerImpl",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.resource.ResourceCacheImpl",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.resource.util.StringResourceRepositoryImpl",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.ParserPoolImpl",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.util.introspection.UberspectImpl",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.util.introspection.TypeConversionHandlerImpl",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.log.Slf4jLogChute",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.parser.StandardParser",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.resource.loader.StringResourceLoader",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Foreach",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Include",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Parse",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Macro",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Define",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Evaluate",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Stop",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Break",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
}
]
@@ -0,0 +1,10 @@
{
"resources": {
"includes": [
{"pattern": "helpers\\.nashorn\\.js"},
{"pattern": "handlebars-v4\\.7\\.7\\.js"},
{"pattern": "com/github/jknack/handlebars/.*"},
{"pattern": ".*/helpers/.*\\.js"}
]
}
}
@@ -0,0 +1,201 @@
/*
* 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.error;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Tests for ErrorFormatter output formatting.
*
* @since 0.2.0
*******************************************************************************/
class ErrorFormatterTest
{
/***************************************************************************
* Test basic error formatting.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_basicError()
{
ErrorFormatter formatter = new ErrorFormatter(false);
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.RENDER_ERROR,
"Syntax error in template"
);
String output = formatter.format(error);
assertThat(output).contains("error:");
assertThat(output).contains("Syntax error in template");
}
/***************************************************************************
* Test error with suggestions.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_withSuggestions()
{
ErrorFormatter formatter = new ErrorFormatter(false);
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.TEMPLATE_NOT_FOUND,
"Template 'foo' not found",
List.of("foobar", "foo-api"),
null
);
String output = formatter.format(error);
assertThat(output).contains("Did you mean?");
assertThat(output).contains("foobar");
assertThat(output).contains("foo-api");
}
/***************************************************************************
* Test error with hint.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_withHint()
{
ErrorFormatter formatter = new ErrorFormatter(false);
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.NETWORK_ERROR,
"Connection refused",
"Check your network connection"
);
String output = formatter.format(error);
assertThat(output).contains("Check your network connection");
}
/***************************************************************************
* Test verbose mode includes stack trace.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_verboseWithCause()
{
ErrorFormatter formatter = new ErrorFormatter(true);
Exception cause = new RuntimeException("Root cause");
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.IO_ERROR,
"Failed to read file",
cause
);
String output = formatter.format(error);
assertThat(output).contains("Stack trace:");
assertThat(output).contains("RuntimeException");
assertThat(output).contains("Root cause");
}
/***************************************************************************
* Test non-verbose mode hides stack trace.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_nonVerboseHidesStackTrace()
{
ErrorFormatter formatter = new ErrorFormatter(false);
Exception cause = new RuntimeException("Root cause");
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.IO_ERROR,
"Failed to read file",
cause
);
String output = formatter.format(error);
assertThat(output).doesNotContain("Stack trace:");
}
/***************************************************************************
* Test formatting generic exception.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_genericException()
{
ErrorFormatter formatter = new ErrorFormatter(false);
Exception e = new RuntimeException("Something went wrong");
String output = formatter.format(e, "Try again later");
assertThat(output).contains("error:");
assertThat(output).contains("Something went wrong");
assertThat(output).contains("Try again later");
}
/***************************************************************************
* Test formatting exception without hint.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_exceptionNoHint()
{
ErrorFormatter formatter = new ErrorFormatter(false);
Exception e = new RuntimeException("Error message");
String output = formatter.format(e, null);
assertThat(output).contains("error:");
assertThat(output).contains("Error message");
}
/***************************************************************************
* Test verbose exception shows stack trace.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_verboseException()
{
ErrorFormatter formatter = new ErrorFormatter(true);
Exception e = new RuntimeException("Error");
String output = formatter.format(e, null);
assertThat(output).contains("Stack trace:");
}
}
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more