Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a42f7b41e | ||
|
|
bdb5833be2 | ||
|
|
f751b2eb4e | ||
|
|
b4adabecfe | ||
|
|
418f09fea1 | ||
|
|
759f40d61e | ||
|
|
608e807704 | ||
|
|
ec52f64c49 | ||
|
|
c2f619798b | ||
|
|
fe4883c966 | ||
|
|
fb10e15ef2 | ||
|
|
98f4a470d3 | ||
|
|
514cd3d5ae | ||
|
|
618f09e5f0 | ||
|
|
a25309b75d | ||
|
|
8028f9b101 |
No files matched your search
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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*
|
||||
|
||||
Generated
+2
-2
@@ -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" />
|
||||
|
||||
Generated
+2
@@ -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" />
|
||||
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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.
@@ -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"
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user