Author SHA1 Message Date
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
52 changed files with 2918 additions and 112 deletions

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
{
"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:*)"
]
}
}
+201
View File
@@ -0,0 +1,201 @@
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
strategy:
matrix:
include:
- os: ubuntu-latest
artifact: qctl-linux-amd64
arch: amd64
- os: ubuntu-24.04-arm
artifact: qctl-linux-arm64
arch: arm64
- os: macos-15
artifact: qctl-macos-amd64
arch: amd64
- os: macos-14
artifact: qctl-macos-arm64
arch: arm64
- os: windows-latest
artifact: qctl-windows-amd64.exe
arch: amd64
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: Create release
uses: softprops/action-gh-release@v2
with:
files: artifacts/**/*
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 artifacts
uses: actions/download-artifact@v4
with:
pattern: qctl-linux-*
path: .
merge-multiple: true
- name: Prepare binaries
run: |
mv qctl-linux-amd64/qctl-linux-amd64 qctl-linux-amd64.bin
mv qctl-linux-arm64/qctl-linux-arm64 qctl-linux-arm64.bin
mv qctl-linux-amd64.bin qctl-linux-amd64
mv qctl-linux-arm64.bin qctl-linux-arm64
- 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-packages:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: develop
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Update package manifests
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_AMD64=$(shasum -a 256 artifacts/qctl-linux-amd64/qctl-linux-amd64 | cut -d' ' -f1)
SHA_LINUX_ARM64=$(shasum -a 256 artifacts/qctl-linux-arm64/qctl-linux-arm64 | cut -d' ' -f1)
SHA_WINDOWS=$(shasum -a 256 artifacts/qctl-windows-amd64.exe/qctl-windows-amd64.exe | cut -d' ' -f1)
# Update Homebrew
sed -i "s/version \".*\"/version \"${VERSION}\"/" packaging/homebrew/qctl.rb
sed -i "s/PLACEHOLDER_ARM64_SHA256/${SHA_MACOS_ARM64}/" packaging/homebrew/qctl.rb
sed -i "s/PLACEHOLDER_AMD64_SHA256/${SHA_MACOS_AMD64}/" packaging/homebrew/qctl.rb
sed -i "s/PLACEHOLDER_LINUX_AMD64_SHA256/${SHA_LINUX_AMD64}/" packaging/homebrew/qctl.rb
sed -i "s/PLACEHOLDER_LINUX_ARM64_SHA256/${SHA_LINUX_ARM64}/" packaging/homebrew/qctl.rb
# Update Scoop
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" packaging/scoop/qctl.json
sed -i "s|/v[0-9.]*\/|/v${VERSION}/|g" packaging/scoop/qctl.json
sed -i "s/PLACEHOLDER_WINDOWS_SHA256/${SHA_WINDOWS}/" packaging/scoop/qctl.json
# Update AUR
sed -i "s/pkgver=.*/pkgver=${VERSION}/" packaging/aur/PKGBUILD
sed -i "s/PLACEHOLDER_LINUX_SHA256/${SHA_LINUX_AMD64}/" packaging/aur/PKGBUILD
- name: Commit package updates
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add packaging/homebrew/qctl.rb packaging/scoop/qctl.json packaging/aur/PKGBUILD
git commit -m "chore(packages): update manifests to ${GITHUB_REF_NAME}"
git push
+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" />
+117
View File
@@ -0,0 +1,117 @@
# 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)
JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn clean package -DskipTests
# Output: qctl-cli/target/qctl (native profile is in qctl-cli pom.xml, not a -P flag)
# 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 Handlebars
├── 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 (only module with native profile)
├── 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). Handlebars for rendering with custom helpers (camelCase, pascalCase, etc.).
**Interactive Commands**: All console I/O goes through `ConsoleUI` class (`qctl-qqq/template/ConsoleUI.java`). This separates UI from logic. Commands use `ConsoleUI` for prompts, styled output, and selections.
**Version Info**: `VersionProvider` reads from `version.properties` (Maven-filtered). Shows version, build timestamp, and git commit hash.
**Native Image**: GraalVM 21 with reflection config for Jackson records and Handlebars resources.
## 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`
## Distribution
On tagged release (`git tag v1.0.0 && git push origin v1.0.0`):
| Channel | Location | 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 | Yes |
**Platforms**: Linux (x64, ARM64), macOS (Intel, Apple Silicon), Windows (x64)
## Native Image Notes
- Only `qctl-cli` builds native image (other modules removed native profile)
- Handlebars requires `--initialize-at-run-time=com.github.jknack.handlebars.helper.DefaultHelperRegistry`
- Jackson records need reflection config in `META-INF/native-image/reflect-config.json`
- Resources config in `META-INF/native-image/resource-config.json`
## Testing
- JUnit 5 + AssertJ + Mockito
- Run: `mvn test` or `mvn verify`
+212
View File
@@ -1,2 +1,214 @@
# 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
├── docs/ # Architecture and design documentation
├── packaging/ # Distribution manifests (Homebrew, Scoop, AUR, Docker)
└── codestyle/ # Checkstyle config and license headers
```
## Development
```bash
# Run tests
mvn clean verify
# 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
```
## 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.
+171
View File
@@ -0,0 +1,171 @@
# 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 new`
```
User: qctl qqq new voyage --template qqq-app
1. Load config (defaults → global → project → env → flags)
2. Resolve template:
- Check local cache for "qqq-app" template
- If missing/outdated, fetch from templates.qrun.io
- Verify signature (warn-only in V1)
3. Prompt for variables (or use --non-interactive defaults)
4. Render template via Handlebars:
- Process template.yaml manifest
- Apply variable substitutions
- Copy/transform files to target directory
5. Post-generation hooks:
- Run `build` phase (mvn compile)
- Run `run` phase (start app)
- Run `healthcheck` phase (verify startup)
6. 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 (qqq-app)
```
templates/qqq-app/
├── template.yaml # Manifest with prompts and hooks
├── README.md.hbs # Handlebars template
├── pom.xml.hbs
├── src/
│ └── main/
│ └── java/
│ └── {{packagePath}}/
│ ├── Application.java.hbs
│ └── {{name}}QInstance.java.hbs
└── .qctl/
└── template-version # Tracks template version for upgrades
```
## 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)
- **CLI**: Picocli 4.7.5
- **Config**: Jackson YAML + JSON Schema validation
- **HTTP**: JDK HttpClient with retry/backoff
- **Templates**: Handlebars 4.4.0
- **Build**: Maven 3.9+, GraalVM native-image
- **Test**: JUnit 5, AssertJ, Mockito, Testcontainers
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+16
View File
@@ -0,0 +1,16 @@
# Maintainer: QRun-IO <contact@qrun.io>
pkgname=qctl-bin
pkgver=0.1.0
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=('PLACEHOLDER_LINUX_SHA256')
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.1.0",
"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.1.0/qctl-windows-amd64.exe",
"hash": "PLACEHOLDER_WINDOWS_SHA256"
}
},
"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/v$version/qctl-windows-amd64.exe"
}
}
}
}
+68 -19
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.0.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>
<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>
<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>
<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>
+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-cli</artifactId>
<name>qctl-cli</name>
+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,7 +34,7 @@ import picocli.CommandLine.Spec;
@Command(
name = "qctl",
mixinStandardHelpOptions = true,
version = { "qctl 0.1.0" },
versionProvider = VersionProvider.class,
description = "qctl CLI",
subcommands = {
HelpCommand.class,
@@ -132,7 +133,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 };
}
}
}
@@ -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)
@@ -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}
+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-integration-tests</artifactId>
<name>qctl-integration-tests</name>
+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);
}
}
}
+40 -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,44 @@
<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>com.github.jknack</groupId>
<artifactId>handlebars</artifactId>
<version>4.4.0</version>
<exclusions>
<exclusion>
<groupId>org.openjdk.nashorn</groupId>
<artifactId>nashorn-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli-codegen</artifactId>
<version>${picocli.version}</version>
<scope>provided</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,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;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import io.qrun.qctl.qqq.template.ConsoleUI;
import io.qrun.qctl.qqq.template.PostGenHookRunner;
import io.qrun.qctl.qqq.template.PromptRunner;
import io.qrun.qctl.qqq.template.TemplateEngine;
import io.qrun.qctl.qqq.template.TemplateManifest;
import io.qrun.qctl.qqq.template.TemplateResolver;
import io.qrun.qctl.qqq.template.TemplatesHub;
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 Handlebars 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 Runnable
{
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;
/***************************************************************************
* Default constructor for picocli.
*
* @since 0.1.0
***************************************************************************/
public InitCommand()
{
}
@Override
public void run()
{
ConsoleUI ui = new ConsoleUI();
try
{
ui.header("qctl qqq init");
ui.subtitle("Initialize a new QQQ project");
ui.println();
// Step 1: Template selection
TemplatesHub hub = new TemplatesHub();
TemplatesHub.TemplateEntry hubEntry = selectTemplate(hub, ui);
TemplatesHub.TemplatesIndex index = hub.fetchIndex();
String gitSource = hubEntry.getGitUrl(index.registry());
// Step 2: Get target directory
Path effectiveTargetDir = getTargetDirectory(ui);
// Check target directory
if(Files.exists(effectiveTargetDir) && !force)
{
ui.error("target directory already exists: " + effectiveTargetDir);
ui.println("Use --force to overwrite.");
System.exit(ExitCodes.CONFLICT);
}
// Step 3: Resolve template
ui.println();
ui.info("Resolving template...");
TemplateResolver resolver = new TemplateResolver();
Path templatePath = resolver.resolve(gitSource, version);
// Get manifest from hub entry
TemplateManifest manifest = new TemplateManifest(
hubEntry.id(),
hubEntry.name(),
hubEntry.version(),
hubEntry.description(),
hubEntry.prompts(),
hubEntry.postGen(),
hubEntry.ignore()
);
// 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: Render templates
TemplateEngine engine = new TemplateEngine();
if(dryRun)
{
ui.println();
ui.println("[DRY-RUN] Would create files:");
engine.renderDryRun(templatePath, effectiveTargetDir, allVars, manifest);
}
else
{
if(force && Files.exists(effectiveTargetDir))
{
ui.info("Overwriting existing directory...");
}
Files.createDirectories(effectiveTargetDir);
int fileCount = engine.render(templatePath, effectiveTargetDir, allVars, manifest);
ui.println();
ui.success("Created " + fileCount + " files in " + effectiveTargetDir);
// Run post-gen hooks
if(!skipHooks && manifest.postGen() != null)
{
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");
}
catch(IOException e)
{
ui.error(e.getMessage());
System.exit(ExitCodes.GENERIC);
}
catch(Exception e)
{
ui.error(e.getMessage());
System.exit(ExitCodes.GENERIC);
}
}
/***************************************************************************
* Select template - use provided name or prompt interactively.
*
* @param hub templates hub
* @param ui console UI
* @return selected template entry
* @throws IOException if hub cannot be fetched
* @since 0.1.0
***************************************************************************/
private TemplatesHub.TemplateEntry selectTemplate(TemplatesHub hub, ConsoleUI ui) throws IOException
{
List<TemplatesHub.TemplateEntry> templates = hub.fetchIndex().templates();
// If template name provided and found, use it
Optional<TemplatesHub.TemplateEntry> entry = hub.findById(templateName);
if(entry.isPresent())
{
TemplatesHub.TemplateEntry selected = entry.get();
ui.showValue("Template", selected.name(), selected.id());
return selected;
}
// Template not found - prompt for selection
ui.error("Template '" + templateName + "' not found.");
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,
TemplatesHub.TemplateEntry::name,
TemplatesHub.TemplateEntry::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);
}
}
@@ -0,0 +1,85 @@
/*
* 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 io.qrun.qctl.qqq.template.TemplatesHub;
import io.qrun.qctl.shared.ExitCodes;
import picocli.CommandLine.Command;
/*******************************************************************************
* List available project templates from the hub.
*
* @since 0.1.0
*******************************************************************************/
@Command(name = "list", description = "List available project templates")
public class ListCommand implements Runnable
{
private static final String ANSI_BOLD = "\u001B[1m";
private static final String ANSI_CYAN = "\u001B[36m";
private static final String ANSI_DIM = "\u001B[2m";
private static final String ANSI_RESET = "\u001B[0m";
/***************************************************************************
* Default constructor for picocli.
*
* @since 0.1.0
***************************************************************************/
public ListCommand()
{
}
@Override
public void run()
{
try
{
System.out.println("Fetching templates from hub...\n");
TemplatesHub hub = new TemplatesHub();
var index = hub.fetchIndex();
if(index.templates() == null || index.templates().isEmpty())
{
System.out.println("No templates available.");
return;
}
System.out.println(ANSI_BOLD + "Available Templates:" + ANSI_RESET + "\n");
for(var template : index.templates())
{
System.out.println(ANSI_CYAN + " " + template.id() + 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> <target-dir>");
}
catch(IOException e)
{
System.err.println("error: Failed to fetch templates: " + e.getMessage());
System.exit(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,304 @@
/*
* 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 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 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,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,154 @@
/*
* 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.
*
* @since 0.1.0
*******************************************************************************/
public class PromptRunner
{
private final ConsoleUI ui;
/***************************************************************************
* Create a new PromptRunner with default ConsoleUI.
*
* @since 0.1.0
***************************************************************************/
public PromptRunner()
{
this.ui = new ConsoleUI();
}
/***************************************************************************
* 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;
}
/***************************************************************************
* 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)
{
// Skip if already provided via CLI
if(overrides.containsKey(prompt.name()))
{
String value = overrides.get(prompt.name());
ui.showValue(prompt.message() != null ? prompt.message() : prompt.name(), value);
results.put(prompt.name(), value);
continue;
}
String value = promptForValue(prompt);
results.put(prompt.name(), value);
}
return results;
}
/***************************************************************************
* Prompt for a single value.
*
* @param prompt prompt definition
* @return 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());
}
else
{
return ui.promptText(message, prompt.defaultValue());
}
}
/***************************************************************************
* 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,373 @@
/*
* 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.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.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import com.github.jknack.handlebars.Handlebars;
import com.github.jknack.handlebars.Template;
/*******************************************************************************
* Renders templates using Handlebars.
*
* Supports path substitution ({{projectName}}/src) and content templating.
*
* @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"
);
private final Handlebars handlebars;
/***************************************************************************
* Create a new template engine.
*
* @since 0.1.0
***************************************************************************/
public TemplateEngine()
{
this.handlebars = new Handlebars();
registerHelpers();
}
/***************************************************************************
* Register custom Handlebars helpers.
*
* @since 0.1.0
***************************************************************************/
private void registerHelpers()
{
// lowercase helper
handlebars.registerHelper("lowercase", (context, options) ->
context != null ? context.toString().toLowerCase() : "");
// uppercase helper
handlebars.registerHelper("uppercase", (context, options) ->
context != null ? context.toString().toUpperCase() : "");
// camelCase helper
handlebars.registerHelper("camelCase", (context, options) ->
{
if(context == null)
{
return "";
}
String s = context.toString();
if(s.isEmpty())
{
return "";
}
return Character.toLowerCase(s.charAt(0)) + s.substring(1);
});
// PascalCase helper
handlebars.registerHelper("pascalCase", (context, options) ->
{
if(context == null)
{
return "";
}
String s = context.toString();
if(s.isEmpty())
{
return "";
}
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
});
// kebab-case helper
handlebars.registerHelper("kebabCase", (context, options) ->
{
if(context == null)
{
return "";
}
return context.toString()
.replaceAll("([a-z])([A-Z])", "$1-$2")
.toLowerCase();
});
// snake_case helper
handlebars.registerHelper("snakeCase", (context, options) ->
{
if(context == null)
{
return "";
}
return context.toString()
.replaceAll("([a-z])([A-Z])", "$1_$2")
.toLowerCase();
});
}
/***************************************************************************
* 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 rendering fails
* @since 0.1.0
***************************************************************************/
public int render(Path templateDir, Path targetDir, Map<String, String> variables,
TemplateManifest manifest) throws IOException
{
Path sourceDir = resolveTemplateSource(templateDir);
Set<String> ignorePatterns = manifest.ignore() != null
? new HashSet<>(manifest.ignore())
: new HashSet<>();
ignorePatterns.add(".git");
ignorePatterns.add("template.yaml");
AtomicInteger fileCount = new AtomicInteger();
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;
}
Path relativePath = sourceDir.relativize(dir);
String renderedPath = renderPath(relativePath.toString(), variables);
Path targetPath = targetDir.resolve(renderedPath);
Files.createDirectories(targetPath);
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;
}
String renderedPath = renderPath(relativePath.toString(), variables);
Path targetPath = targetDir.resolve(renderedPath);
if(isBinaryFile(file))
{
Files.copy(file, targetPath);
}
else
{
String content = Files.readString(file);
String rendered = renderContent(content, variables);
Files.writeString(targetPath, rendered);
}
fileCount.incrementAndGet();
System.out.println(" " + renderedPath);
return FileVisitResult.CONTINUE;
}
});
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 rendering fails
* @since 0.1.0
***************************************************************************/
public void renderDryRun(Path templateDir, Path targetDir, Map<String, String> variables,
TemplateManifest manifest) throws IOException
{
Path sourceDir = resolveTemplateSource(templateDir);
Set<String> ignorePatterns = manifest.ignore() != null
? new HashSet<>(manifest.ignore())
: new HashSet<>();
ignorePatterns.add(".git");
ignorePatterns.add("template.yaml");
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;
}
String renderedPath = renderPath(relativePath.toString(), variables);
System.out.println(" " + targetDir.resolve(renderedPath));
return FileVisitResult.CONTINUE;
}
});
}
/***************************************************************************
* 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;
}
/***************************************************************************
* Render a path string with variable substitution.
*
* @param path path string with {{var}} placeholders
* @param variables template variables
* @return rendered path
* @since 0.1.0
***************************************************************************/
private String renderPath(String path, Map<String, String> variables)
{
String result = path;
for(Map.Entry<String, String> entry : variables.entrySet())
{
result = result.replace("{{" + entry.getKey() + "}}", entry.getValue());
}
return result;
}
/***************************************************************************
* Render content using Handlebars.
*
* @param content template content
* @param variables template variables
* @return rendered content
* @since 0.1.0
***************************************************************************/
private String renderContent(String content, Map<String, String> variables)
{
try
{
Template template = handlebars.compileInline(content);
return template.apply(variables);
}
catch(IOException e)
{
// If template compilation fails, return content as-is
return content;
}
}
/***************************************************************************
* 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;
}
}
@@ -0,0 +1,99 @@
/*
* 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.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
/*******************************************************************************
* Represents a template manifest (template.yaml).
*
* @since 0.1.0
*******************************************************************************/
@JsonIgnoreProperties(ignoreUnknown = true)
public record TemplateManifest(
String id,
String name,
String version,
String description,
List<Prompt> prompts,
List<PostGenHook> postGen,
List<String> ignore
)
{
private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory());
private static final String MANIFEST_FILE = "template.yaml";
/***************************************************************************
* 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);
}
return YAML.readValue(manifestPath.toFile(), TemplateManifest.class);
}
/***************************************************************************
* 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,
String validation
)
{
}
/***************************************************************************
* A post-generation hook definition.
*
* @since 0.1.0
***************************************************************************/
@JsonIgnoreProperties(ignoreUnknown = true)
public record PostGenHook(
String name,
String command,
String workDir,
Boolean optional
)
{
}
}
@@ -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,159 @@
/*
* 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(
String id,
String name,
String description,
String repo,
String version,
List<String> tags,
String maintainer,
List<TemplateManifest.Prompt> prompts,
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,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,47 @@
[
{
"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
}
]
@@ -0,0 +1,10 @@
{
"resources": {
"includes": [
{"pattern": "helpers\\.nashorn\\.js"},
{"pattern": "handlebars-v4\\.7\\.7\\.js"},
{"pattern": "com/github/jknack/handlebars/.*"},
{"pattern": ".*/helpers/.*\\.js"}
]
}
}
+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-qrun</artifactId>
<name>qctl-qrun</name>
@@ -16,10 +16,10 @@ package io.qrun.qctl.qrun;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import io.qrun.qctl.core.output.Output;
import io.qrun.qctl.shared.ExitCodes;
import picocli.CommandLine.Command;
@@ -53,7 +53,7 @@ public class PackageCommand implements Runnable
catch(IOException e)
{
System.err.println("qrun package error: " + e.getMessage());
System.exit(1);
System.exit(ExitCodes.GENERIC);
}
}
}
@@ -19,6 +19,7 @@ import java.util.Map;
import java.util.UUID;
import io.qrun.qctl.core.http.ApiClient;
import io.qrun.qctl.core.sys.SystemPaths;
import io.qrun.qctl.shared.ExitCodes;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
@@ -105,7 +106,7 @@ public class PublishCommand implements Runnable
catch(Exception e)
{
System.err.println("error: " + e.getMessage());
System.exit(1);
System.exit(ExitCodes.GENERIC);
}
}
@@ -21,6 +21,7 @@ import java.util.Map;
import io.qrun.qctl.core.http.ApiClient;
import io.qrun.qctl.core.output.Output;
import io.qrun.qctl.core.sys.SystemPaths;
import io.qrun.qctl.shared.ExitCodes;
import picocli.CommandLine.Command;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
@@ -112,7 +113,7 @@ public class StatusCommand implements Runnable
catch(Exception e)
{
System.err.println("error: " + e.getMessage());
System.exit(1);
System.exit(ExitCodes.GENERIC);
}
}
+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-qstudio</artifactId>
<name>qctl-qstudio</name>
+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-shared</artifactId>
<name>qctl-shared</name>
@@ -0,0 +1,62 @@
/*
* 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.shared;
/*******************************************************************************
* Standard exit codes for qctl commands.
*
* Why: Consistent exit codes enable scripting and CI/CD integration. These
* codes map directly to RFC 7807 problem types and HTTP status categories.
*
* @since 0.1.0
*******************************************************************************/
public final class ExitCodes
{
/** Operation completed successfully. */
public static final int SUCCESS = 0;
/** Generic/unexpected error. */
public static final int GENERIC = 1;
/** Usage or configuration error (bad arguments, invalid config). */
public static final int USAGE = 2;
/** Network error (connection failed, timeout, 408/429/5xx). */
public static final int NETWORK = 3;
/** Authentication error (401/403 from API). */
public static final int AUTH = 4;
/** Resource not found (404 from API). */
public static final int NOT_FOUND = 5;
/** Validation error (400/422, bad input). */
public static final int VALIDATION = 6;
/** Integrity error (hash/signature mismatch). */
public static final int INTEGRITY = 7;
/** Conflict error (409, state conflict). */
public static final int CONFLICT = 8;
/** Cancelled by user (Ctrl+C, SIGINT). */
public static final int CANCELLED = 9;
/***************************************************************************
* Non-instantiable utility class.
***************************************************************************/
private ExitCodes()
{
}
}