Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e41534a47e
|
||
|
|
0a35966498
|
||
|
|
6a27f3d9b4 | ||
|
|
f80d1ac9f8 | ||
|
|
e47cca4b81 | ||
|
|
c7cf55a99e | ||
|
|
dd275cb9fe | ||
|
|
8ac4fdf2e1 | ||
|
|
386e6caecf | ||
|
|
04914eaf2d | ||
|
|
73c271c201 | ||
|
|
bc93243f6b | ||
|
|
a2ec447003 | ||
|
|
abc118dbe5 | ||
|
|
5005b8e5fa | ||
|
|
30e124dde5 | ||
|
|
daea5e66b3 | ||
|
|
a2e85f7f93 | ||
|
|
e14565a277 | ||
|
|
5a967f072f | ||
|
|
a71af51f47 | ||
|
|
1380b18652 | ||
|
|
334133af71 | ||
|
|
a7abd429fe | ||
|
|
932f4d8e34 | ||
|
|
674adedcba | ||
|
|
18a3377ea7 | ||
|
|
5a42f7b41e | ||
|
|
bdb5833be2 | ||
|
|
f751b2eb4e | ||
|
|
b4adabecfe | ||
|
|
418f09fea1 | ||
|
|
759f40d61e | ||
|
|
608e807704 | ||
|
|
ec52f64c49 | ||
|
|
c2f619798b | ||
|
|
fe4883c966 | ||
|
|
fb10e15ef2 | ||
|
|
98f4a470d3 | ||
|
|
514cd3d5ae | ||
|
|
618f09e5f0 | ||
|
|
a25309b75d | ||
|
|
8028f9b101 |
No files matched your search
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(grep:*)",
|
||||
"Bash(mvn checkstyle:check:*)",
|
||||
"Bash(mvn spotless:check:*)",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(mvn clean verify:*)",
|
||||
"Bash(/usr/libexec/java_home:*)",
|
||||
"Bash(JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn clean verify:*)",
|
||||
"Bash(/Users/james.maes/Git.Local/QRunIO/qctl/qctl-cli/target/qctl:*)",
|
||||
"Bash(JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn compile:*)",
|
||||
"Bash(JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn checkstyle:check:*)",
|
||||
"Bash(JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn clean package:*)",
|
||||
"Bash(git clone:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(unzip:*)",
|
||||
"Bash(/Users/james.maes/Git.Local/QRunIO/qctl/qctl-core/target/qctl:*)",
|
||||
"Bash(gh run list:*)",
|
||||
"Bash(gh run view:*)",
|
||||
"Bash(JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn spotless:apply:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(mv:*)",
|
||||
"Bash(gh label list:*)",
|
||||
"Bash(gh repo view:*)",
|
||||
"Bash(gh issue create:*)",
|
||||
"Bash(gh issue list:*)",
|
||||
"Bash(gh api:*)",
|
||||
"Bash(gh issue edit:*)",
|
||||
"Bash(gh repo create:*)",
|
||||
"Bash(git branch:*)",
|
||||
"Bash(git tag:*)",
|
||||
"Bash(JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn clean compile:*)",
|
||||
"Bash(gh release view:*)",
|
||||
"Bash(gh release download:*)",
|
||||
"Bash(shasum:*)",
|
||||
"Bash(brew untap:*)",
|
||||
"Bash(brew tap:*)",
|
||||
"Bash(brew install:*)",
|
||||
"Bash(qctl:*)",
|
||||
"Bash(qctl --help:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(gh secret list:*)",
|
||||
"Bash(gh secret set:*)",
|
||||
"Bash(gh run watch:*)",
|
||||
"Bash(gh pr list:*)",
|
||||
"Bash(gh pr merge:*)",
|
||||
"Bash(git pull:*)",
|
||||
"Bash(git stash:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
with:
|
||||
publish: false
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by qqq-orb generate_qctl_workflow; edit the central worker, not local jobs.
|
||||
# GitHub-native macOS/Windows runners are required for the release binaries.
|
||||
name: Build
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
full_native:
|
||||
description: Qualify all five native platforms without publishing
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
native:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qctl-native.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
|
||||
with:
|
||||
full_native: ${{ github.event_name == 'workflow_dispatch' && inputs.full_native }}
|
||||
secrets: inherit
|
||||
@@ -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,199 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
qctl is a multi-module Maven CLI tool for the QRun platform. It provides commands for project scaffolding (`qqq`), package management (`qbit`), deployment (`qrun`), and AI-assisted planning (`qstudio`).
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Full build with tests
|
||||
mvn clean verify
|
||||
|
||||
# Fast compile (skip tests)
|
||||
mvn compile -DskipTests
|
||||
|
||||
# Checkstyle only
|
||||
mvn checkstyle:check
|
||||
|
||||
# Spotless check/apply
|
||||
mvn spotless:check
|
||||
mvn spotless:apply
|
||||
|
||||
# Native build (requires GraalVM 21 and -Pnative profile)
|
||||
JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn clean package -DskipTests -Pnative
|
||||
|
||||
# Output: qctl-cli/target/qctl
|
||||
|
||||
# Test version flag
|
||||
./qctl-cli/target/qctl --version
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
qctl/
|
||||
├── qctl-shared # DTOs, ExitCodes, SPI interfaces, utilities
|
||||
├── qctl-core # Config, HTTP client, auth, cache, logging, Main entrypoint
|
||||
├── qctl-qqq # Template scaffolding (init, list) with Velocity
|
||||
├── qctl-qbit # Package management commands, lockfile handling
|
||||
├── qctl-qrun # OCI packaging and deployment commands
|
||||
├── qctl-qstudio # AI planning commands (offline V1)
|
||||
├── qctl-cli # Aggregator for native image build (has native profile)
|
||||
├── qctl-integration-tests # E2E and integration tests with fixture templates
|
||||
├── docs/ # Architecture and design documentation
|
||||
├── packaging/ # Distribution manifests (Homebrew, Scoop, AUR, Docker)
|
||||
└── codestyle/ # Checkstyle config and license headers
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Plugin System**: Commands discovered via `ServiceLoader<CommandPlugin>`. Each module implements `CommandPlugin` and registers via `META-INF/services`.
|
||||
|
||||
**CLI Framework**: Picocli with annotation-based commands. Main entrypoint in `qctl-core/Main.java`.
|
||||
|
||||
**Template System**: Templates fetched from [QRun-IO/templates-hub](https://github.com/QRun-IO/templates-hub). Apache Velocity for rendering with StringTool helpers (`$str.kebab()`, `$str.pascal()`, `$str.replace()`). Supports computed variables and transforms. Path variables use `__VARNAME__` or `$VARNAME` syntax.
|
||||
|
||||
**Interactive Commands**: All console I/O goes through `ConsoleUI` class (`qctl-qqq/template/ConsoleUI.java`). This separates UI from logic.
|
||||
|
||||
**Version Info**: `VersionProvider` reads from `version.properties` (Maven-filtered). Shows version, build timestamp, and git commit hash. Uses `${revision}` property for CI-friendly versioning.
|
||||
|
||||
**Native Image**: GraalVM 21 with reflection config. Native build is in a Maven profile (`-Pnative`) in `qctl-cli/pom.xml`.
|
||||
|
||||
## Code Style
|
||||
|
||||
- **3-space indentation** (Checkstyle enforced)
|
||||
- **Braces on new line** (`LeftCurly: nl`)
|
||||
- **Javadoc required** on all methods (flowerbox style)
|
||||
- **No star imports**
|
||||
- **Import order**: javax, java, third-party, static (alphabetical)
|
||||
|
||||
Flowerbox Javadoc:
|
||||
```java
|
||||
/***************************************************************************
|
||||
* Brief description.
|
||||
*
|
||||
* @param foo description
|
||||
* @return description
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
**Exit Codes** (`qctl-shared/ExitCodes.java`):
|
||||
- 0=success, 1=generic, 2=usage, 3=network, 4=auth, 5=not found, 6=validation, 7=integrity, 8=conflict, 9=cancelled
|
||||
|
||||
**Config Paths**:
|
||||
- macOS: `~/Library/Application Support/qctl/qctl.yaml`
|
||||
- Linux: `$XDG_CONFIG_HOME/qctl/qctl.yaml`
|
||||
- Windows: `%APPDATA%\qctl\qctl.yaml`
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
Workflow: `.github/workflows/build.yml`
|
||||
|
||||
**Trigger**: Push to `main`/`develop`, tags `v*`, PRs
|
||||
|
||||
**Jobs**:
|
||||
1. `test` - Run `mvn clean verify`
|
||||
2. `build-native` - Build native images for 5 platforms (only on push, not PRs)
|
||||
3. `release` - Create GitHub release with artifacts (only on tags)
|
||||
4. `docker` - Build/push multi-arch Docker image to GHCR
|
||||
5. `update-homebrew` - Push to `QRun-IO/homebrew-qctl` tap
|
||||
6. `update-scoop` - Create PR for Scoop manifest update
|
||||
7. `update-aur` - Create PR for AUR PKGBUILD update
|
||||
|
||||
**Build Matrix**:
|
||||
| Platform | Runner | Artifact |
|
||||
|----------|--------|----------|
|
||||
| Linux x64 | ubuntu-latest | qctl-linux-amd64 |
|
||||
| Linux ARM64 | ubuntu-24.04-arm | qctl-linux-arm64 |
|
||||
| macOS Intel | macos-15 | qctl-macos-amd64 |
|
||||
| macOS ARM | macos-14 | qctl-macos-arm64 |
|
||||
| Windows x64 | windows-latest | qctl-windows-amd64.exe |
|
||||
|
||||
**Required Secrets**:
|
||||
- `HOMEBREW_TAP_TOKEN` - PAT with repo scope for homebrew tap
|
||||
|
||||
**Release Process**:
|
||||
```bash
|
||||
git tag -a v0.0.3 -m "Release v0.0.3"
|
||||
git push origin v0.0.3
|
||||
```
|
||||
|
||||
## Distribution
|
||||
|
||||
| Channel | Location | Update Method |
|
||||
|---------|----------|---------------|
|
||||
| Homebrew | `QRun-IO/homebrew-qctl` | Direct push |
|
||||
| Scoop | `packaging/scoop/qctl.json` | PR to develop |
|
||||
| AUR | `packaging/aur/PKGBUILD` | PR to develop |
|
||||
| Docker | `ghcr.io/qrun-io/qctl` | Direct push |
|
||||
| GitHub | Releases | Auto-created |
|
||||
|
||||
**Install**:
|
||||
```bash
|
||||
# Homebrew
|
||||
brew tap QRun-IO/qctl && brew install qctl
|
||||
|
||||
# Docker
|
||||
docker run ghcr.io/qrun-io/qctl --help
|
||||
```
|
||||
|
||||
## Native Image Notes
|
||||
|
||||
- Native profile is in `qctl-cli/pom.xml` (activated with `-Pnative`)
|
||||
- Velocity requires runtime initialization for template parsing
|
||||
- Jackson records need reflection config in `META-INF/native-image/reflect-config.json`
|
||||
- Resources config in `META-INF/native-image/resource-config.json`
|
||||
|
||||
## Testing
|
||||
|
||||
**Frameworks**: JUnit 5 + AssertJ + Mockito
|
||||
|
||||
**Run Tests**:
|
||||
```bash
|
||||
mvn test # Unit tests only
|
||||
mvn verify # Unit + integration tests
|
||||
mvn test -pl qctl-integration-tests # E2E tests only
|
||||
```
|
||||
|
||||
**Test Modules**:
|
||||
- Unit tests in each module's `src/test/java/`
|
||||
- Integration tests in `qctl-integration-tests/`
|
||||
|
||||
**E2E Test Infrastructure** (`qctl-integration-tests/`):
|
||||
- `CommandTestHarness` - In-process command execution with stdout/stderr capture
|
||||
- `TestTemplateRegistry` - Fixture-based template registry for offline testing
|
||||
- `TestableConsoleUI` - Simulated user input for interactive prompts
|
||||
- `ExitCodeAssertions` - Fluent assertions for exit codes
|
||||
|
||||
**Test Fixtures** (`src/test/resources/fixtures/templates/`):
|
||||
| Fixture | Purpose |
|
||||
|---------|---------|
|
||||
| `test-minimal` | Basic template with no prompts |
|
||||
| `test-with-prompts` | Template with interactive prompts |
|
||||
| `test-full-substitution` | Tests variable replacement in content |
|
||||
| `test-computed-vars` | Tests computed variables (packagePath) |
|
||||
| `test-transforms` | Tests delete transforms |
|
||||
| `test-variable-typo` | Tests "Did you mean?" suggestions |
|
||||
|
||||
**Path Variable Syntax**:
|
||||
- Use `__VARNAME__` in directory names (converted to `$VARNAME` at runtime)
|
||||
- Avoids shell/Maven interpretation of `$` characters
|
||||
- Example: `template/src/main/java/__packagePath__/App.java`
|
||||
|
||||
## Session Continuity
|
||||
|
||||
To continue from last session, see `docs/internal/session-state.md` for current progress and next steps.
|
||||
|
||||
## Knowledge base
|
||||
|
||||
Reviewed dossier for this repo (commit `c7cf55a99e51`, branch `develop`, 2026-07-04):
|
||||
`R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/repos/qctl.md`
|
||||
|
||||
QQQ platform hub (cross-repo map, v4.0 impact tracking, licensing follow-ups):
|
||||
`R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/qqq-hub.md`
|
||||
@@ -1,680 +1,202 @@
|
||||
QQQ - Low-code Application Framework for Engineers
|
||||
Copyright (C) 2021-2025 QRun-IO, LLC
|
||||
https://www.qrun.io | https://github.com/QRun-IO
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright
|
||||
owner or by an individual or Legal Entity authorized to submit on
|
||||
behalf of the copyright owner. For the purposes of this definition,
|
||||
"submitted" means any form of electronic, verbal, or written
|
||||
communication sent to the Licensor or its representatives, including
|
||||
but not limited to communication on electronic mailing lists, source
|
||||
code control systems, and issue tracking systems that are managed by,
|
||||
or on behalf of, the Licensor for the purpose of discussing and
|
||||
improving the Work, but excluding communication that is conspicuously
|
||||
marked or otherwise designated in writing by the copyright owner as
|
||||
"Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2022-2025 QRunIO, LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,15 @@
|
||||
qctl - QRun CLI
|
||||
Copyright 2022-2025 QRunIO, LLC
|
||||
|
||||
This product includes software developed at QRunIO, LLC (https://qrun.io/).
|
||||
|
||||
Licensed under the Apache License, Version 2.0.
|
||||
|
||||
================================================================================
|
||||
|
||||
Trademarks:
|
||||
|
||||
QQQ, qBits, qRun, qStudio, and the QRunIO logo are trademarks of QRunIO, LLC.
|
||||
Use of these trademarks must comply with QRunIO's Trademark Guidelines.
|
||||
|
||||
================================================================================
|
||||
@@ -1,2 +1,233 @@
|
||||
# qctl
|
||||
|
||||
Command-line interface for the QRun ecosystem. Scaffold QQQ applications, manage qBits, and deploy to qRun.
|
||||
|
||||
## Installation
|
||||
|
||||
### Homebrew (macOS/Linux)
|
||||
|
||||
```bash
|
||||
brew tap QRun-IO/qctl
|
||||
brew install qctl
|
||||
```
|
||||
|
||||
### Scoop (Windows)
|
||||
|
||||
```powershell
|
||||
scoop bucket add qrun https://github.com/QRun-IO/qctl
|
||||
scoop install qctl
|
||||
```
|
||||
|
||||
### AUR (Arch Linux)
|
||||
|
||||
```bash
|
||||
yay -S qctl-bin
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker run --rm ghcr.io/qrun-io/qctl:latest --help
|
||||
|
||||
# Mount current directory for project scaffolding
|
||||
docker run --rm -v $(pwd):/work -w /work ghcr.io/qrun-io/qctl:latest qqq init new-qqq-application my-app
|
||||
```
|
||||
|
||||
### Pre-built Binaries
|
||||
|
||||
Download from [Releases](https://github.com/QRun-IO/qctl/releases):
|
||||
|
||||
| Platform | Binary |
|
||||
|----------|--------|
|
||||
| Linux x64 | `qctl-linux-amd64` |
|
||||
| Linux ARM64 | `qctl-linux-arm64` |
|
||||
| macOS Intel | `qctl-macos-amd64` |
|
||||
| macOS Apple Silicon | `qctl-macos-arm64` |
|
||||
| Windows | `qctl-windows-amd64.exe` |
|
||||
|
||||
```bash
|
||||
# Example: macOS Apple Silicon
|
||||
curl -L https://github.com/QRun-IO/qctl/releases/latest/download/qctl-macos-arm64 -o qctl
|
||||
chmod +x qctl
|
||||
sudo mv qctl /usr/local/bin/
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
Requires GraalVM 21+:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/QRun-IO/qctl.git
|
||||
cd qctl
|
||||
JAVA_HOME=/path/to/graalvm mvn clean package -DskipTests -Pnative
|
||||
./qctl-cli/target/qctl --version
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# List available project templates
|
||||
qctl qqq list
|
||||
|
||||
# Scaffold a new QQQ application
|
||||
qctl qqq init new-qqq-application my-app
|
||||
|
||||
# With custom variables
|
||||
qctl qqq init new-qqq-application my-app \
|
||||
--var=projectName=MyApp \
|
||||
--var=groupId=com.example \
|
||||
--var=package=com.example.myapp
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### qqq - Scaffolding
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `qctl qqq list` | List available templates from the hub |
|
||||
| `qctl qqq init <template> <dir>` | Initialize a new project from template |
|
||||
|
||||
**Init Options:**
|
||||
- `--var=key=value` - Set template variable
|
||||
- `--version=1.0.0` - Use specific template version
|
||||
- `--dry-run` - Preview without writing files
|
||||
- `--force` - Overwrite existing directory
|
||||
- `--no-prompt` - Skip interactive prompts
|
||||
- `--skip-hooks` - Skip post-generation hooks
|
||||
|
||||
### auth - Authentication
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `qctl auth login` | Authenticate to qRun APIs |
|
||||
| `qctl auth logout` | Clear stored credentials |
|
||||
|
||||
### qbit - Package Management
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `qctl qbit resolve` | Resolve qBit dependencies |
|
||||
|
||||
### qrun - Deployment
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `qctl qrun status` | Check deployment status |
|
||||
| `qctl qrun package` | Package application |
|
||||
| `qctl qrun publish` | Publish to registry |
|
||||
|
||||
### cache - Maintenance
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `qctl cache clean` | Clear local cache |
|
||||
|
||||
## Global Options
|
||||
|
||||
```
|
||||
--debug Enable debug logging
|
||||
--verbose Enable verbose output
|
||||
--offline Run without network access
|
||||
--hermetic Resolve strictly from lockfile
|
||||
--output Output format: text|json
|
||||
--help Show help
|
||||
--version Show version
|
||||
```
|
||||
|
||||
## Templates
|
||||
|
||||
Templates are managed in [QRun-IO/templates-hub](https://github.com/QRun-IO/templates-hub). Available templates:
|
||||
|
||||
| ID | Description |
|
||||
|----|-------------|
|
||||
| `new-qqq-application` | Complete QQQ application with entities, database, and dashboard |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
qctl/
|
||||
├── qctl-cli/ # Native image entry point (aggregates all modules)
|
||||
├── qctl-core/ # Core commands (auth, cache)
|
||||
├── qctl-shared/ # Shared utilities and constants
|
||||
├── qctl-qqq/ # Scaffolding commands (init, list)
|
||||
├── qctl-qbit/ # Package management commands
|
||||
├── qctl-qrun/ # Deployment commands
|
||||
├── qctl-qstudio/ # Planning commands
|
||||
├── qctl-integration-tests/ # E2E tests with fixture templates
|
||||
├── docs/ # Architecture and design documentation
|
||||
├── packaging/ # Distribution manifests (Homebrew, Scoop, AUR, Docker)
|
||||
└── codestyle/ # Checkstyle config and license headers
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
mvn clean verify
|
||||
|
||||
# Run unit tests only
|
||||
mvn test
|
||||
|
||||
# Run E2E tests only
|
||||
mvn test -pl qctl-integration-tests
|
||||
|
||||
# Build native image (requires GraalVM)
|
||||
JAVA_HOME=/path/to/graalvm mvn clean package -DskipTests -Pnative
|
||||
|
||||
# Run with JVM (faster iteration)
|
||||
mvn clean package -DskipTests
|
||||
java -jar qctl-cli/target/qctl-cli-0.1.0-SNAPSHOT.jar --help
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
The project includes comprehensive E2E tests in `qctl-integration-tests/`:
|
||||
|
||||
| Test Class | Coverage |
|
||||
|------------|----------|
|
||||
| `InitCommandE2ETest` | CLI flags and basic init behavior |
|
||||
| `InitCommandFullE2ETest` | Variable substitution, computed vars, transforms |
|
||||
| `NativeBinaryE2ETest` | Native binary smoke tests (requires build) |
|
||||
|
||||
Test fixtures in `src/test/resources/fixtures/templates/` provide offline, deterministic testing.
|
||||
|
||||
## Releasing
|
||||
|
||||
Create a tagged release to trigger the full CI/CD pipeline:
|
||||
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
This automatically:
|
||||
1. Runs tests on Ubuntu
|
||||
2. Builds native binaries for all platforms
|
||||
3. Creates a GitHub Release with binaries attached
|
||||
4. Pushes Docker image to `ghcr.io/qrun-io/qctl`
|
||||
5. Updates package manifests (Homebrew, Scoop, AUR) with correct checksums
|
||||
|
||||
### Distribution Channels
|
||||
|
||||
| Channel | Package | Auto-Updated |
|
||||
|---------|---------|--------------|
|
||||
| Homebrew | `packaging/homebrew/qctl.rb` | Yes |
|
||||
| Scoop | `packaging/scoop/qctl.json` | Yes |
|
||||
| AUR | `packaging/aur/PKGBUILD` | Yes |
|
||||
| Docker | `packaging/docker/Dockerfile` | Yes |
|
||||
| GitHub | Releases page | Yes |
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
| Platform | Architecture | Binary |
|
||||
|----------|--------------|--------|
|
||||
| Linux | x64 | `qctl-linux-amd64` |
|
||||
| Linux | ARM64 | `qctl-linux-arm64` |
|
||||
| macOS | Intel | `qctl-macos-amd64` |
|
||||
| macOS | Apple Silicon | `qctl-macos-arm64` |
|
||||
| Windows | x64 | `qctl-windows-amd64.exe` |
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0 - See [LICENSE](LICENSE) for details.
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
# qctl Roadmap
|
||||
|
||||
## Vision
|
||||
|
||||
qctl is the unified CLI for QRun platform developers. It enables the complete developer lifecycle: create projects, manage dependencies, build/deploy applications, and operate running services.
|
||||
|
||||
## Core Integration
|
||||
|
||||
**Voyage** - The QRun platform backend that qctl integrates with for:
|
||||
- Template registry (qqq)
|
||||
- Package registry (qbit)
|
||||
- Artifact storage & deployment (qrun)
|
||||
- Service orchestration & observability (qrun)
|
||||
|
||||
---
|
||||
|
||||
## User Journeys
|
||||
|
||||
### Journey 1: Create a New QQQ Application
|
||||
|
||||
**Goal:** Developer creates a new QQQ application from a template and has a running local dev environment.
|
||||
|
||||
**User Flow:**
|
||||
|
||||
```bash
|
||||
# Step 1: See available templates
|
||||
$ qctl qqq list
|
||||
Available templates:
|
||||
qqq-web-api Web API application with Javalin middleware
|
||||
qqq-cli-tool Command-line tool with Picocli
|
||||
qqq-batch Batch processing application
|
||||
qqq-full-stack Full-stack with React frontend
|
||||
|
||||
# Step 2: Initialize a new project
|
||||
$ qctl qqq init qqq-web-api my-app
|
||||
|
||||
? Java package [com.example.myapp]: com.acme.orders
|
||||
? Maven groupId [com.acme]: com.acme
|
||||
? Maven artifactId [my-app]: orders-service
|
||||
? Application name [myApp]: ordersApp
|
||||
? Application label [My App]: Orders Service
|
||||
|
||||
Creating project from template: qqq-web-api
|
||||
Cloning template...
|
||||
Rendering files with Velocity...
|
||||
Moving src/main/java/com/example/orders → src/main/java/com/acme/orders
|
||||
Moving src/test/java/com/example/orders → src/test/java/com/acme/orders
|
||||
Running post-gen hook: build
|
||||
> mvn clean verify -DskipTests
|
||||
[BUILD SUCCESS]
|
||||
|
||||
Project created: ./my-app
|
||||
Next steps:
|
||||
cd my-app
|
||||
mvn spring-boot:run
|
||||
|
||||
# Step 3: Run locally
|
||||
$ cd my-app && mvn spring-boot:run
|
||||
[INFO] Started OrdersApp in 2.3s
|
||||
```
|
||||
|
||||
**Required Capabilities:**
|
||||
|
||||
| Capability | Description | Status |
|
||||
|------------|-------------|--------|
|
||||
| Template listing | Fetch templates from Voyage (or local cache) | POC - needs Voyage integration |
|
||||
| Template selection | Interactive or CLI arg | POC - needs polish |
|
||||
| Prompt collection | Interactive prompts from manifest | POC - needs validation |
|
||||
| CLI var overrides | `--var key=value` for non-interactive | POC - needs testing |
|
||||
| Velocity rendering | Render file contents with Velocity | **TODO** (currently Handlebars) |
|
||||
| Path rendering | Render file/dir names with variables | POC - switch to Velocity |
|
||||
| Computed variables | Derive `packagePath` from `packageName` | **TODO** |
|
||||
| Directory transforms | Move directories per manifest rules | **TODO** |
|
||||
| Post-gen hooks | Run shell commands after render | POC - needs phases |
|
||||
| Dry-run mode | Show what would be created | POC - needs refinement |
|
||||
| Force overwrite | `--force` to overwrite existing | POC - needs merge mode |
|
||||
| Error handling | Clear errors with recovery suggestions | **TODO** |
|
||||
| Template caching | Cache templates with TTL | **TODO** |
|
||||
|
||||
**Design Decision: Template Engine**
|
||||
|
||||
**Decision:** Use **Apache Velocity** instead of Handlebars for consistency with QQQ's existing template/report system.
|
||||
|
||||
- QQQ already uses Velocity for template rendering
|
||||
- Template authors familiar with QQQ will know Velocity syntax (`$variable`, `#if...#end`)
|
||||
- More powerful than Handlebars (full control flow, macros)
|
||||
- Requires migration from current Handlebars implementation
|
||||
|
||||
**Design Decision: Template Transformation**
|
||||
|
||||
**Decision:** Hybrid approach with Velocity rendering + structural transformations.
|
||||
|
||||
1. **Velocity for content:** Templates use `$packageName`, `$groupId` in source files
|
||||
2. **Manifest rules for structure:** Define path transformations and directory moves
|
||||
3. **Computed variables:** Derive `packagePath` from `packageName` automatically
|
||||
|
||||
**Template Manifest Example:**
|
||||
```yaml
|
||||
name: qqq-web-api
|
||||
prompts:
|
||||
- name: packageName
|
||||
message: "Java package"
|
||||
default: "com.example.myapp"
|
||||
- name: groupId
|
||||
message: "Maven groupId"
|
||||
default: "com.example"
|
||||
- name: artifactId
|
||||
message: "Maven artifactId"
|
||||
default: "my-qqq-app"
|
||||
- name: appName
|
||||
message: "Application name"
|
||||
default: "myApp"
|
||||
|
||||
computed:
|
||||
packagePath: "${packageName.replace('.', '/')}"
|
||||
|
||||
transforms:
|
||||
- type: move
|
||||
from: "src/main/java/com/example/orders"
|
||||
to: "src/main/java/${packagePath}"
|
||||
- type: move
|
||||
from: "src/test/java/com/example/orders"
|
||||
to: "src/test/java/${packagePath}"
|
||||
|
||||
postGen:
|
||||
- name: build
|
||||
command: "mvn clean verify -DskipTests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Journey 2: Add QBit Dependencies
|
||||
|
||||
**Goal:** Developer finds and adds QQQ modules (qbits) to their project.
|
||||
|
||||
```bash
|
||||
# Search for authentication modules
|
||||
qctl qbit search auth
|
||||
|
||||
# Add the SSO qbit to project
|
||||
qctl qbit add @qrun/auth-sso
|
||||
|
||||
# See what's installed
|
||||
qctl qbit list
|
||||
|
||||
# Update to latest versions
|
||||
qctl qbit update
|
||||
```
|
||||
|
||||
**Required Capabilities:**
|
||||
- [ ] Search Voyage registry by keyword/tag
|
||||
- [ ] View qbit details (description, version, dependencies)
|
||||
- [ ] Add qbit with version constraint (`@qrun/auth-sso@^2.0`)
|
||||
- [ ] Resolve dependency tree (handle conflicts)
|
||||
- [ ] Generate/update lockfile (`qbits.lock`)
|
||||
- [ ] Vendor packages to `vendor/qbits/`
|
||||
- [ ] Integrity verification (sha512)
|
||||
|
||||
---
|
||||
|
||||
### Journey 3: Build & Deploy Application
|
||||
|
||||
**Goal:** Developer packages their application and deploys it to an environment.
|
||||
|
||||
```bash
|
||||
# Build OCI image
|
||||
qctl qrun package
|
||||
|
||||
# Deploy to dev environment
|
||||
qctl qrun publish --env dev
|
||||
|
||||
# Deploy to production
|
||||
qctl qrun publish --env prod --channel stable
|
||||
```
|
||||
|
||||
**Required Capabilities:**
|
||||
- [ ] Build OCI image via Jib
|
||||
- [ ] Push to Voyage artifact registry
|
||||
- [ ] Create release record with metadata
|
||||
- [ ] Deploy to target environment via Voyage
|
||||
- [ ] Support release channels (dev, beta, stable)
|
||||
- [ ] Rollback to previous version
|
||||
|
||||
---
|
||||
|
||||
### Journey 4: Operate Running Services
|
||||
|
||||
**Goal:** Developer monitors and manages their deployed applications.
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
qctl qrun status --app my-app --env prod
|
||||
|
||||
# View logs
|
||||
qctl qrun logs --app my-app --env prod --follow
|
||||
|
||||
# Restart service
|
||||
qctl qrun restart --app my-app --env prod
|
||||
|
||||
# Stop service (maintenance)
|
||||
qctl qrun stop --app my-app --env prod
|
||||
|
||||
# Start service
|
||||
qctl qrun start --app my-app --env prod
|
||||
```
|
||||
|
||||
**Required Capabilities:**
|
||||
- [ ] Query Voyage for service status (replicas, health, version)
|
||||
- [ ] Stream logs from Voyage
|
||||
- [ ] Stop/start/restart services via Voyage API
|
||||
- [ ] View deployment history
|
||||
- [ ] Scale replicas up/down
|
||||
|
||||
---
|
||||
|
||||
## Milestones
|
||||
|
||||
### v0.2.0 - Create & Manage
|
||||
|
||||
**Focus:** Complete "Create New Application" and "Add Dependencies" journeys.
|
||||
|
||||
**Epic: qqq - Template System**
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Velocity engine | Replace Handlebars with Apache Velocity |
|
||||
| Computed variables | Auto-derive `packagePath` from `packageName` |
|
||||
| Directory transforms | Move folders per manifest `transforms` rules |
|
||||
| Template manifest v2 | Add `computed` and `transforms` sections |
|
||||
| Migrate templates | Update templates-hub to Velocity syntax |
|
||||
| Voyage mock integration | Template listing from mock API |
|
||||
| Prompt validation | Validate required fields, types, constraints |
|
||||
| Error messages | Clear errors with recovery suggestions |
|
||||
| Merge mode | `--merge` for selective regeneration |
|
||||
|
||||
**Epic: qbit - Package Management**
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| qbit search | Search against Voyage mock registry |
|
||||
| qbit add | Add package with version constraints |
|
||||
| qbit remove | Remove package, update lockfile |
|
||||
| qbit update | Update packages to latest |
|
||||
| qbit list | List installed packages |
|
||||
| SemVer resolution | Constraint solving (^, ~, exact) |
|
||||
| Conflict detection | Identify and report version conflicts |
|
||||
| Vendor layout | Download to `vendor/qbits/` |
|
||||
| Integrity verification | SHA-512 hash checks |
|
||||
|
||||
---
|
||||
|
||||
### v0.3.0 - Deploy & Operate
|
||||
|
||||
**Focus:** Complete "Deploy Application" and "Operate Services" journeys.
|
||||
|
||||
**Epic: qrun - Build & Publish**
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Jib integration | OCI image building |
|
||||
| qrun package | Build container image |
|
||||
| qrun publish | Push to Voyage registry |
|
||||
| OCI labels | Inject standard and custom labels |
|
||||
| Digest verification | Verify image after push |
|
||||
| Release channels | stable/beta/alpha support |
|
||||
| qrun rollback | Rollback to previous version |
|
||||
|
||||
**Epic: qrun - Service Operations**
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| qrun status | Service status from Voyage |
|
||||
| qrun logs | Stream logs with --follow |
|
||||
| qrun stop | Stop service |
|
||||
| qrun start | Start service |
|
||||
| qrun restart | Restart service |
|
||||
| Deployment history | View past deployments |
|
||||
| qrun scale | Scale replicas up/down |
|
||||
|
||||
---
|
||||
|
||||
### v1.0.0 - Production Ready
|
||||
|
||||
**Focus:** AI planning, security hardening, enterprise features.
|
||||
|
||||
**Epic: qstudio - AI Planning**
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Code indexing | Local codebase analysis |
|
||||
| qstudio plan | Generate implementation plan |
|
||||
| Path policies | Allow/deny path lists |
|
||||
| Ledger storage | Track plan history |
|
||||
|
||||
**Epic: Authentication & Security**
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| OIDC device flow | Full authentication flow |
|
||||
| Service accounts | CI/CD support |
|
||||
| Signature enforcement | Require signed packages/images |
|
||||
| OS keychain | Secure credential storage |
|
||||
|
||||
**Epic: Enterprise Features**
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| SSO integration | SAML/OIDC enterprise SSO |
|
||||
| RBAC | Team-based access control |
|
||||
| Audit logging | Compliance audit trail |
|
||||
| Org management | Organization/team management |
|
||||
|
||||
---
|
||||
|
||||
## Voyage Integration
|
||||
|
||||
**Voyage** is the QRun platform backend (currently in development). Until Voyage is ready, qctl will:
|
||||
1. Use mock APIs for development and testing
|
||||
2. Support local/offline modes where possible
|
||||
3. Design against Voyage's planned API contracts (standard QQQ data model)
|
||||
|
||||
Voyage will provide a versioned API that drives templates, qbits, artifacts, and service orchestration.
|
||||
|
||||
## Voyage API Dependencies (Planned)
|
||||
|
||||
qctl requires these Voyage API endpoints:
|
||||
|
||||
### Templates (qqq)
|
||||
- `GET /v1/templates` - List available templates
|
||||
- `GET /v1/templates/{id}` - Get template details
|
||||
- `GET /v1/templates/{id}/versions/{version}` - Download template
|
||||
|
||||
### Packages (qbit)
|
||||
- `GET /v1/qbits?q={query}` - Search packages
|
||||
- `GET /v1/qbits/{id}` - Get package details
|
||||
- `GET /v1/qbits/{id}/versions/{version}` - Download package
|
||||
|
||||
### Artifacts (qrun)
|
||||
- `POST /v1/artifacts` - Register artifact
|
||||
- `GET /v1/artifacts/{id}` - Get artifact info
|
||||
- `POST /v1/releases` - Create release
|
||||
- `POST /v1/deployments` - Deploy to environment
|
||||
|
||||
### Services (qrun)
|
||||
- `GET /v1/apps/{app}/status` - Service status
|
||||
- `GET /v1/apps/{app}/logs` - Service logs
|
||||
- `POST /v1/apps/{app}/restart` - Restart service
|
||||
- `POST /v1/apps/{app}/stop` - Stop service
|
||||
- `POST /v1/apps/{app}/start` - Start service
|
||||
- `POST /v1/apps/{app}/scale` - Scale replicas
|
||||
|
||||
---
|
||||
|
||||
## GitHub Issues Structure
|
||||
|
||||
Each milestone will have:
|
||||
- 1 Epic issue tracking the user journey
|
||||
- Story issues for each capability (linked to epic)
|
||||
- Labels: `epic`, `story`, `module:qqq|qbit|qrun|qstudio`
|
||||
|
||||
Example:
|
||||
```
|
||||
Epic: [v0.2.0] Create New Application Journey
|
||||
├── Story: Template registry integration with Voyage
|
||||
├── Story: Add web-api template
|
||||
├── Story: Add cli-tool template
|
||||
├── Story: Add batch-processor template
|
||||
├── Story: Implement qqq upgrade command
|
||||
└── Story: Enhanced post-init hooks
|
||||
```
|
||||
@@ -0,0 +1,209 @@
|
||||
# qctl Vision & Architecture
|
||||
|
||||
## Purpose
|
||||
|
||||
qctl is the unified CLI for the QRun platform - a complete developer workflow tool for building, packaging, deploying, and managing QQQ-based applications. It consolidates project scaffolding, dependency management, artifact publishing, and AI-assisted development into a single binary.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Single Binary**: One native executable for all QRun platform operations
|
||||
2. **Offline-First**: Full functionality without network when possible (hermetic mode)
|
||||
3. **Declarative Config**: YAML-driven configuration with sensible defaults
|
||||
4. **Platform Parity**: Consistent behavior across macOS, Linux, Windows
|
||||
5. **Developer Experience**: Fast startup (native), helpful errors, shell completion
|
||||
|
||||
## Command Structure
|
||||
|
||||
```
|
||||
qctl
|
||||
├── qqq # Project scaffolding
|
||||
│ ├── new <name> # Create new project from template
|
||||
│ ├── list # List available templates
|
||||
│ └── upgrade # Upgrade project to newer template version
|
||||
├── qbit # Dependency management (QQQ modules)
|
||||
│ ├── search <query> # Search registry for qBits
|
||||
│ ├── add <pkg>[@ver] # Add qBit to project
|
||||
│ ├── remove <pkg> # Remove qBit
|
||||
│ ├── update [pkg] # Update qBit(s)
|
||||
│ ├── list # List installed qBits
|
||||
│ └── resolve # Resolve and lock dependencies
|
||||
├── qrun # Build, package, deploy
|
||||
│ ├── package # Build OCI image via Jib
|
||||
│ ├── publish # Push to registry, create release
|
||||
│ ├── status # Check deployment status
|
||||
│ └── logs # Stream application logs
|
||||
├── qstudio # AI-assisted development
|
||||
│ └── plan # Generate implementation plan
|
||||
├── auth # Authentication
|
||||
│ ├── login # Authenticate (device flow or API key)
|
||||
│ ├── logout # Clear credentials
|
||||
│ └── whoami # Show current identity
|
||||
└── cache # Local cache management
|
||||
├── ls # List cached items
|
||||
├── prune # Remove old entries
|
||||
└── clean # Clear entire cache
|
||||
```
|
||||
|
||||
## Module Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ qctl-cli │
|
||||
│ (Native image aggregator, Main class) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ qctl-qqq │ │ qctl-qbit │ │ qctl-qrun │ │qctl-qstudio │
|
||||
│ Templates │ │ Packages │ │ Deploy │ │ AI Plan │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
|
||||
│ │ │ │
|
||||
└──────────────┴──────┬───────┴──────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ qctl-core │
|
||||
│ Config, HTTP, Auth, Cache, Output, Logging, CLI framework │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ qctl-shared │
|
||||
│ DTOs, SemVer, SPI, Utilities, Constants │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Data Flow: `qctl qqq init`
|
||||
|
||||
```
|
||||
User: qctl qqq init qqq-app my-project
|
||||
|
||||
1. Load config (defaults → global → project → env → flags)
|
||||
2. Resolve template:
|
||||
- Check local cache for "qqq-app" template
|
||||
- If missing/outdated, fetch from templates-hub
|
||||
- Verify signature (warn-only in V1)
|
||||
3. Prompt for variables (or use --no-prompt defaults)
|
||||
4. Evaluate computed variables (e.g., packagePath from packageName)
|
||||
5. Render template via Apache Velocity:
|
||||
- Process template.yaml manifest (schema v2)
|
||||
- Substitute $variables in file content and paths
|
||||
- Convert __VARNAME__ to $VARNAME in paths (CI-safe syntax)
|
||||
- Apply transforms (delete patterns)
|
||||
- Copy rendered files to target directory
|
||||
6. Post-generation hooks (if defined)
|
||||
7. Output success message with next steps
|
||||
```
|
||||
|
||||
## Configuration Precedence
|
||||
|
||||
```
|
||||
Lowest ──────────────────────────────────────────────────► Highest
|
||||
|
||||
Built-in Global Config Project Config Env Vars CLI Flags
|
||||
defaults ~/...qctl.yaml ./qctl.yaml QCTL_* --flag
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning | When |
|
||||
|------|----------------------------|--------------------------------|
|
||||
| 0 | Success | Operation completed |
|
||||
| 1 | Generic error | Unexpected failure |
|
||||
| 2 | Usage/config error | Invalid arguments or config |
|
||||
| 3 | Network error | Connection failed, timeout |
|
||||
| 4 | Auth error | 401/403 from API |
|
||||
| 5 | Not found | 404 from API |
|
||||
| 6 | Validation error | 400/422, bad input |
|
||||
| 7 | Integrity error | Hash/signature mismatch |
|
||||
| 8 | Conflict | 409, state conflict |
|
||||
| 9 | Cancelled | User interrupted (Ctrl+C) |
|
||||
|
||||
## V1 Scope (Current Target)
|
||||
|
||||
**Included**:
|
||||
- `qqq new` from local and HTTPS templates
|
||||
- `qbit` search/add/list/update/remove with lockfile
|
||||
- `qrun package` (Jib) and `publish` to registry
|
||||
- `qstudio plan` (offline, writes plan.md)
|
||||
- Config loading, validation, profiles
|
||||
- Native builds for macOS/Linux; jlink for Windows
|
||||
|
||||
**Deferred (Post-V1)**:
|
||||
- Sigstore enforcement (currently warn-only)
|
||||
- Plugin system
|
||||
- Self-update mechanism
|
||||
- `qstudio` diff apply (AI code changes)
|
||||
- Streaming logs
|
||||
- Promote/rollback workflows
|
||||
|
||||
## Template Structure (new-qqq-application)
|
||||
|
||||
```
|
||||
templates/new-qqq-application/
|
||||
├── template.yaml # Manifest with prompts, computed vars, transforms
|
||||
├── template/ # Template files directory
|
||||
│ ├── README.md # Velocity template with $variables
|
||||
│ ├── pom.xml # Maven POM with $groupId, $artifactId, etc.
|
||||
│ └── src/
|
||||
│ └── main/
|
||||
│ └── java/
|
||||
│ └── $packagePath/ # Path variable substitution
|
||||
│ └── Application.java # Uses $packageName, $projectName
|
||||
```
|
||||
|
||||
**template.yaml Schema v2**:
|
||||
```yaml
|
||||
schemaVersion: 2
|
||||
id: new-qqq-application
|
||||
name: New QQQ Application
|
||||
version: 1.0.0
|
||||
prompts:
|
||||
- name: packageName
|
||||
message: Java package name
|
||||
type: text
|
||||
defaultValue: com.example
|
||||
required: true
|
||||
computed:
|
||||
- name: packagePath
|
||||
expression: "$str.replace($packageName, '.', '/')"
|
||||
transforms:
|
||||
- type: delete
|
||||
pattern: "**/.gitkeep"
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `qctl.yaml` - Project and global configuration
|
||||
- `qbits.lock` - Locked dependency versions (JSON)
|
||||
- `vendor/qbits/` - Vendored qBit packages
|
||||
- `.qctl/` - Project-local qctl state
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Language**: Java 21 (Temurin/GraalVM)
|
||||
- **CLI**: Picocli 4.7.5
|
||||
- **Config**: Jackson YAML + JSON Schema validation
|
||||
- **HTTP**: JDK HttpClient with retry/backoff
|
||||
- **Templates**: Apache Velocity 2.4.1
|
||||
- **Build**: Maven 3.9+, GraalVM native-image
|
||||
- **Test**: JUnit 5, AssertJ, Mockito, Testcontainers
|
||||
|
||||
## Testing Architecture
|
||||
|
||||
```
|
||||
qctl-integration-tests/
|
||||
├── src/test/java/io/qrun/qctl/e2e/
|
||||
│ ├── InitCommandE2ETest.java # CLI flag tests
|
||||
│ ├── InitCommandFullE2ETest.java # Full variable/transform tests
|
||||
│ └── harness/
|
||||
│ ├── CommandTestHarness.java # In-process command execution
|
||||
│ ├── TestTemplateRegistry.java # Fixture-based registry
|
||||
│ └── TestableConsoleUI.java # Simulated user input
|
||||
└── src/test/resources/fixtures/templates/
|
||||
├── test-minimal/ # Basic template
|
||||
├── test-computed-vars/ # Computed variable tests
|
||||
├── test-full-substitution/ # Variable replacement tests
|
||||
└── test-transforms/ # Transform operation tests
|
||||
```
|
||||
|
||||
**Path Variable Convention**: Use `__VARNAME__` in directory names for templates. The engine converts this to `$VARNAME` before Velocity processing, avoiding shell/Maven interpretation issues.
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,242 @@
|
||||
# Session State
|
||||
|
||||
Last updated: 2026-01-03
|
||||
|
||||
## Current Status
|
||||
|
||||
**Stories #18-25 complete.** All qqq template system stories finished except #26 (merge mode). Ready for Story #26 or to start Epic #11 (qbit package management).
|
||||
|
||||
## Recently Completed
|
||||
|
||||
### Story #25 - Improve Error Messages (2026-01-04)
|
||||
|
||||
Implemented structured error handling with recovery suggestions:
|
||||
- Created `SuggestionEngine` with Levenshtein distance for fuzzy matching
|
||||
- Created `TemplateError` with categories mapped to exit codes
|
||||
- Created `ErrorFormatter` for consistent ANSI-colored output
|
||||
- Added "Did you mean?" suggestions for mistyped template names
|
||||
- Added `--verbose` flag to show stack traces
|
||||
- Added 27 unit tests (13 SuggestionEngine, 6 TemplateError, 8 ErrorFormatter)
|
||||
- Updated InitCommand and ListCommand to use structured errors
|
||||
|
||||
### Story #24 - Add Prompt Validation (2026-01-03)
|
||||
|
||||
Implemented input validation with retry loop for template prompts:
|
||||
- Created `PromptValidation` record for validation rules (pattern, length, range, enum)
|
||||
- Created `PromptValidator` class with support for required, pattern, length, range, and enum validation
|
||||
- Updated `PromptRunner` to validate input and re-prompt on failure
|
||||
- Added validation for `--var` CLI overrides with fallback to interactive prompt
|
||||
- Added `ConsoleUI` methods for validation feedback (error checkmark, success checkmark, warning)
|
||||
- Added 20 unit tests for PromptValidator
|
||||
|
||||
Manifest schema now supports:
|
||||
```yaml
|
||||
prompts:
|
||||
- name: packageName
|
||||
message: "Java package"
|
||||
required: true
|
||||
validation:
|
||||
pattern: "^[a-z][a-z0-9]*(\\.[a-z][a-z0-9]*)*$"
|
||||
message: "Must be a valid Java package name"
|
||||
```
|
||||
|
||||
### Story #23 - Voyage Mock API Integration (2026-01-03)
|
||||
|
||||
Implemented TemplateRegistry abstraction for template management:
|
||||
- Created `TemplateRegistry` interface with list/get/download/refresh methods
|
||||
- Created `TemplateInfo` record for template metadata
|
||||
- Created `MockTemplateRegistry` with embedded template data
|
||||
- Created `GitHubTemplateRegistry` wrapping existing TemplatesHub
|
||||
- Created `TemplateRegistryFactory` for registry selection via env var
|
||||
- Refactored `ListCommand` to use TemplateRegistry with `--refresh` flag
|
||||
- Refactored `InitCommand` to use TemplateRegistry
|
||||
- Added 13 unit tests for registry classes
|
||||
- Added native-image reflection config for TemplateInfo
|
||||
|
||||
### Story #22 - Migrate templates-hub to Velocity (2026-01-03)
|
||||
|
||||
Completed migration of new-qqq-application-template:
|
||||
- Restructured template repo with `template/` subdirectory
|
||||
- Converted all Java files to Velocity syntax (`$variable`, `${variable}`)
|
||||
- Fixed pom.xml with literal blocks (`#[[${maven.prop}]]#`) for Maven properties
|
||||
- Added native-image reflection config for Velocity runtime classes
|
||||
- Implemented verbatim file handling for shell scripts (mvnw, gradlew)
|
||||
- Added Maven property detection to skip validation for `${prop.name}` patterns
|
||||
- All changes pushed to both template repo and templates-hub
|
||||
|
||||
### Story #21 - Template Manifest Schema v2 (2026-01-03)
|
||||
|
||||
Updated manifest schema to v2 with backward compatibility:
|
||||
- Added `schemaVersion` field (Integer) to TemplateManifest and TemplateEntry
|
||||
- Added `minimumQctlVersion` field with version check in InitCommand
|
||||
- Added `required` field (Boolean) to Prompt record
|
||||
- Added `phase` field (String) to PostGenHook record
|
||||
- Added `getEffectiveSchemaVersion()` helper method (defaults to 1)
|
||||
- Added `isVersionSatisfied()` and `parseVersion()` helpers in InitCommand
|
||||
- Updated TemplateEngineTest constructor calls (9 → 11 params)
|
||||
|
||||
Simplified scope (per user agreement):
|
||||
- Deferred `prompts[].validation` to Story #24
|
||||
- Deferred conditional fields to future story
|
||||
|
||||
### Story #20 - Directory Transforms (2026-01-03)
|
||||
|
||||
Implemented post-render file transforms for template processing:
|
||||
- Created `Transform.java` record with type, pattern, replacement fields
|
||||
- Created `TransformExecutor.java` for rename/delete operations using glob patterns
|
||||
- Added `transforms` field to `TemplateManifest` and `TemplatesHub.TemplateEntry`
|
||||
- Integrated transform execution in `InitCommand.java` after rendering
|
||||
- Added 13 tests for TransformExecutor
|
||||
|
||||
Simplified scope (per user agreement):
|
||||
- Only `rename` and `delete` transforms (dropped `move` as redundant with path variables)
|
||||
- Glob pattern matching with `${varName}` substitution in replacement strings
|
||||
|
||||
Manifest schema now supports:
|
||||
```yaml
|
||||
transforms:
|
||||
- type: rename
|
||||
pattern: "**/*Template*.java"
|
||||
replacement: "${appClassName}.java"
|
||||
- type: delete
|
||||
pattern: "**/.gitkeep"
|
||||
```
|
||||
|
||||
### Story #19 - Computed Variables (2026-01-04)
|
||||
|
||||
Implemented computed variable support for template manifests:
|
||||
- Added `replace()` method to `StringTool.java` for string substitution
|
||||
- Created `ComputedVariable.java` record for manifest definitions
|
||||
- Added `computed` field to `TemplateManifest` and `TemplatesHub.TemplateEntry`
|
||||
- Added `evaluateComputed()` method to `TemplateEngine.java`
|
||||
- Updated `InitCommand.java` to evaluate computed vars after prompts
|
||||
- Added 16 new tests (9 ComputedVariable + 7 StringTool.replace)
|
||||
|
||||
Manifest schema now supports:
|
||||
```yaml
|
||||
computed:
|
||||
- name: packagePath
|
||||
expression: "$str.replace($packageName, '.', '/')"
|
||||
- name: appClassName
|
||||
expression: "$str.pascal($appName)"
|
||||
```
|
||||
|
||||
### Story #18 - Velocity Engine (2026-01-04)
|
||||
|
||||
Implemented Apache Velocity template engine replacing Handlebars:
|
||||
- Created `StringTool.java` with transformation methods ($str.camel(), etc.)
|
||||
- Created `TemplateRenderException.java` for fail-fast error handling
|
||||
- Refactored `TemplateEngine.java` to use VelocityEngine
|
||||
- Added 51 unit tests (15 new + 36 existing)
|
||||
- Updated native-image reflect-config.json for Velocity classes
|
||||
|
||||
### Epic #13-17 Story Details (2026-01-04)
|
||||
|
||||
Fleshed out all remaining stories for Epics #13-17:
|
||||
- **Epic #13** (qrun Build & Publish): #36-42 - Jib integration, OCI packaging, publishing, labels, digest verification, release channels, rollback
|
||||
- **Epic #14** (qrun Service Operations): #43-49 - Status, logs (SSE streaming), stop, start, restart, deployment history, scale
|
||||
- **Epic #15** (qstudio AI Planning): #50-53 - Code indexing, plan generation (multi-LLM), path policies, plan ledger
|
||||
- **Epic #16** (Authentication & Security): #54-57 - OIDC device flow, service accounts, signature verification (Sigstore/PGP), OS keychain integration
|
||||
- **Epic #17** (Enterprise Features): #58-61 - SSO (SAML/OIDC), RBAC, audit logging, org/team management
|
||||
|
||||
### Epic #12 Story Details (2026-01-04)
|
||||
|
||||
Fleshed out all 9 stories for Epic #12 (qqq - Template System) with:
|
||||
- User stories and design specs
|
||||
- Architecture diagrams (current vs target state)
|
||||
- Files to create/modify tables
|
||||
- Implementation task checklists
|
||||
- Acceptance criteria
|
||||
|
||||
Stories updated: #18-26 (Velocity, computed vars, transforms, manifest v2, templates migration, Voyage mock, validation, errors, merge mode)
|
||||
|
||||
### Epic #11 Story Details (2026-01-04)
|
||||
|
||||
Fleshed out all 9 stories for Epic #11 (qbit - Package Management) with:
|
||||
- User stories and design specs
|
||||
- Command interfaces with examples
|
||||
- Files to create/modify tables
|
||||
- Implementation task checklists
|
||||
- Acceptance criteria
|
||||
|
||||
Stories updated: #27-35 (search, add, remove, update, list, SemVer, conflicts, vendor, integrity)
|
||||
|
||||
### Roadmap & Issue Creation (2026-01-03)
|
||||
|
||||
Created comprehensive roadmap and GitHub project structure:
|
||||
|
||||
1. **Roadmap document** - `docs/ROADMAP.md` with user journey focus
|
||||
2. **Milestones** - v0.2.0, v0.3.0, v1.0.0
|
||||
3. **Epics** - 7 epic issues (#11-#17) with proper labels
|
||||
4. **Stories** - 44 story issues (#18-#61) linked as sub-issues
|
||||
5. **Issue types** - Epic for #11-17, Feature for #18-61
|
||||
6. **Sub-issues** - All stories linked to parent epics
|
||||
|
||||
### Epic Summary
|
||||
|
||||
| Epic | Milestone | Stories |
|
||||
|------|-----------|---------|
|
||||
| #11 qbit - Package Management | v0.2.0 | #27-35 (9) |
|
||||
| #12 qqq - Template System | v0.2.0 | #18-26 (9) |
|
||||
| #13 qrun - Build & Publish | v0.3.0 | #36-42 (7) |
|
||||
| #14 qrun - Service Operations | v0.3.0 | #43-49 (7) |
|
||||
| #15 qstudio - AI Planning | v1.0.0 | #50-53 (4) |
|
||||
| #16 Authentication & Security | v1.0.0 | #54-57 (4) |
|
||||
| #17 Enterprise Features | v1.0.0 | #58-61 (4) |
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
- **Template engine**: Apache Velocity (not Handlebars) for QQQ consistency
|
||||
- **Voyage integration**: Mock APIs for now, real integration when ready
|
||||
- **User journey focus**: Roadmap organized by concrete use cases
|
||||
|
||||
## Current Branch State
|
||||
|
||||
- **Branch**: `develop`
|
||||
- **Clean**: Yes (no uncommitted changes)
|
||||
|
||||
## Next Steps / TODO
|
||||
|
||||
### v0.2.0 - Create & Manage (Priority)
|
||||
|
||||
**Epic #12 - qqq Template System** (in progress):
|
||||
1. ~~#18 - Replace Handlebars with Apache Velocity~~ DONE
|
||||
2. ~~#19 - Add computed variables support~~ DONE
|
||||
3. ~~#20 - Implement directory transforms~~ DONE
|
||||
4. ~~#21 - Update template manifest schema v2~~ DONE
|
||||
5. ~~#22 - Migrate templates-hub to Velocity~~ DONE
|
||||
6. ~~#23 - Integrate template listing with Voyage mock~~ DONE
|
||||
7. ~~#24 - Add prompt validation~~ DONE
|
||||
8. ~~#25 - Improve error messages~~ DONE
|
||||
9. #26 - Implement --merge mode (next)
|
||||
|
||||
**Epic #11 - qbit Package Management** (after qqq):
|
||||
1. #27 - qbit search command
|
||||
2. #28 - qbit add with version constraints
|
||||
3. #29 - qbit remove command
|
||||
4. #30 - qbit update command
|
||||
5. #31 - qbit list command
|
||||
6. #32 - SemVer constraint resolution
|
||||
7. #33 - Dependency conflict detection
|
||||
8. #34 - Vendor directory layout
|
||||
9. #35 - SHA-512 integrity verification
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- GitHub project: https://github.com/orgs/QRun-IO/projects/12
|
||||
|
||||
## How to Continue
|
||||
|
||||
When starting a new session, say "continue from last session" and Claude will:
|
||||
1. Read this file for context
|
||||
2. Check git status for any pending work
|
||||
3. Review GitHub project for current sprint
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `CLAUDE.md` | Project overview and build commands |
|
||||
| `docs/ROADMAP.md` | User journey roadmap |
|
||||
| `docs/internal/session-state.md` | This file - session continuity |
|
||||
| `.github/workflows/build.yml` | CI/CD pipeline |
|
||||
@@ -0,0 +1,55 @@
|
||||
# Testing Guide
|
||||
|
||||
qctl uses JUnit 5 with comprehensive E2E tests that verify actual file content, variable substitution, and generated project validity.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
mvn verify
|
||||
|
||||
# Integration tests only
|
||||
mvn test -pl qctl-integration-tests
|
||||
|
||||
# Specific test class
|
||||
mvn test -pl qctl-integration-tests -Dtest=InitCommandFullE2ETest
|
||||
```
|
||||
|
||||
## Test Fixtures
|
||||
|
||||
Fixtures in `qctl-integration-tests/src/test/resources/fixtures/templates/`:
|
||||
|
||||
| Fixture | Purpose |
|
||||
|---------|---------|
|
||||
| `test-minimal` | Basic template with no variables |
|
||||
| `test-computed-vars` | Computed variable evaluation (`packagePath` from `packageName`) |
|
||||
| `test-full-substitution` | Variable replacement in content and paths |
|
||||
| `test-transforms` | Delete and rename transforms |
|
||||
| `test-with-prompts` | Interactive prompt handling |
|
||||
| `test-variable-typo` | "Did you mean?" error suggestions |
|
||||
| `test-transform-error` | Error message quality |
|
||||
|
||||
## Path Variable Convention
|
||||
|
||||
Use `__VARNAME__` syntax in directory names (e.g., `__packagePath__`). The template engine converts this to `$VARNAME` before Velocity processing, avoiding shell/Maven interpretation issues on CI.
|
||||
|
||||
```
|
||||
template/src/main/java/__packagePath__/App.java
|
||||
↓ converted to
|
||||
template/src/main/java/$packagePath/App.java
|
||||
↓ Velocity renders
|
||||
src/main/java/com/example/app/App.java
|
||||
```
|
||||
|
||||
## Test Harness
|
||||
|
||||
- **CommandTestHarness**: Executes commands in-process, captures stdout/stderr/exit code
|
||||
- **TestTemplateRegistry**: Provides fixtures without network access
|
||||
- **TestableConsoleUI**: Simulates user input for interactive prompts
|
||||
|
||||
## Adding a Fixture
|
||||
|
||||
1. Create directory under `fixtures/templates/<name>/`
|
||||
2. Add `template.yaml` manifest (schema v2)
|
||||
3. Add `template/` directory with files
|
||||
4. Register in `TestTemplateRegistry.java`
|
||||
@@ -0,0 +1,16 @@
|
||||
# Maintainer: QRun-IO <contact@qrun.io>
|
||||
pkgname=qctl-bin
|
||||
pkgver=0.0.2
|
||||
pkgrel=1
|
||||
pkgdesc="CLI for the QRun ecosystem - scaffold, package, and deploy QQQ apps"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/QRun-IO/qctl"
|
||||
license=('AGPL-3.0')
|
||||
provides=('qctl')
|
||||
conflicts=('qctl')
|
||||
source_x86_64=("https://github.com/QRun-IO/qctl/releases/download/v${pkgver}/qctl-linux-amd64")
|
||||
sha256sums_x86_64=('374f0f10a8aa781e1b5b8d72483ec257dfee9794b69419c7700f55ad7fbcf4b3')
|
||||
|
||||
package() {
|
||||
install -Dm755 "${srcdir}/qctl-linux-amd64" "${pkgdir}/usr/bin/qctl"
|
||||
}
|
||||
@@ -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.0.2",
|
||||
"description": "CLI for the QRun ecosystem - scaffold, package, and deploy QQQ apps",
|
||||
"homepage": "https://github.com/QRun-IO/qctl",
|
||||
"license": "AGPL-3.0",
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/QRun-IO/qctl/releases/download/v0.0.2/qctl-windows-amd64.exe",
|
||||
"hash": "358aee9b7655afe8f86342ecdbebe150bdda9a1868d1848718876a18faab6f5e"
|
||||
}
|
||||
},
|
||||
"bin": [["qctl-windows-amd64.exe", "qctl"]],
|
||||
"checkver": {
|
||||
"github": "https://github.com/QRun-IO/qctl"
|
||||
},
|
||||
"autoupdate": {
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/QRun-IO/qctl/releases/download/v0.0.2/qctl-windows-amd64.exe"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>io.qrun.qctl</groupId>
|
||||
<artifactId>qctl-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<version>${revision}</version>
|
||||
<packaging>pom</packaging>
|
||||
<name>qctl</name>
|
||||
<description>qctl CLI - multi-module project</description>
|
||||
@@ -22,24 +22,26 @@
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<revision>0.2.1</revision>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<picocli.version>4.7.5</picocli.version>
|
||||
<jackson.version>2.17.1</jackson.version>
|
||||
<logback.version>1.5.13</logback.version>
|
||||
<slf4j.version>2.0.13</slf4j.version>
|
||||
<networknt.json.schema.version>1.4.1</networknt.json.schema.version>
|
||||
<handlebars.version>4.4.0</handlebars.version>
|
||||
<bouncycastle.version>1.78.1</bouncycastle.version>
|
||||
<junit.jupiter.version>5.10.2</junit.jupiter.version>
|
||||
<assertj.version>3.25.3</assertj.version>
|
||||
<mockito.version>5.12.0</mockito.version>
|
||||
<testcontainers.version>1.19.7</testcontainers.version>
|
||||
<spotless.version>2.43.0</spotless.version>
|
||||
<checkstyle.version>10.17.0</checkstyle.version>
|
||||
<errorprone.version>2.28.0</errorprone.version>
|
||||
<native.maven.plugin.version>0.10.4</native.maven.plugin.version>
|
||||
<picocli.version>4.7.6</picocli.version>
|
||||
<jackson.version>2.18.2</jackson.version>
|
||||
<logback.version>1.5.15</logback.version>
|
||||
<slf4j.version>2.0.16</slf4j.version>
|
||||
<networknt.json.schema.version>1.5.4</networknt.json.schema.version>
|
||||
<velocity.version>2.4.1</velocity.version>
|
||||
<bouncycastle.version>1.79</bouncycastle.version>
|
||||
<junit.jupiter.version>5.11.4</junit.jupiter.version>
|
||||
<assertj.version>3.27.2</assertj.version>
|
||||
<mockito.version>5.15.2</mockito.version>
|
||||
<testcontainers.version>1.20.4</testcontainers.version>
|
||||
<spotless.version>2.44.0</spotless.version>
|
||||
<checkstyle.version>10.21.1</checkstyle.version>
|
||||
<errorprone.version>2.36.0</errorprone.version>
|
||||
<native.maven.plugin.version>0.10.5</native.maven.plugin.version>
|
||||
<git.commit.id.plugin.version>9.0.1</git.commit.id.plugin.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
@@ -64,10 +66,15 @@
|
||||
<build>
|
||||
<pluginManagement>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>flatten-maven-plugin</artifactId>
|
||||
<version>1.6.0</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<release>${maven.compiler.release}</release>
|
||||
<showWarnings>true</showWarnings>
|
||||
@@ -77,7 +84,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.5</version>
|
||||
<version>3.5.2</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
@@ -95,12 +102,54 @@
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.1.1</version>
|
||||
<version>3.5.0</version>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>flatten-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<updatePomFile>true</updatePomFile>
|
||||
<flattenMode>resolveCiFriendliesOnly</flattenMode>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>flatten</id>
|
||||
<phase>process-resources</phase>
|
||||
<goals>
|
||||
<goal>flatten</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>flatten.clean</id>
|
||||
<phase>clean</phase>
|
||||
<goals>
|
||||
<goal>clean</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>io.github.git-commit-id</groupId>
|
||||
<artifactId>git-commit-id-maven-plugin</artifactId>
|
||||
<version>${git.commit.id.plugin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>get-git-info</id>
|
||||
<phase>initialize</phase>
|
||||
<goals>
|
||||
<goal>revision</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<failOnNoGitDirectory>false</failOnNoGitDirectory>
|
||||
<generateGitPropertiesFile>false</generateGitPropertiesFile>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>com.diffplug.spotless</groupId>
|
||||
<artifactId>spotless-maven-plugin</artifactId>
|
||||
@@ -148,7 +197,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.3.1</version>
|
||||
<version>3.6.0</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
|
||||
+41
-36
@@ -6,7 +6,7 @@
|
||||
<parent>
|
||||
<groupId>io.qrun.qctl</groupId>
|
||||
<artifactId>qctl-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
<artifactId>qctl-cli</artifactId>
|
||||
<name>qctl-cli</name>
|
||||
@@ -37,39 +37,44 @@
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.graalvm.buildtools</groupId>
|
||||
<artifactId>native-maven-plugin</artifactId>
|
||||
<version>${native.maven.plugin.version}</version>
|
||||
<extensions>true</extensions>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>build-native</id>
|
||||
<goals>
|
||||
<goal>compile-no-fork</goal>
|
||||
</goals>
|
||||
<phase>package</phase>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<mainClass>io.qrun.qctl.core.Main</mainClass>
|
||||
<imageName>qctl</imageName>
|
||||
<useArgFile>false</useArgFile>
|
||||
<buildArgs>
|
||||
<buildArg>--no-fallback</buildArg>
|
||||
<buildArg>--enable-url-protocols=http,https</buildArg>
|
||||
<buildArg>-H:+ReportExceptionStackTraces</buildArg>
|
||||
<buildArg>-H:IncludeResources=.*\.properties$</buildArg>
|
||||
<buildArg>-H:IncludeResources=.*\.xml$</buildArg>
|
||||
<buildArg>-H:IncludeResources=.*\.json$</buildArg>
|
||||
<buildArg>-H:IncludeResources=.*\.yaml$</buildArg>
|
||||
<buildArg>-H:IncludeResources=.*\.yml$</buildArg>
|
||||
<buildArg>-H:IncludeResources=META-INF/services/.*</buildArg>
|
||||
</buildArgs>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>native</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.graalvm.buildtools</groupId>
|
||||
<artifactId>native-maven-plugin</artifactId>
|
||||
<version>${native.maven.plugin.version}</version>
|
||||
<extensions>true</extensions>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>build-native</id>
|
||||
<goals>
|
||||
<goal>compile-no-fork</goal>
|
||||
</goals>
|
||||
<phase>package</phase>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<mainClass>io.qrun.qctl.core.Main</mainClass>
|
||||
<imageName>qctl</imageName>
|
||||
<useArgFile>false</useArgFile>
|
||||
<buildArgs>
|
||||
<buildArg>--no-fallback</buildArg>
|
||||
<buildArg>--enable-url-protocols=http,https</buildArg>
|
||||
<buildArg>-H:+ReportExceptionStackTraces</buildArg>
|
||||
<buildArg>-H:IncludeResources=.*\.properties$</buildArg>
|
||||
<buildArg>-H:IncludeResources=.*\.xml$</buildArg>
|
||||
<buildArg>-H:IncludeResources=.*\.json$</buildArg>
|
||||
<buildArg>-H:IncludeResources=.*\.yaml$</buildArg>
|
||||
<buildArg>-H:IncludeResources=.*\.yml$</buildArg>
|
||||
<buildArg>-H:IncludeResources=META-INF/services/.*</buildArg>
|
||||
</buildArgs>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
+17
-51
@@ -6,7 +6,7 @@
|
||||
<parent>
|
||||
<groupId>io.qrun.qctl</groupId>
|
||||
<artifactId>qctl-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
<artifactId>qctl-core</artifactId>
|
||||
<name>qctl-core</name>
|
||||
@@ -75,6 +75,22 @@
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>version.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>version.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
|
||||
<plugin>
|
||||
@@ -117,54 +133,4 @@
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>native</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.graalvm.buildtools</groupId>
|
||||
<artifactId>native-maven-plugin</artifactId>
|
||||
<version>${native.maven.plugin.version}</version>
|
||||
<extensions>true</extensions>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>build-native</id>
|
||||
<goals>
|
||||
<goal>compile-no-fork</goal>
|
||||
</goals>
|
||||
<phase>package</phase>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<skip>false</skip>
|
||||
<mainClass>io.qrun.qctl.core.Main</mainClass>
|
||||
<imageName>qctl</imageName>
|
||||
<useArgFile>false</useArgFile>
|
||||
<agent>
|
||||
<enabled>true</enabled>
|
||||
<defaultMode>Standard</defaultMode>
|
||||
</agent>
|
||||
<buildArgs>
|
||||
<buildArg>--no-fallback</buildArg>
|
||||
<buildArg>--enable-http</buildArg>
|
||||
<buildArg>--enable-https</buildArg>
|
||||
<buildArg>--enable-url-protocols=http,https</buildArg>
|
||||
<buildArg>--initialize-at-build-time=org.slf4j</buildArg>
|
||||
<buildArg>-H:+ReportExceptionStackTraces</buildArg>
|
||||
<buildArg>-H:+UnlockExperimentalVMOptions</buildArg>
|
||||
<buildArg>-H:IncludeResources=.*\.properties$</buildArg>
|
||||
<buildArg>-H:IncludeResources=.*\.xml$</buildArg>
|
||||
<buildArg>-H:IncludeResources=.*\.json$</buildArg>
|
||||
<buildArg>-H:IncludeResources=.*\.yaml$</buildArg>
|
||||
<buildArg>-H:IncludeResources=.*\.yml$</buildArg>
|
||||
<buildArg>-H:IncludeResources=META-INF/services/.*</buildArg>
|
||||
<buildArg>-H:ReflectionConfigurationFiles=${project.basedir}/src/main/resources/META-INF/native-image/reflect-config.json</buildArg>
|
||||
</buildArgs>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -13,6 +13,7 @@
|
||||
package io.qrun.qctl.core;
|
||||
|
||||
|
||||
import io.qrun.qctl.shared.ExitCodes;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import picocli.CommandLine;
|
||||
@@ -33,12 +34,13 @@ import picocli.CommandLine.Spec;
|
||||
@Command(
|
||||
name = "qctl",
|
||||
mixinStandardHelpOptions = true,
|
||||
version = { "qctl 0.1.0" },
|
||||
versionProvider = VersionProvider.class,
|
||||
description = "qctl CLI",
|
||||
subcommands = {
|
||||
HelpCommand.class,
|
||||
io.qrun.qctl.core.cli.AuthCommand.class,
|
||||
io.qrun.qctl.core.cli.CacheCommand.class
|
||||
io.qrun.qctl.core.cli.CacheCommand.class,
|
||||
io.qrun.qctl.core.cli.DoctorCommand.class
|
||||
})
|
||||
public class Main implements Runnable
|
||||
{
|
||||
@@ -132,7 +134,7 @@ public class Main implements Runnable
|
||||
catch(Exception e)
|
||||
{
|
||||
log.error("Startup failed", e);
|
||||
System.exit(2);
|
||||
System.exit(ExitCodes.USAGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.core;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Properties;
|
||||
import picocli.CommandLine.IVersionProvider;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Provides version information for the CLI from build-time properties.
|
||||
*
|
||||
* Reads version.properties populated by Maven resource filtering.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*******************************************************************************/
|
||||
public class VersionProvider implements IVersionProvider
|
||||
{
|
||||
private static final String VERSION_PROPERTIES = "/version.properties";
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Returns version strings for display by picocli --version flag.
|
||||
*
|
||||
* @return array of version strings
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String[] getVersion()
|
||||
{
|
||||
Properties props = new Properties();
|
||||
try(InputStream is = getClass().getResourceAsStream(VERSION_PROPERTIES))
|
||||
{
|
||||
if(is != null)
|
||||
{
|
||||
props.load(is);
|
||||
}
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
// Fall back to defaults
|
||||
}
|
||||
|
||||
String version = props.getProperty("version", "unknown");
|
||||
String buildTime = props.getProperty("build.time", "");
|
||||
String gitCommit = props.getProperty("git.commit", "");
|
||||
|
||||
if(!gitCommit.isEmpty())
|
||||
{
|
||||
return new String[] {
|
||||
"qctl " + version,
|
||||
"Build: " + buildTime,
|
||||
"Commit: " + gitCommit
|
||||
};
|
||||
}
|
||||
else if(!buildTime.isEmpty())
|
||||
{
|
||||
return new String[] {
|
||||
"qctl " + version,
|
||||
"Build: " + buildTime
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return new String[] { "qctl " + version };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.core.cli;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import io.qrun.qctl.core.doctor.DependencyChecker;
|
||||
import io.qrun.qctl.core.doctor.DependencyStatus;
|
||||
import io.qrun.qctl.shared.ExitCodes;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Check system dependencies and report status.
|
||||
*
|
||||
* Verifies that required tools (Java, Maven, Git) are installed and meet
|
||||
* version requirements. Reports optional tools (Docker) as well.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
@Command(
|
||||
name = "doctor",
|
||||
description = "Check system dependencies and configuration"
|
||||
)
|
||||
public class DoctorCommand implements Runnable
|
||||
{
|
||||
private static final String ANSI_RESET = "\u001B[0m";
|
||||
private static final String ANSI_GREEN = "\u001B[32m";
|
||||
private static final String ANSI_RED = "\u001B[31m";
|
||||
private static final String ANSI_YELLOW = "\u001B[33m";
|
||||
private static final String ANSI_BOLD = "\u001B[1m";
|
||||
private static final String ANSI_DIM = "\u001B[2m";
|
||||
|
||||
private static final String CHECK_MARK = "✓";
|
||||
private static final String CROSS_MARK = "✗";
|
||||
private static final String WARN_MARK = "!";
|
||||
|
||||
@Option(names = {"-v", "--verbose"}, description = "Show detailed information")
|
||||
private boolean verbose;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Run the doctor command.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
System.out.println();
|
||||
System.out.println(ANSI_BOLD + "qctl doctor" + ANSI_RESET);
|
||||
System.out.println(ANSI_DIM + "Checking system dependencies..." + ANSI_RESET);
|
||||
System.out.println();
|
||||
|
||||
List<DependencyStatus> results = DependencyChecker.checkAll();
|
||||
|
||||
int requiredMissing = 0;
|
||||
int optionalMissing = 0;
|
||||
|
||||
for(DependencyStatus status : results)
|
||||
{
|
||||
printStatus(status);
|
||||
|
||||
if(!status.isOk() && !status.isOptional())
|
||||
{
|
||||
requiredMissing++;
|
||||
}
|
||||
else if(status.isOptional())
|
||||
{
|
||||
optionalMissing++;
|
||||
}
|
||||
}
|
||||
|
||||
System.out.println();
|
||||
|
||||
if(requiredMissing > 0)
|
||||
{
|
||||
System.out.println(ANSI_RED + requiredMissing + " required "
|
||||
+ (requiredMissing == 1 ? "dependency" : "dependencies")
|
||||
+ " missing." + ANSI_RESET);
|
||||
System.out.println("Run " + ANSI_BOLD + "qctl doctor -v" + ANSI_RESET + " for installation hints.");
|
||||
System.exit(ExitCodes.VALIDATION);
|
||||
}
|
||||
else if(optionalMissing > 0)
|
||||
{
|
||||
System.out.println(ANSI_YELLOW + optionalMissing + " optional "
|
||||
+ (optionalMissing == 1 ? "dependency" : "dependencies")
|
||||
+ " missing." + ANSI_RESET);
|
||||
if(verbose)
|
||||
{
|
||||
System.out.println("Optional dependencies enable additional features but are not required.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
System.out.println(ANSI_GREEN + "All dependencies satisfied!" + ANSI_RESET);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Print a single dependency status line.
|
||||
*
|
||||
* @param status dependency status to print
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private void printStatus(DependencyStatus status)
|
||||
{
|
||||
String icon;
|
||||
String color;
|
||||
|
||||
switch(status.status())
|
||||
{
|
||||
case OK:
|
||||
icon = ANSI_GREEN + CHECK_MARK + ANSI_RESET;
|
||||
color = ANSI_RESET;
|
||||
break;
|
||||
case NOT_FOUND:
|
||||
case VERSION_MISMATCH:
|
||||
case ERROR:
|
||||
icon = ANSI_RED + CROSS_MARK + ANSI_RESET;
|
||||
color = ANSI_RED;
|
||||
break;
|
||||
case OPTIONAL_MISSING:
|
||||
icon = ANSI_YELLOW + WARN_MARK + ANSI_RESET;
|
||||
color = ANSI_YELLOW;
|
||||
break;
|
||||
default:
|
||||
icon = "?";
|
||||
color = ANSI_RESET;
|
||||
}
|
||||
|
||||
StringBuilder line = new StringBuilder();
|
||||
line.append(" ").append(icon).append(" ");
|
||||
|
||||
if(status.isOk())
|
||||
{
|
||||
line.append(ANSI_BOLD).append(status.name()).append(ANSI_RESET);
|
||||
line.append(" ").append(status.version());
|
||||
if(status.path() != null && verbose)
|
||||
{
|
||||
line.append(ANSI_DIM).append(" ").append(status.path()).append(ANSI_RESET);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
line.append(color).append(ANSI_BOLD).append(status.name()).append(ANSI_RESET);
|
||||
line.append(color).append(" ").append(status.getMessage()).append(ANSI_RESET);
|
||||
}
|
||||
|
||||
System.out.println(line);
|
||||
|
||||
if(verbose && status.hint() != null && !status.isOk())
|
||||
{
|
||||
System.out.println(ANSI_DIM + " " + status.hint() + ANSI_RESET);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ package io.qrun.qctl.core.cli.auth;
|
||||
|
||||
import io.qrun.qctl.core.auth.TokenStore;
|
||||
import io.qrun.qctl.core.sys.SystemPaths;
|
||||
import io.qrun.qctl.shared.ExitCodes;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
|
||||
@@ -43,7 +44,7 @@ public class LoginCommand implements Runnable
|
||||
catch(Exception e)
|
||||
{
|
||||
System.err.println("auth login error: " + e.getMessage());
|
||||
System.exit(1);
|
||||
System.exit(ExitCodes.AUTH);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ package io.qrun.qctl.core.cli.auth;
|
||||
|
||||
import io.qrun.qctl.core.auth.TokenStore;
|
||||
import io.qrun.qctl.core.sys.SystemPaths;
|
||||
import io.qrun.qctl.shared.ExitCodes;
|
||||
import picocli.CommandLine.Command;
|
||||
|
||||
|
||||
@@ -32,7 +33,7 @@ public class LogoutCommand implements Runnable
|
||||
catch(Exception e)
|
||||
{
|
||||
System.err.println("auth logout error: " + e.getMessage());
|
||||
System.exit(1);
|
||||
System.exit(ExitCodes.AUTH);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,31 +17,18 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
|
||||
/**
|
||||
/*******************************************************************************
|
||||
* Simple deep-merge utility for JSON objects.
|
||||
*/
|
||||
*
|
||||
* Why: Config loading overlays several sources (defaults, files, env),
|
||||
* and we need deterministic, recursive object merging semantics.
|
||||
* @since 0.1.0
|
||||
*******************************************************************************/
|
||||
final class JsonMerge
|
||||
{
|
||||
/***************************************************************************
|
||||
* Deep JSON merge helper.
|
||||
*
|
||||
* Why: Config loading overlays several sources (defaults, files, env),
|
||||
* and we need deterministic, recursive object merging semantics.
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
/**
|
||||
* Deep-merges {@code override} into {@code base}. Objects are merged recursively; other types
|
||||
* replace the base value.
|
||||
*
|
||||
* @param base
|
||||
* base JSON (may be null)
|
||||
* @param override
|
||||
* override JSON
|
||||
*
|
||||
* @return merged JSON node
|
||||
*/
|
||||
/***************************************************************************
|
||||
* Merges override into base using object-wise deep merge.
|
||||
* Deep-merges override into base using object-wise deep merge.
|
||||
* Objects are merged recursively; other types replace the base value.
|
||||
*
|
||||
* @param base base JSON (may be null)
|
||||
* @param override override JSON (may be null)
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.core.doctor;
|
||||
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Checks for required external dependencies (Java, Maven, Git, Docker).
|
||||
*
|
||||
* Provides both individual checks and a comprehensive report.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class DependencyChecker
|
||||
{
|
||||
private static final int DEFAULT_TIMEOUT_MS = 5000;
|
||||
private static final int THREAD_JOIN_TIMEOUT = 1000;
|
||||
private static final int ERROR_EXIT_CODE = -1;
|
||||
private static final int MIN_JAVA_VERSION = 21;
|
||||
private static final String JAVA_VERSION_PATTERN = "version \"?(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?";
|
||||
private static final String MVN_VERSION_PATTERN = "Apache Maven (\\d+\\.\\d+\\.\\d+)";
|
||||
private static final String GIT_VERSION_PATTERN = "git version (\\d+\\.\\d+\\.\\d+)";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if Java is installed and meets minimum version requirement.
|
||||
*
|
||||
* @return dependency status for Java
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static DependencyStatus checkJava()
|
||||
{
|
||||
return checkJava(MIN_JAVA_VERSION);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if Java is installed and meets specified minimum version.
|
||||
*
|
||||
* @param minVersion minimum required Java version
|
||||
* @return dependency status for Java
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static DependencyStatus checkJava(int minVersion)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessResult result = runCommand("java", "-version");
|
||||
if(result.exitCode() != 0)
|
||||
{
|
||||
return DependencyStatus.notFound("Java", "java", "Install Java " + minVersion + "+ from https://adoptium.net/");
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// Java prints version to stderr, so check both streams //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
String output = result.stderr().isEmpty() ? result.stdout() : result.stderr();
|
||||
Pattern pattern = Pattern.compile(JAVA_VERSION_PATTERN);
|
||||
Matcher matcher = pattern.matcher(output);
|
||||
|
||||
if(matcher.find())
|
||||
{
|
||||
int majorVersion = Integer.parseInt(matcher.group(1));
|
||||
String fullVersion = extractFullVersion(output, "java");
|
||||
String path = findExecutablePath("java");
|
||||
|
||||
if(majorVersion >= minVersion)
|
||||
{
|
||||
return DependencyStatus.ok("Java", fullVersion, path);
|
||||
}
|
||||
else
|
||||
{
|
||||
return DependencyStatus.versionMismatch("Java", fullVersion, minVersion + "+", path,
|
||||
"Upgrade to Java " + minVersion + "+ from https://adoptium.net/");
|
||||
}
|
||||
}
|
||||
|
||||
return DependencyStatus.notFound("Java", "java", "Install Java " + minVersion + "+ from https://adoptium.net/");
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return DependencyStatus.error("Java", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if Maven is installed.
|
||||
*
|
||||
* @return dependency status for Maven
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static DependencyStatus checkMaven()
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessResult result = runCommand("mvn", "-version");
|
||||
if(result.exitCode() != 0)
|
||||
{
|
||||
return DependencyStatus.notFound("Maven", "mvn", "Install Maven from https://maven.apache.org/download.cgi");
|
||||
}
|
||||
|
||||
Pattern pattern = Pattern.compile(MVN_VERSION_PATTERN);
|
||||
Matcher matcher = pattern.matcher(result.stdout());
|
||||
|
||||
if(matcher.find())
|
||||
{
|
||||
String version = matcher.group(1);
|
||||
String path = findExecutablePath("mvn");
|
||||
return DependencyStatus.ok("Maven", version, path);
|
||||
}
|
||||
|
||||
return DependencyStatus.notFound("Maven", "mvn", "Install Maven from https://maven.apache.org/download.cgi");
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return DependencyStatus.error("Maven", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if Git is installed.
|
||||
*
|
||||
* @return dependency status for Git
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static DependencyStatus checkGit()
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessResult result = runCommand("git", "--version");
|
||||
if(result.exitCode() != 0)
|
||||
{
|
||||
return DependencyStatus.notFound("Git", "git", "Install Git from https://git-scm.com/downloads");
|
||||
}
|
||||
|
||||
Pattern pattern = Pattern.compile(GIT_VERSION_PATTERN);
|
||||
Matcher matcher = pattern.matcher(result.stdout());
|
||||
|
||||
if(matcher.find())
|
||||
{
|
||||
String version = matcher.group(1);
|
||||
String path = findExecutablePath("git");
|
||||
return DependencyStatus.ok("Git", version, path);
|
||||
}
|
||||
|
||||
return DependencyStatus.notFound("Git", "git", "Install Git from https://git-scm.com/downloads");
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return DependencyStatus.error("Git", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if Docker is installed (optional dependency).
|
||||
*
|
||||
* @return dependency status for Docker
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static DependencyStatus checkDocker()
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessResult result = runCommand("docker", "--version");
|
||||
if(result.exitCode() != 0)
|
||||
{
|
||||
return DependencyStatus.optional("Docker", "docker", "Install Docker from https://docs.docker.com/get-docker/");
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// Docker version output: "Docker version 24.0.5, build ..." //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
Pattern pattern = Pattern.compile("Docker version (\\d+\\.\\d+\\.\\d+)");
|
||||
Matcher matcher = pattern.matcher(result.stdout());
|
||||
|
||||
if(matcher.find())
|
||||
{
|
||||
String version = matcher.group(1);
|
||||
String path = findExecutablePath("docker");
|
||||
return DependencyStatus.ok("Docker", version, path);
|
||||
}
|
||||
|
||||
return DependencyStatus.optional("Docker", "docker", "Install Docker from https://docs.docker.com/get-docker/");
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return DependencyStatus.optional("Docker", "docker", "Install Docker from https://docs.docker.com/get-docker/");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check all dependencies and return a comprehensive report.
|
||||
*
|
||||
* @return list of dependency statuses
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static List<DependencyStatus> checkAll()
|
||||
{
|
||||
List<DependencyStatus> results = new ArrayList<>();
|
||||
results.add(checkJava());
|
||||
results.add(checkMaven());
|
||||
results.add(checkGit());
|
||||
results.add(checkDocker());
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Require Java to be installed with minimum version. Throws if not met.
|
||||
*
|
||||
* @param minVersion minimum required Java version
|
||||
* @throws DependencyException if Java is not installed or version too low
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static void requireJava(int minVersion) throws DependencyException
|
||||
{
|
||||
DependencyStatus status = checkJava(minVersion);
|
||||
if(!status.isOk())
|
||||
{
|
||||
throw new DependencyException(status);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Require Maven to be installed. Throws if not found.
|
||||
*
|
||||
* @throws DependencyException if Maven is not installed
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static void requireMaven() throws DependencyException
|
||||
{
|
||||
DependencyStatus status = checkMaven();
|
||||
if(!status.isOk())
|
||||
{
|
||||
throw new DependencyException(status);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Require Git to be installed. Throws if not found.
|
||||
*
|
||||
* @throws DependencyException if Git is not installed
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static void requireGit() throws DependencyException
|
||||
{
|
||||
DependencyStatus status = checkGit();
|
||||
if(!status.isOk())
|
||||
{
|
||||
throw new DependencyException(status);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Run a command and capture output.
|
||||
*
|
||||
* @param command command and arguments
|
||||
* @return process result with exit code and output
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private static ProcessResult runCommand(String... command)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.redirectErrorStream(false);
|
||||
Process process = pb.start();
|
||||
|
||||
StringBuilder stdout = new StringBuilder();
|
||||
StringBuilder stderr = new StringBuilder();
|
||||
|
||||
Thread stdoutThread = new Thread(() -> readStream(process.getInputStream(), stdout));
|
||||
Thread stderrThread = new Thread(() -> readStream(process.getErrorStream(), stderr));
|
||||
stdoutThread.start();
|
||||
stderrThread.start();
|
||||
|
||||
boolean completed = process.waitFor(DEFAULT_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
if(!completed)
|
||||
{
|
||||
process.destroyForcibly();
|
||||
return new ProcessResult(ERROR_EXIT_CODE, "", "Command timed out");
|
||||
}
|
||||
|
||||
stdoutThread.join(THREAD_JOIN_TIMEOUT);
|
||||
stderrThread.join(THREAD_JOIN_TIMEOUT);
|
||||
|
||||
return new ProcessResult(process.exitValue(), stdout.toString(), stderr.toString());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return new ProcessResult(ERROR_EXIT_CODE, "", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Read an input stream into a StringBuilder.
|
||||
*
|
||||
* @param inputStream stream to read
|
||||
* @param output builder to append to
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private static void readStream(java.io.InputStream inputStream, StringBuilder output)
|
||||
{
|
||||
try(BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)))
|
||||
{
|
||||
String line;
|
||||
while((line = reader.readLine()) != null)
|
||||
{
|
||||
output.append(line).append("\n");
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Find the path to an executable using 'which' (Unix) or 'where' (Windows).
|
||||
*
|
||||
* @param executable name of executable
|
||||
* @return path or "unknown"
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private static String findExecutablePath(String executable)
|
||||
{
|
||||
try
|
||||
{
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
String[] command = os.contains("win")
|
||||
? new String[] { "where", executable }
|
||||
: new String[] { "which", executable };
|
||||
|
||||
ProcessResult result = runCommand(command);
|
||||
if(result.exitCode() == 0 && !result.stdout().isEmpty())
|
||||
{
|
||||
return result.stdout().trim().split("\n")[0];
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Extract full version string from command output.
|
||||
*
|
||||
* @param output command output
|
||||
* @param tool tool name for context
|
||||
* @return version string or "unknown"
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private static String extractFullVersion(String output, String tool)
|
||||
{
|
||||
if("java".equals(tool))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////
|
||||
// Try to extract version like "21.0.5" or "21" //
|
||||
////////////////////////////////////////////////////////////////
|
||||
Pattern pattern = Pattern.compile("version \"?(\\d+(?:\\.\\d+)*(?:[._][\\w-]+)?)");
|
||||
Matcher matcher = pattern.matcher(output);
|
||||
if(matcher.find())
|
||||
{
|
||||
String version = matcher.group(1);
|
||||
////////////////////////////////////////////////////////////
|
||||
// Check for GraalVM or other JVM vendors //
|
||||
////////////////////////////////////////////////////////////
|
||||
if(output.contains("GraalVM"))
|
||||
{
|
||||
return version + " (GraalVM)";
|
||||
}
|
||||
else if(output.contains("Temurin"))
|
||||
{
|
||||
return version + " (Temurin)";
|
||||
}
|
||||
else if(output.contains("OpenJDK"))
|
||||
{
|
||||
return version + " (OpenJDK)";
|
||||
}
|
||||
return version;
|
||||
}
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Result of running an external process.
|
||||
*
|
||||
* @param exitCode process exit code
|
||||
* @param stdout standard output
|
||||
* @param stderr standard error
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private record ProcessResult(int exitCode, String stdout, String stderr) {}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.core.doctor;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Exception thrown when a required dependency is not satisfied.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class DependencyException extends Exception
|
||||
{
|
||||
private final DependencyStatus status;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create exception from dependency status.
|
||||
*
|
||||
* @param status the failing dependency status
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public DependencyException(DependencyStatus status)
|
||||
{
|
||||
super(status.getMessage());
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get the dependency status that caused this exception.
|
||||
*
|
||||
* @return dependency status
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public DependencyStatus getStatus()
|
||||
{
|
||||
return status;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get a user-friendly error message with installation hint.
|
||||
*
|
||||
* @return formatted error message
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String getFormattedMessage()
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(status.getMessage());
|
||||
if(status.hint() != null && !status.hint().isEmpty())
|
||||
{
|
||||
sb.append("\n").append(status.hint());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.core.doctor;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Status of a dependency check.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public record DependencyStatus(
|
||||
String name,
|
||||
Status status,
|
||||
String version,
|
||||
String path,
|
||||
String requiredVersion,
|
||||
String hint
|
||||
)
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
* Dependency check status values.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public enum Status
|
||||
{
|
||||
OK,
|
||||
NOT_FOUND,
|
||||
VERSION_MISMATCH,
|
||||
OPTIONAL_MISSING,
|
||||
ERROR
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if dependency is satisfied.
|
||||
*
|
||||
* @return true if OK
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public boolean isOk()
|
||||
{
|
||||
return status == Status.OK;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if this is an optional dependency.
|
||||
*
|
||||
* @return true if optional
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public boolean isOptional()
|
||||
{
|
||||
return status == Status.OPTIONAL_MISSING;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create status for a found and valid dependency.
|
||||
*
|
||||
* @param name dependency name
|
||||
* @param version found version
|
||||
* @param path executable path
|
||||
* @return OK status
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static DependencyStatus ok(String name, String version, String path)
|
||||
{
|
||||
return new DependencyStatus(name, Status.OK, version, path, null, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create status for a missing required dependency.
|
||||
*
|
||||
* @param name dependency name
|
||||
* @param executable executable name
|
||||
* @param hint installation hint
|
||||
* @return NOT_FOUND status
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static DependencyStatus notFound(String name, String executable, String hint)
|
||||
{
|
||||
return new DependencyStatus(name, Status.NOT_FOUND, null, null, null, hint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create status for a version mismatch.
|
||||
*
|
||||
* @param name dependency name
|
||||
* @param foundVersion version that was found
|
||||
* @param requiredVersion version that is required
|
||||
* @param path executable path
|
||||
* @param hint upgrade hint
|
||||
* @return VERSION_MISMATCH status
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static DependencyStatus versionMismatch(String name, String foundVersion,
|
||||
String requiredVersion, String path, String hint)
|
||||
{
|
||||
return new DependencyStatus(name, Status.VERSION_MISMATCH, foundVersion, path, requiredVersion, hint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create status for a missing optional dependency.
|
||||
*
|
||||
* @param name dependency name
|
||||
* @param executable executable name
|
||||
* @param hint installation hint
|
||||
* @return OPTIONAL_MISSING status
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static DependencyStatus optional(String name, String executable, String hint)
|
||||
{
|
||||
return new DependencyStatus(name, Status.OPTIONAL_MISSING, null, null, null, hint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create status for an error during check.
|
||||
*
|
||||
* @param name dependency name
|
||||
* @param errorMessage error message
|
||||
* @return ERROR status
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static DependencyStatus error(String name, String errorMessage)
|
||||
{
|
||||
return new DependencyStatus(name, Status.ERROR, null, null, null, errorMessage);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get a user-friendly message for this status.
|
||||
*
|
||||
* @return status message
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String getMessage()
|
||||
{
|
||||
return switch(status)
|
||||
{
|
||||
case OK -> name + " " + version;
|
||||
case NOT_FOUND -> name + " not found";
|
||||
case VERSION_MISMATCH -> name + " " + version + " found, but " + requiredVersion + " required";
|
||||
case OPTIONAL_MISSING -> name + " not found (optional)";
|
||||
case ERROR -> name + " check failed: " + hint;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,12 @@
|
||||
package io.qrun.qctl.core.sys;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
|
||||
/**
|
||||
@@ -26,6 +29,8 @@ import java.nio.file.Paths;
|
||||
*/
|
||||
public final class SystemPaths
|
||||
{
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SystemPaths.class);
|
||||
|
||||
/***************************************************************************
|
||||
* Non-instantiable utility class.
|
||||
*
|
||||
@@ -97,15 +102,22 @@ public final class SystemPaths
|
||||
|
||||
|
||||
|
||||
/** Ensures the given directory exists, creating it if needed. */
|
||||
/***************************************************************************
|
||||
* Ensures the given directory exists, creating it if needed.
|
||||
*
|
||||
* @param p directory path to create
|
||||
* @return the same path for fluent usage
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public static Path ensureDir(Path p)
|
||||
{
|
||||
try
|
||||
{
|
||||
Files.createDirectories(p);
|
||||
}
|
||||
catch(Exception expected)
|
||||
catch(IOException e)
|
||||
{
|
||||
LOG.debug("Failed to create directory {}: {}", p, e.getMessage());
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version=${project.version}
|
||||
build.time=${maven.build.timestamp}
|
||||
git.commit=${git.commit.id.abbrev}
|
||||
@@ -6,11 +6,46 @@
|
||||
<parent>
|
||||
<groupId>io.qrun.qctl</groupId>
|
||||
<artifactId>qctl-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
<artifactId>qctl-integration-tests</artifactId>
|
||||
<name>qctl-integration-tests</name>
|
||||
<description>Integration and E2E tests for qctl CLI</description>
|
||||
|
||||
<dependencies>
|
||||
<!-- Command modules under test -->
|
||||
<dependency>
|
||||
<groupId>io.qrun.qctl</groupId>
|
||||
<artifactId>qctl-core</artifactId>
|
||||
<version>${revision}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.qrun.qctl</groupId>
|
||||
<artifactId>qctl-qqq</artifactId>
|
||||
<version>${revision}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.qrun.qctl</groupId>
|
||||
<artifactId>qctl-qbit</artifactId>
|
||||
<version>${revision}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.qrun.qctl</groupId>
|
||||
<artifactId>qctl-qrun</artifactId>
|
||||
<version>${revision}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.qrun.qctl</groupId>
|
||||
<artifactId>qctl-qstudio</artifactId>
|
||||
<version>${revision}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Test frameworks -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
@@ -34,5 +69,41 @@
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Picocli for command parsing -->
|
||||
<dependency>
|
||||
<groupId>info.picocli</groupId>
|
||||
<artifactId>picocli</artifactId>
|
||||
<version>${picocli.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<!-- Run integration tests with failsafe -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>integration-test</goal>
|
||||
<goal>verify</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<qctl.native.binary>${project.basedir}/../qctl-cli/target/qctl</qctl.native.binary>
|
||||
</systemPropertyVariables>
|
||||
<includes>
|
||||
<include>**/*IT.java</include>
|
||||
<include>**/*E2ETest.java</include>
|
||||
</includes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.e2e;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import io.qrun.qctl.e2e.harness.CommandTestHarness;
|
||||
import io.qrun.qctl.e2e.harness.TestTemplateRegistry;
|
||||
import io.qrun.qctl.qqq.registry.TemplateRegistryFactory;
|
||||
import io.qrun.qctl.shared.ExitCodes;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Comprehensive E2E tests for the qqq init command using fixture templates.
|
||||
*
|
||||
* Tests command-line flags, project generation, and error handling scenarios.
|
||||
* Uses TestTemplateRegistry to provide fully offline, deterministic tests.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
class InitCommandE2ETest
|
||||
{
|
||||
private CommandTestHarness harness;
|
||||
private TestTemplateRegistry registry;
|
||||
private InputStream originalIn;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException
|
||||
{
|
||||
harness = new CommandTestHarness();
|
||||
registry = new TestTemplateRegistry();
|
||||
registry.loadFromResources("test-minimal");
|
||||
registry.loadFromResources("test-with-prompts");
|
||||
TemplateRegistryFactory.setRegistry(registry);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Provide empty input to prevent hanging on unexpected prompts //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
originalIn = System.in;
|
||||
System.setIn(new ByteArrayInputStream("\n\n\n".getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
TemplateRegistryFactory.reset();
|
||||
System.setIn(originalIn);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test happy path: init with minimal template creates project.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_minimalTemplate_createsProject()
|
||||
{
|
||||
Path outputDir = tempDir.resolve("my-project");
|
||||
|
||||
harness.execute("qqq", "init", "test-minimal", "--no-prompt",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// For debugging - print actual output if test fails //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
|
||||
.isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(harness.getStdout()).contains("Project initialized successfully");
|
||||
assertThat(outputDir).exists();
|
||||
assertThat(outputDir.resolve("README.md")).exists();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test --dry-run flag shows what would be created without creating files.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_dryRun_showsFilesWithoutCreating()
|
||||
{
|
||||
Path outputDir = tempDir.resolve("dry-run-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--dry-run",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(harness.getStdout()).contains("[DRY-RUN]");
|
||||
assertThat(harness.getStdout()).contains("README.md");
|
||||
assertThat(outputDir).doesNotExist();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test directory conflict without --force shows error.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_existingDir_showsConflictError() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("existing-project");
|
||||
Files.createDirectories(outputDir);
|
||||
|
||||
harness.execute("qqq", "init", "test-minimal", "--no-prompt",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.CONFLICT);
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Error message goes to stderr, suggestion goes to stdout //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(harness.getStderr()).contains("target directory already exists");
|
||||
assertThat(harness.getStdout()).contains("--force");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test --force flag overwrites existing directory.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_forceFlag_overwritesExistingDir() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("force-test");
|
||||
Files.createDirectories(outputDir);
|
||||
Files.writeString(outputDir.resolve("existing.txt"), "should be preserved");
|
||||
|
||||
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--force",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(harness.getStdout()).contains("Overwriting existing directory");
|
||||
assertThat(outputDir.resolve("README.md")).exists();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test --merge flag merges with existing directory.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_mergeFlag_mergesWithExistingDir() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("merge-test");
|
||||
Files.createDirectories(outputDir);
|
||||
Files.writeString(outputDir.resolve("existing.txt"), "should be preserved");
|
||||
|
||||
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--merge",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(harness.getStdout()).contains("Merging with existing directory");
|
||||
assertThat(outputDir.resolve("existing.txt")).exists();
|
||||
assertThat(outputDir.resolve("README.md")).exists();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test --var flag sets template variables.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_varFlag_setsTemplateVariables()
|
||||
{
|
||||
Path outputDir = tempDir.resolve("var-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
|
||||
"--var", "projectName=custom-name",
|
||||
"--var", "packageName=com.acme",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(harness.getStdout()).contains("Project initialized successfully");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test non-existent template shows error with suggestions.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_nonExistentTemplate_showsError()
|
||||
{
|
||||
Path outputDir = tempDir.resolve("error-test");
|
||||
|
||||
harness.execute("qqq", "init", "not-a-real-template", "--no-prompt",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Should show error - template not found //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(harness.getStdout() + harness.getStderr())
|
||||
.containsIgnoringCase("not found")
|
||||
.contains("not-a-real-template");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test --verbose flag includes extra error details.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_verboseFlag_showsDetailedErrors()
|
||||
{
|
||||
Path outputDir = tempDir.resolve("verbose-test");
|
||||
|
||||
harness.execute("qqq", "init", "not-a-real-template", "--no-prompt", "-v",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verbose output should include stack trace or extra context //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
String output = harness.getStdout() + harness.getStderr();
|
||||
assertThat(output).containsIgnoringCase("not found");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test init with multiple --var flags.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_multipleVars_allApplied()
|
||||
{
|
||||
Path outputDir = tempDir.resolve("multi-var-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
|
||||
"--var", "projectName=my-app",
|
||||
"--var", "packageName=org.example",
|
||||
"--var", "includeTests=false",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test --skip-hooks flag prevents post-gen hooks from running.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_skipHooks_noHooksExecuted()
|
||||
{
|
||||
Path outputDir = tempDir.resolve("skip-hooks-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--skip-hooks",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(harness.getStdout()).contains("Project initialized successfully");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test combined flags work together.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_combinedFlags_dryRunWithForce() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("combined-test");
|
||||
Files.createDirectories(outputDir);
|
||||
|
||||
harness.execute("qqq", "init", "test-minimal", "--no-prompt",
|
||||
"--dry-run", "--force",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(harness.getStdout()).contains("[DRY-RUN]");
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// In dry-run mode, existing files should not be modified //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(outputDir.resolve("README.md")).doesNotExist();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,938 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.e2e;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import io.qrun.qctl.e2e.harness.CommandTestHarness;
|
||||
import io.qrun.qctl.e2e.harness.TestTemplateRegistry;
|
||||
import io.qrun.qctl.qqq.registry.TemplateRegistryFactory;
|
||||
import io.qrun.qctl.shared.ExitCodes;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Full E2E tests that verify actual template rendering and output content.
|
||||
*
|
||||
* These tests go beyond smoke tests to verify:
|
||||
* - Variable substitution in generated files
|
||||
* - Computed variable evaluation
|
||||
* - Error message quality (suggestions, line numbers)
|
||||
* - Generated project validity
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
class InitCommandFullE2ETest
|
||||
{
|
||||
private CommandTestHarness harness;
|
||||
private TestTemplateRegistry registry;
|
||||
private InputStream originalIn;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException
|
||||
{
|
||||
harness = new CommandTestHarness();
|
||||
registry = new TestTemplateRegistry();
|
||||
registry.loadFromResources("test-minimal");
|
||||
registry.loadFromResources("test-with-prompts");
|
||||
registry.loadFromResources("test-variable-typo");
|
||||
registry.loadFromResources("test-full-substitution");
|
||||
registry.loadFromResources("test-computed-vars");
|
||||
registry.loadFromResources("test-transforms");
|
||||
TemplateRegistryFactory.setRegistry(registry);
|
||||
|
||||
originalIn = System.in;
|
||||
System.setIn(new ByteArrayInputStream("\n\n\n".getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
TemplateRegistryFactory.reset();
|
||||
System.setIn(originalIn);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that template variables are actually substituted in file content.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_variableSubstitution_replacesVariablesInContent() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("var-substitution-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
|
||||
"--var", "projectName=MyAwesomeProject",
|
||||
"--var", "packageName=com.example.awesome",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
|
||||
.isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify file content has substituted values, not raw variables //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Path readmePath = outputDir.resolve("README.md");
|
||||
assertThat(readmePath).exists();
|
||||
|
||||
String content = Files.readString(readmePath);
|
||||
assertThat(content)
|
||||
.as("README.md should contain substituted project name")
|
||||
.contains("MyAwesomeProject")
|
||||
.doesNotContain("$projectName");
|
||||
|
||||
assertThat(content)
|
||||
.as("README.md should contain substituted package name")
|
||||
.contains("com.example.awesome")
|
||||
.doesNotContain("$packageName");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that computed variables are evaluated and available.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_computedVariables_evaluatesExpressions() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("computed-var-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
|
||||
"--var", "projectName=TestProject",
|
||||
"--var", "packageName=org.example.test",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
|
||||
.isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// The computed variable packagePath should be org/example/test //
|
||||
// We'd need a template file that uses $packagePath to verify this //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(harness.getStdout()).contains("Project initialized successfully");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that undefined variable errors show "Did you mean?" suggestions.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_undefinedVariable_showsDidYouMeanSuggestion() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("typo-test");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// test-variable-typo has $projetcName (typo) instead of $projectName //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
harness.execute("qqq", "init", "test-variable-typo", "--no-prompt",
|
||||
"--var", "projectName=MyProject",
|
||||
"--var", "packageName=com.example",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Should fail with render error and show suggestion //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
String output = harness.getStdout() + harness.getStderr();
|
||||
|
||||
assertThat(output)
|
||||
.as("Error should mention the undefined variable")
|
||||
.containsIgnoringCase("projetcName");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// The "Did you mean?" feature should suggest the correct variable //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(output)
|
||||
.as("Error should suggest similar variable name")
|
||||
.containsIgnoringCase("projectName");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that --dry-run shows accurate file list without creating.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_dryRun_showsAccurateFileList()
|
||||
{
|
||||
Path outputDir = tempDir.resolve("dry-run-list-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt", "--dry-run",
|
||||
"--var", "projectName=DryRunProject",
|
||||
"--var", "packageName=com.dryrun",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(harness.getStdout())
|
||||
.contains("[DRY-RUN]")
|
||||
.contains("README.md");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify no files were actually created //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(outputDir).doesNotExist();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test merge mode preserves existing files and adds new ones.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_mergeMode_preservesExistingFiles() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("merge-content-test");
|
||||
Files.createDirectories(outputDir);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Create an existing file that should be preserved //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Path existingFile = outputDir.resolve("existing-config.txt");
|
||||
Files.writeString(existingFile, "Original content that should not change");
|
||||
|
||||
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--merge",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify existing file content is unchanged //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(Files.readString(existingFile))
|
||||
.isEqualTo("Original content that should not change");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify new template files were added //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(outputDir.resolve("README.md")).exists();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that multiple --var flags all get applied.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_multipleVars_allSubstituted() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("multi-var-content-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
|
||||
"--var", "projectName=FirstVar",
|
||||
"--var", "packageName=second.var.package",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
String content = Files.readString(outputDir.resolve("README.md"));
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Both variables should be substituted //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(content).contains("FirstVar");
|
||||
assertThat(content).contains("second.var.package");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test force mode actually overwrites existing file content.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_forceMode_overwritesFileContent() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("force-content-test");
|
||||
Files.createDirectories(outputDir);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Create a README.md with different content //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Path readmePath = outputDir.resolve("README.md");
|
||||
Files.writeString(readmePath, "Old content that should be replaced");
|
||||
|
||||
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt", "--force",
|
||||
"--var", "projectName=NewProject",
|
||||
"--var", "packageName=com.new.project",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify content was replaced with template output //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
String newContent = Files.readString(readmePath);
|
||||
assertThat(newContent)
|
||||
.doesNotContain("Old content")
|
||||
.contains("NewProject");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test full substitution across multiple files with pom.xml variables.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_fullSubstitution_allVariablesInAllFiles() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("full-sub-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-full-substitution", "--no-prompt",
|
||||
"--var", "projectName=AwesomeApp",
|
||||
"--var", "packageName=io.awesome.app",
|
||||
"--var", "groupId=io.awesome",
|
||||
"--var", "artifactId=awesome-app",
|
||||
"--var", "version=2.0.0",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
|
||||
.isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify README.md has all variables substituted //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
String readme = Files.readString(outputDir.resolve("README.md"));
|
||||
assertThat(readme)
|
||||
.contains("AwesomeApp")
|
||||
.contains("io.awesome.app")
|
||||
.contains("io.awesome")
|
||||
.contains("awesome-app")
|
||||
.contains("2.0.0")
|
||||
.doesNotContain("$projectName")
|
||||
.doesNotContain("$packageName")
|
||||
.doesNotContain("$groupId")
|
||||
.doesNotContain("$artifactId")
|
||||
.doesNotContain("$version");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify pom.xml has all variables substituted //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
String pom = Files.readString(outputDir.resolve("pom.xml"));
|
||||
assertThat(pom)
|
||||
.contains("<groupId>io.awesome</groupId>")
|
||||
.contains("<artifactId>awesome-app</artifactId>")
|
||||
.contains("<version>2.0.0</version>")
|
||||
.contains("<name>AwesomeApp</name>")
|
||||
.doesNotContain("$groupId")
|
||||
.doesNotContain("$artifactId")
|
||||
.doesNotContain("$version");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test computed variables are evaluated and substituted.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_computedVars_evaluatesAndSubstitutes() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("computed-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-computed-vars", "--no-prompt",
|
||||
"--var", "packageName=com.test.example",
|
||||
"--var", "projectName=my-cool-project",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
|
||||
.isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify README.md has computed variables substituted //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
String readme = Files.readString(outputDir.resolve("README.md"));
|
||||
assertThat(readme)
|
||||
.as("Package path should be computed from packageName")
|
||||
.contains("com/test/example")
|
||||
.doesNotContain("$packagePath");
|
||||
|
||||
assertThat(readme)
|
||||
.as("Kebab case should be computed from projectName")
|
||||
.contains("my-cool-project")
|
||||
.doesNotContain("$projectNameKebab");
|
||||
|
||||
assertThat(readme)
|
||||
.as("Pascal case should be computed from projectName (capitalizes first letter)")
|
||||
.contains("My-cool-project")
|
||||
.doesNotContain("$projectNamePascal");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that $packagePath in directory names gets substituted by Velocity.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_packagePathSubstitution_createsCorrectDirectories() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("transform-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-transforms", "--no-prompt",
|
||||
"--var", "packageName=org.acme.test",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
|
||||
.isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify $packagePath was substituted to org/acme/test by Velocity //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Path expectedJavaDir = outputDir.resolve("src/main/java/org/acme/test");
|
||||
assertThat(expectedJavaDir)
|
||||
.as("Directory should be created with $packagePath substituted to org/acme/test")
|
||||
.exists()
|
||||
.isDirectory();
|
||||
|
||||
Path appJava = expectedJavaDir.resolve("App.java");
|
||||
assertThat(appJava)
|
||||
.as("App.java should exist in transformed directory")
|
||||
.exists();
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify literal $packagePath directory does not exist //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Path placeholderDir = outputDir.resolve("src/main/java/$packagePath");
|
||||
assertThat(placeholderDir)
|
||||
.as("Literal $packagePath directory should not exist after substitution")
|
||||
.doesNotExist();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test delete transform removes specified files.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_deleteTransform_removesFiles() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("delete-transform-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-transforms", "--no-prompt",
|
||||
"--var", "packageName=com.example",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
|
||||
.isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify .gitkeep files were deleted by the delete transform //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Path gitkeepPath = outputDir.resolve("src/main/java/com/example/.gitkeep");
|
||||
assertThat(gitkeepPath)
|
||||
.as(".gitkeep should be deleted by transform")
|
||||
.doesNotExist();
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify other files still exist //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(outputDir.resolve("src/main/java/com/example/App.java"))
|
||||
.as("App.java should still exist")
|
||||
.exists();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that provided values are used and other vars can be set together.
|
||||
*
|
||||
* Note: --no-prompt mode currently requires all vars to be provided via
|
||||
* --var flags; defaults from template.yaml are not applied. This tests
|
||||
* that explicitly provided values work correctly.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_providedValues_usedCorrectly() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("provided-values-test");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Provide custom projectName, use defaults for others //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
harness.execute("qqq", "init", "test-full-substitution", "--no-prompt",
|
||||
"--var", "projectName=CustomProject",
|
||||
"--var", "packageName=com.custom",
|
||||
"--var", "groupId=com.custom",
|
||||
"--var", "artifactId=custom-app",
|
||||
"--var", "version=2.0.0",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
|
||||
.isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify all provided values are used correctly //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
String readme = Files.readString(outputDir.resolve("README.md"));
|
||||
assertThat(readme)
|
||||
.as("Custom project name should be used")
|
||||
.contains("CustomProject");
|
||||
|
||||
assertThat(readme)
|
||||
.as("Custom package name should be used")
|
||||
.contains("com.custom");
|
||||
|
||||
String pom = Files.readString(outputDir.resolve("pom.xml"));
|
||||
assertThat(pom)
|
||||
.as("Custom groupId should be used")
|
||||
.contains("<groupId>com.custom</groupId>");
|
||||
|
||||
assertThat(pom)
|
||||
.as("Custom version should be used")
|
||||
.contains("<version>2.0.0</version>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test App.java in transformed directory has correct package statement.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_javaFile_hasCorrectPackageStatement() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("java-package-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-transforms", "--no-prompt",
|
||||
"--var", "packageName=io.qrun.myapp",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
|
||||
.isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify App.java has correct package statement //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Path appJava = outputDir.resolve("src/main/java/io/qrun/myapp/App.java");
|
||||
assertThat(appJava).exists();
|
||||
|
||||
String content = Files.readString(appJava);
|
||||
assertThat(content)
|
||||
.as("App.java should have correct package statement")
|
||||
.contains("package io.qrun.myapp;")
|
||||
.doesNotContain("$packageName");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test pom.xml is valid XML after substitution.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_generatedPom_isValidXml() throws Exception
|
||||
{
|
||||
Path outputDir = tempDir.resolve("valid-xml-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-full-substitution", "--no-prompt",
|
||||
"--var", "projectName=XmlTest",
|
||||
"--var", "packageName=com.xml.test",
|
||||
"--var", "groupId=com.xml",
|
||||
"--var", "artifactId=xml-test",
|
||||
"--var", "version=1.0.0",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Parse pom.xml to verify it's valid XML //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Path pomPath = outputDir.resolve("pom.xml");
|
||||
javax.xml.parsers.DocumentBuilderFactory factory =
|
||||
javax.xml.parsers.DocumentBuilderFactory.newInstance();
|
||||
javax.xml.parsers.DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// This will throw an exception if XML is invalid //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
org.w3c.dom.Document doc = builder.parse(pomPath.toFile());
|
||||
assertThat(doc.getDocumentElement().getNodeName())
|
||||
.as("Root element should be 'project'")
|
||||
.isEqualTo("project");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test behavior when requesting a template that doesn't exist.
|
||||
*
|
||||
* With TestTemplateRegistry, non-existent templates cause the system
|
||||
* to fall back to the first available template in --no-prompt mode.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_unknownTemplate_fallsBackToFirst() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("unknown-template-test");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// With --no-prompt, requesting unknown template uses first available //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
harness.execute("qqq", "init", "non-existent-template", "--no-prompt",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Should complete successfully using first available template //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Should succeed with fallback template")
|
||||
.isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Output directory should exist with files from fallback template //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(outputDir).exists().isDirectory();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test merge mode skips files that already exist and are identical.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_mergeMode_skipsIdenticalFiles() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("merge-identical-test");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// First run to create files //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
harness.execute("qqq", "init", "test-minimal", "--no-prompt",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Second run with merge should skip existing files //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--merge",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(harness.getStdout())
|
||||
.as("Should show skip status for existing files")
|
||||
.contains("[skip]");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test merge with --include pattern only overwrites matched files.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_mergeWithInclude_onlyOverwritesMatched() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("merge-include-test");
|
||||
Files.createDirectories(outputDir);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Create existing README.md with custom content //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Path readme = outputDir.resolve("README.md");
|
||||
Files.writeString(readme, "Original README content");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Create existing other.txt that should not be touched //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Path other = outputDir.resolve("other.txt");
|
||||
Files.writeString(other, "Other content");
|
||||
|
||||
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--merge",
|
||||
"--include", "README.md",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// README.md should be overwritten (included in --include) //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(Files.readString(readme))
|
||||
.as("README.md should be overwritten")
|
||||
.doesNotContain("Original README content");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// other.txt should be untouched //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(Files.readString(other))
|
||||
.as("other.txt should be preserved")
|
||||
.isEqualTo("Other content");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test merge with --exclude pattern skips matched files.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_mergeWithExclude_skipsMatched() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("merge-exclude-test");
|
||||
Files.createDirectories(outputDir);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Create existing README.md that should be skipped //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Path readme = outputDir.resolve("README.md");
|
||||
Files.writeString(readme, "Preserved README");
|
||||
|
||||
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--merge",
|
||||
"--exclude", "README.md",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// README.md should be preserved (excluded from merge) //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(Files.readString(readme))
|
||||
.as("README.md should be preserved due to --exclude")
|
||||
.isEqualTo("Preserved README");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that unicode characters in variable values are handled correctly.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_unicodeInVariables_handledCorrectly() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("unicode-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
|
||||
"--var", "projectName=Projet-Franca\u0327ais",
|
||||
"--var", "packageName=com.example.unicode",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
|
||||
.isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
String readme = Files.readString(outputDir.resolve("README.md"));
|
||||
assertThat(readme)
|
||||
.as("Unicode characters should be preserved")
|
||||
.contains("Projet-Franca\u0327ais");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that special characters in package names work correctly.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_specialCharsInPackage_escapedCorrectly() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("special-chars-test");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Package names with underscores are valid Java package names //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
harness.execute("qqq", "init", "test-computed-vars", "--no-prompt",
|
||||
"--var", "projectName=test_project",
|
||||
"--var", "packageName=com.example_company.test_app",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
|
||||
.isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify directory structure is created correctly //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Path packageDir = outputDir.resolve("src/main/java/com/example_company/test_app");
|
||||
assertThat(packageDir)
|
||||
.as("Package directory with underscores should be created")
|
||||
.exists()
|
||||
.isDirectory();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test deeply nested directories are created correctly.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_deeplyNestedPackage_createsAllDirectories() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("deep-nest-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-computed-vars", "--no-prompt",
|
||||
"--var", "projectName=deep-test",
|
||||
"--var", "packageName=com.very.deeply.nested.package.structure",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
|
||||
.isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verify deeply nested directory structure is created //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Path deepDir = outputDir.resolve(
|
||||
"src/main/java/com/very/deeply/nested/package/structure");
|
||||
assertThat(deepDir)
|
||||
.as("Deep directory structure should be created")
|
||||
.exists()
|
||||
.isDirectory();
|
||||
|
||||
assertThat(deepDir.resolve("App.java"))
|
||||
.as("App.java should exist in deep directory")
|
||||
.exists();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that computed variable appears in directory path.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_computedVar_usedInDirectoryPath() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("computed-dir-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-full-substitution", "--no-prompt",
|
||||
"--var", "projectName=ComputedDirTest",
|
||||
"--var", "packageName=org.test.computed",
|
||||
"--var", "groupId=org.test",
|
||||
"--var", "artifactId=computed-test",
|
||||
"--var", "version=1.0.0",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
|
||||
.isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// packagePath computed from packageName should create org/test/computed//
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Path javaDir = outputDir.resolve("src/main/java/org/test/computed");
|
||||
assertThat(javaDir)
|
||||
.as("Directory path should use computed packagePath")
|
||||
.exists()
|
||||
.isDirectory();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test verbose mode shows additional information.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_verboseMode_showsExtraInfo()
|
||||
{
|
||||
Path outputDir = tempDir.resolve("verbose-test");
|
||||
|
||||
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--verbose",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Verbose mode should complete successfully //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
assertThat(harness.getStdout())
|
||||
.contains("Project initialized successfully");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that empty project name is rejected.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_emptyVarValue_isHandled() throws IOException
|
||||
{
|
||||
Path outputDir = tempDir.resolve("empty-var-test");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Empty string should be treated as a valid (empty) value //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
|
||||
"--var", "projectName=",
|
||||
"--var", "packageName=com.empty",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Command should complete (empty string is valid for non-required) //
|
||||
// or fail validation if required //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
String output = harness.getStdout() + harness.getStderr();
|
||||
assertThat(output).isNotEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.e2e;
|
||||
|
||||
|
||||
import io.qrun.qctl.e2e.harness.CommandTestHarness;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static io.qrun.qctl.e2e.harness.ExitCodeAssertions.assertSuccess;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Integration tests for the qqq init command.
|
||||
*
|
||||
* Tests command parsing, help text, and basic validation without
|
||||
* spawning native processes or making network calls.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
class InitCommandIT
|
||||
{
|
||||
private CommandTestHarness harness;
|
||||
|
||||
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
harness = new CommandTestHarness();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that --help displays init command usage.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_help_showsUsage()
|
||||
{
|
||||
harness.execute("qqq", "init", "--help");
|
||||
|
||||
assertSuccess(harness);
|
||||
assertThat(harness.getStdout())
|
||||
.contains("Initialize a new project from a template")
|
||||
.contains("--output")
|
||||
.contains("--force")
|
||||
.contains("--dry-run")
|
||||
.contains("--merge");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that qqq command group shows help.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testQqq_help_showsSubcommands()
|
||||
{
|
||||
harness.execute("qqq", "--help");
|
||||
|
||||
assertSuccess(harness);
|
||||
assertThat(harness.getStdout())
|
||||
.contains("init")
|
||||
.contains("list");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that top-level --version works.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testVersion_showsVersionInfo()
|
||||
{
|
||||
harness.execute("--version");
|
||||
|
||||
assertSuccess(harness);
|
||||
assertThat(harness.getStdout())
|
||||
.containsPattern("qctl.*\\d+\\.\\d+\\.\\d+");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that unknown command shows error.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testUnknownCommand_showsError()
|
||||
{
|
||||
harness.execute("unknown-command");
|
||||
|
||||
assertThat(harness.getExitCode()).isNotZero();
|
||||
assertThat(harness.getStderr())
|
||||
.contains("unknown-command");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that qqq list runs and shows templates.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testList_showsTemplates()
|
||||
{
|
||||
harness.execute("qqq", "list");
|
||||
|
||||
assertSuccess(harness);
|
||||
assertThat(harness.getStdout())
|
||||
.containsIgnoringCase("Available Templates");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.e2e;
|
||||
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import io.qrun.qctl.shared.ExitCodes;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* End-to-end tests using the native qctl binary.
|
||||
*
|
||||
* These tests spawn the actual native binary and verify exit codes and output.
|
||||
* Requires the native binary to be built (skipped if not present).
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
class NativeBinaryE2ETest
|
||||
{
|
||||
private static final String BINARY_PROPERTY = "qctl.native.binary";
|
||||
private static final long TIMEOUT_SECONDS = 30;
|
||||
|
||||
private static Path binaryPath;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if native binary is available.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@BeforeAll
|
||||
static void checkBinaryExists()
|
||||
{
|
||||
String binaryPathStr = System.getProperty(BINARY_PROPERTY);
|
||||
if(binaryPathStr != null)
|
||||
{
|
||||
binaryPath = Path.of(binaryPathStr);
|
||||
}
|
||||
else
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// Try default location //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
binaryPath = Path.of("../qctl-cli/target/qctl");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@BeforeEach
|
||||
void assumeBinaryPresent()
|
||||
{
|
||||
assumeTrue(Files.exists(binaryPath) && Files.isExecutable(binaryPath),
|
||||
"Native binary not found at " + binaryPath + ". Run 'mvn package -Pnative' to build.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test --version flag.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testVersion_showsVersionNumber() throws Exception
|
||||
{
|
||||
ProcessResult result = execute("--version");
|
||||
|
||||
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(result.stdout).containsPattern("qctl.*\\d+\\.\\d+\\.\\d+");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test --help flag.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testHelp_showsUsage() throws Exception
|
||||
{
|
||||
ProcessResult result = execute("--help");
|
||||
|
||||
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(result.stdout)
|
||||
.contains("qctl")
|
||||
.contains("qqq")
|
||||
.contains("qbit")
|
||||
.contains("qrun");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test qqq init --help.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testQqqInitHelp_showsUsage() throws Exception
|
||||
{
|
||||
ProcessResult result = execute("qqq", "init", "--help");
|
||||
|
||||
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(result.stdout)
|
||||
.contains("Initialize a new project")
|
||||
.contains("--output")
|
||||
.contains("--force")
|
||||
.contains("--merge");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test qqq list command.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testQqqList_showsTemplates() throws Exception
|
||||
{
|
||||
ProcessResult result = execute("qqq", "list");
|
||||
|
||||
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(result.stdout)
|
||||
.contains("Available Templates")
|
||||
.contains("new-qqq-application");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test unknown command shows error.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testUnknownCommand_showsError() throws Exception
|
||||
{
|
||||
ProcessResult result = execute("not-a-command");
|
||||
|
||||
assertThat(result.exitCode).isNotZero();
|
||||
assertThat(result.stderr).contains("not-a-command");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test init with non-existent template.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_nonExistentTemplate_showsError() throws Exception
|
||||
{
|
||||
ProcessResult result = execute("qqq", "init", "not-a-real-template", "--no-prompt",
|
||||
"-o", tempDir.resolve("test-project").toString());
|
||||
|
||||
// Should show error with suggestions
|
||||
assertThat(result.stdout + result.stderr)
|
||||
.containsIgnoringCase("not found")
|
||||
.contains("not-a-real-template");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test init --dry-run shows preview without creating files.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_dryRun_showsPreview() throws Exception
|
||||
{
|
||||
Path outputDir = tempDir.resolve("dry-run-project");
|
||||
|
||||
ProcessResult result = execute("qqq", "init", "new-qqq-application",
|
||||
"--no-prompt", "--dry-run",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(result.stdout).contains("[DRY-RUN]");
|
||||
assertThat(outputDir).doesNotExist();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test init succeeds with new-qqq-application template.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_defaultTemplate_createsProject() throws Exception
|
||||
{
|
||||
Path outputDir = tempDir.resolve("default-template-project");
|
||||
|
||||
ProcessResult result = execute("qqq", "init", "new-qqq-application",
|
||||
"--no-prompt",
|
||||
"--var", "applicationName=test-app",
|
||||
"--var", "groupId=com.test",
|
||||
"--var", "artifactId=test-app",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(result.stdout).contains("Project initialized successfully");
|
||||
assertThat(outputDir).exists();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test init with --force overwrites existing directory.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_force_overwritesExisting() throws Exception
|
||||
{
|
||||
Path outputDir = tempDir.resolve("force-project");
|
||||
Files.createDirectories(outputDir);
|
||||
Files.writeString(outputDir.resolve("existing.txt"), "test");
|
||||
|
||||
ProcessResult result = execute("qqq", "init", "new-qqq-application",
|
||||
"--no-prompt", "--force",
|
||||
"--var", "applicationName=test-app",
|
||||
"--var", "groupId=com.test",
|
||||
"--var", "artifactId=test-app",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
|
||||
assertThat(result.stdout).contains("Overwriting");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test init without --force fails on existing directory.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testInit_existingDir_showsConflict() throws Exception
|
||||
{
|
||||
Path outputDir = tempDir.resolve("existing-project");
|
||||
Files.createDirectories(outputDir);
|
||||
|
||||
ProcessResult result = execute("qqq", "init", "new-qqq-application",
|
||||
"--no-prompt",
|
||||
"-o", outputDir.toString());
|
||||
|
||||
assertThat(result.exitCode).isEqualTo(ExitCodes.CONFLICT);
|
||||
assertThat(result.stderr).contains("target directory already exists");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Execute the native binary with arguments.
|
||||
*
|
||||
* @param args command-line arguments
|
||||
* @return process result with stdout, stderr, and exit code
|
||||
* @throws IOException if execution fails
|
||||
* @throws InterruptedException if wait is interrupted
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private ProcessResult execute(String... args) throws IOException, InterruptedException
|
||||
{
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(binaryPath.toAbsolutePath().toString());
|
||||
command.addAll(List.of(args));
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.environment().put("QCTL_REGISTRY_TYPE", "mock");
|
||||
pb.directory(tempDir.toFile());
|
||||
|
||||
Process process = pb.start();
|
||||
|
||||
StringBuilder stdout = new StringBuilder();
|
||||
StringBuilder stderr = new StringBuilder();
|
||||
|
||||
try(BufferedReader outReader = new BufferedReader(
|
||||
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
|
||||
BufferedReader errReader = new BufferedReader(
|
||||
new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8)))
|
||||
{
|
||||
Thread outThread = new Thread(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
String line;
|
||||
while((line = outReader.readLine()) != null)
|
||||
{
|
||||
stdout.append(line).append("\n");
|
||||
}
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
|
||||
Thread errThread = new Thread(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
String line;
|
||||
while((line = errReader.readLine()) != null)
|
||||
{
|
||||
stderr.append(line).append("\n");
|
||||
}
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
|
||||
outThread.start();
|
||||
errThread.start();
|
||||
|
||||
boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if(!finished)
|
||||
{
|
||||
process.destroyForcibly();
|
||||
throw new IOException("Process timed out after " + TIMEOUT_SECONDS + " seconds");
|
||||
}
|
||||
|
||||
outThread.join(1000);
|
||||
errThread.join(1000);
|
||||
}
|
||||
|
||||
return new ProcessResult(stdout.toString(), stderr.toString(), process.exitValue());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Result of a process execution.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
record ProcessResult(String stdout, String stderr, int exitCode)
|
||||
{
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.e2e.harness;
|
||||
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import io.qrun.qctl.core.Main;
|
||||
import io.qrun.qctl.shared.spi.CommandPlugin;
|
||||
import picocli.CommandLine;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Test harness for executing qctl commands in-process.
|
||||
*
|
||||
* Captures stdout, stderr, and exit codes for assertion. This enables fast,
|
||||
* deterministic E2E tests without spawning separate processes.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class CommandTestHarness
|
||||
{
|
||||
private final ByteArrayOutputStream stdoutStream = new ByteArrayOutputStream();
|
||||
private final ByteArrayOutputStream stderrStream = new ByteArrayOutputStream();
|
||||
private int exitCode = -1;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Execute a qctl command with the given arguments.
|
||||
*
|
||||
* @param args command-line arguments (e.g., "qqq", "init", "my-template")
|
||||
* @return this harness for fluent assertions
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public CommandTestHarness execute(String... args)
|
||||
{
|
||||
PrintStream origOut = System.out;
|
||||
PrintStream origErr = System.err;
|
||||
|
||||
try
|
||||
{
|
||||
System.setOut(new PrintStream(stdoutStream, true, StandardCharsets.UTF_8));
|
||||
System.setErr(new PrintStream(stderrStream, true, StandardCharsets.UTF_8));
|
||||
|
||||
CommandLine cmd = buildCommandLine();
|
||||
exitCode = cmd.execute(args);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.setOut(origOut);
|
||||
System.setErr(origErr);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Build CommandLine with all SPI-discovered plugins.
|
||||
*
|
||||
* @return configured CommandLine instance
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private CommandLine buildCommandLine()
|
||||
{
|
||||
CommandLine cmd = new CommandLine(new Main());
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Discover and register subcommands via SPI //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
try
|
||||
{
|
||||
java.util.ServiceLoader<CommandPlugin> loader =
|
||||
java.util.ServiceLoader.load(CommandPlugin.class);
|
||||
for(CommandPlugin plugin : loader)
|
||||
{
|
||||
Object command = plugin.getCommand();
|
||||
CommandLine sub = new CommandLine(command);
|
||||
String name = sub.getCommandSpec().name();
|
||||
cmd.addSubcommand(name, command);
|
||||
}
|
||||
}
|
||||
catch(java.util.ServiceConfigurationError | NoClassDefFoundError e)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// Log but continue - some plugins may not be available //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
System.err.println("warning: some command plugins could not be loaded: " + e.getMessage());
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get the captured stdout content.
|
||||
*
|
||||
* @return stdout as string
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String getStdout()
|
||||
{
|
||||
return stdoutStream.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get the captured stderr content.
|
||||
*
|
||||
* @return stderr as string
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String getStderr()
|
||||
{
|
||||
return stderrStream.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get the exit code from the last command execution.
|
||||
*
|
||||
* @return exit code (0 = success)
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public int getExitCode()
|
||||
{
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if stdout contains a specific string.
|
||||
*
|
||||
* @param expected the string to search for
|
||||
* @return true if stdout contains the string
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public boolean stdoutContains(String expected)
|
||||
{
|
||||
return getStdout().contains(expected);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if stderr contains a specific string.
|
||||
*
|
||||
* @param expected the string to search for
|
||||
* @return true if stderr contains the string
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public boolean stderrContains(String expected)
|
||||
{
|
||||
return getStderr().contains(expected);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Reset the harness for reuse.
|
||||
*
|
||||
* @return this harness for fluent chaining
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public CommandTestHarness reset()
|
||||
{
|
||||
stdoutStream.reset();
|
||||
stderrStream.reset();
|
||||
exitCode = -1;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.e2e.harness;
|
||||
|
||||
|
||||
import io.qrun.qctl.shared.ExitCodes;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* AssertJ-style assertions for CommandTestHarness exit codes.
|
||||
*
|
||||
* Provides readable assertions for common exit code scenarios.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public final class ExitCodeAssertions
|
||||
{
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Private constructor - utility class.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private ExitCodeAssertions()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Assert the command executed successfully (exit code 0).
|
||||
*
|
||||
* @param harness the test harness
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static void assertSuccess(CommandTestHarness harness)
|
||||
{
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Expected success (exit code 0), got %d.\nStdout: %s\nStderr: %s",
|
||||
harness.getExitCode(), harness.getStdout(), harness.getStderr())
|
||||
.isEqualTo(ExitCodes.SUCCESS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Assert the command failed with a generic error (exit code 1).
|
||||
*
|
||||
* @param harness the test harness
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static void assertGenericError(CommandTestHarness harness)
|
||||
{
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Expected generic error (exit code 1)")
|
||||
.isEqualTo(ExitCodes.GENERIC);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Assert the command failed with a usage error (exit code 2).
|
||||
*
|
||||
* @param harness the test harness
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static void assertUsageError(CommandTestHarness harness)
|
||||
{
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Expected usage error (exit code 2)")
|
||||
.isEqualTo(ExitCodes.USAGE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Assert the command failed with a validation error (exit code 6).
|
||||
*
|
||||
* @param harness the test harness
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static void assertValidationError(CommandTestHarness harness)
|
||||
{
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Expected validation error (exit code 6)")
|
||||
.isEqualTo(ExitCodes.VALIDATION);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Assert the command failed with a conflict error (exit code 8).
|
||||
*
|
||||
* @param harness the test harness
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static void assertConflictError(CommandTestHarness harness)
|
||||
{
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Expected conflict error (exit code 8)")
|
||||
.isEqualTo(ExitCodes.CONFLICT);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Assert the command returned a specific exit code.
|
||||
*
|
||||
* @param harness the test harness
|
||||
* @param expectedCode expected exit code
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static void assertExitCode(CommandTestHarness harness, int expectedCode)
|
||||
{
|
||||
assertThat(harness.getExitCode())
|
||||
.as("Expected exit code %d", expectedCode)
|
||||
.isEqualTo(expectedCode);
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.e2e.harness;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import io.qrun.qctl.qqq.registry.TemplateInfo;
|
||||
import io.qrun.qctl.qqq.registry.TemplateRegistry;
|
||||
import io.qrun.qctl.qqq.template.TemplateManifest;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Test template registry that loads fixtures from test resources.
|
||||
*
|
||||
* Provides fully offline, deterministic template loading for E2E tests.
|
||||
* Templates are loaded from src/test/resources/fixtures/templates/.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class TestTemplateRegistry implements TemplateRegistry
|
||||
{
|
||||
private static final String FIXTURES_DIR = "fixtures/templates";
|
||||
|
||||
private final Map<String, Path> templatePaths = new HashMap<>();
|
||||
private final Map<String, TemplateInfo> templateInfos = new HashMap<>();
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Register a fixture template by ID.
|
||||
*
|
||||
* @param id template ID
|
||||
* @param fixtureDir path to fixture directory containing template.yaml
|
||||
* @return this for fluent chaining
|
||||
* @throws IOException if template cannot be loaded
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TestTemplateRegistry addFixture(String id, Path fixtureDir) throws IOException
|
||||
{
|
||||
if(!Files.exists(fixtureDir))
|
||||
{
|
||||
throw new IOException("Fixture directory does not exist: " + fixtureDir);
|
||||
}
|
||||
|
||||
templatePaths.put(id, fixtureDir);
|
||||
|
||||
Path manifestPath = fixtureDir.resolve("template.yaml");
|
||||
if(Files.exists(manifestPath))
|
||||
{
|
||||
TemplateManifest manifest = TemplateManifest.load(fixtureDir);
|
||||
templateInfos.put(id, createTemplateInfo(id, manifest, fixtureDir));
|
||||
}
|
||||
else
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// Create minimal info for templates without manifests //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
templateInfos.put(id, createMinimalInfo(id, fixtureDir));
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Load a fixture from classpath resources.
|
||||
*
|
||||
* @param id template ID (also the directory name under fixtures/templates/)
|
||||
* @return this for fluent chaining
|
||||
* @throws IOException if fixture cannot be loaded
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TestTemplateRegistry loadFromResources(String id) throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
java.net.URL url = getClass().getClassLoader().getResource(FIXTURES_DIR + "/" + id);
|
||||
if(url == null)
|
||||
{
|
||||
throw new IOException("Fixture not found in resources: " + id);
|
||||
}
|
||||
Path fixtureDir = Path.of(url.toURI());
|
||||
return addFixture(id, fixtureDir);
|
||||
}
|
||||
catch(URISyntaxException e)
|
||||
{
|
||||
throw new IOException("Invalid fixture path: " + id, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create TemplateInfo from manifest.
|
||||
*
|
||||
* @param id template ID
|
||||
* @param manifest the loaded manifest
|
||||
* @param fixtureDir the fixture directory path
|
||||
* @return TemplateInfo record
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private TemplateInfo createTemplateInfo(String id, TemplateManifest manifest, Path fixtureDir)
|
||||
{
|
||||
return new TemplateInfo(
|
||||
id,
|
||||
manifest.name() != null ? manifest.name() : id,
|
||||
manifest.version() != null ? manifest.version() : "1.0.0",
|
||||
manifest.description() != null ? manifest.description() : "Test fixture template",
|
||||
fixtureDir.toUri().toString(),
|
||||
manifest.getEffectiveSchemaVersion(),
|
||||
manifest.minimumQctlVersion(),
|
||||
List.of("test", "fixture"),
|
||||
"test",
|
||||
manifest.prompts() != null ? manifest.prompts() : List.of(),
|
||||
manifest.computed() != null ? manifest.computed() : List.of(),
|
||||
manifest.transforms() != null ? manifest.transforms() : List.of(),
|
||||
manifest.postGen() != null ? manifest.postGen() : List.of(),
|
||||
manifest.ignore() != null ? manifest.ignore() : List.of()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create minimal TemplateInfo for fixtures without manifest.
|
||||
*
|
||||
* @param id template ID
|
||||
* @param fixtureDir the fixture directory path
|
||||
* @return TemplateInfo record
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private TemplateInfo createMinimalInfo(String id, Path fixtureDir)
|
||||
{
|
||||
return new TemplateInfo(
|
||||
id,
|
||||
id,
|
||||
"1.0.0",
|
||||
"Test fixture template (no manifest)",
|
||||
fixtureDir.toUri().toString(),
|
||||
1,
|
||||
null,
|
||||
List.of("test"),
|
||||
"test",
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public List<TemplateInfo> listTemplates()
|
||||
{
|
||||
return new ArrayList<>(templateInfos.values());
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public Optional<TemplateInfo> getTemplate(String id)
|
||||
{
|
||||
return Optional.ofNullable(templateInfos.get(id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public Path downloadTemplate(String id, String version) throws IOException
|
||||
{
|
||||
Path fixturePath = templatePaths.get(id);
|
||||
if(fixturePath == null)
|
||||
{
|
||||
throw new IOException("Fixture template not found: " + id);
|
||||
}
|
||||
return fixturePath;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void refresh()
|
||||
{
|
||||
// No-op for test registry
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Clear all registered fixtures.
|
||||
*
|
||||
* @return this for fluent chaining
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TestTemplateRegistry clear()
|
||||
{
|
||||
templatePaths.clear();
|
||||
templateInfos.clear();
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.e2e.harness;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
import java.util.function.Function;
|
||||
import io.qrun.qctl.qqq.template.ConsoleUI;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Test-friendly ConsoleUI that queues inputs and captures outputs.
|
||||
*
|
||||
* Allows tests to pre-populate input responses and verify prompt sequences
|
||||
* without actual user interaction.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class TestableConsoleUI extends ConsoleUI
|
||||
{
|
||||
private final Queue<String> queuedInputs = new LinkedList<>();
|
||||
private final List<String> promptHistory = new ArrayList<>();
|
||||
private final List<String> outputHistory = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Queue an input value to be returned by the next prompt.
|
||||
*
|
||||
* @param input the input value
|
||||
* @return this for fluent chaining
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TestableConsoleUI queueInput(String input)
|
||||
{
|
||||
queuedInputs.add(input);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Queue multiple input values in order.
|
||||
*
|
||||
* @param inputs the input values
|
||||
* @return this for fluent chaining
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TestableConsoleUI queueInputs(String... inputs)
|
||||
{
|
||||
for(String input : inputs)
|
||||
{
|
||||
queuedInputs.add(input);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get the list of prompts that were displayed.
|
||||
*
|
||||
* @return list of prompt labels
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public List<String> getPromptHistory()
|
||||
{
|
||||
return new ArrayList<>(promptHistory);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get the list of outputs (headers, info, success, etc).
|
||||
*
|
||||
* @return list of output messages
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public List<String> getOutputHistory()
|
||||
{
|
||||
return new ArrayList<>(outputHistory);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Reset all queued inputs and histories.
|
||||
*
|
||||
* @return this for fluent chaining
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TestableConsoleUI reset()
|
||||
{
|
||||
queuedInputs.clear();
|
||||
promptHistory.clear();
|
||||
outputHistory.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if any queued inputs remain unused.
|
||||
*
|
||||
* @return true if inputs remain
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public boolean hasUnusedInputs()
|
||||
{
|
||||
return !queuedInputs.isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get next queued input or empty string if none.
|
||||
*
|
||||
* @return queued input
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private String nextInput()
|
||||
{
|
||||
return queuedInputs.poll();
|
||||
}
|
||||
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Overridden methods to capture and simulate //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Override
|
||||
public void header(String title)
|
||||
{
|
||||
outputHistory.add("HEADER: " + title);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void subtitle(String text)
|
||||
{
|
||||
outputHistory.add("SUBTITLE: " + text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void info(String text)
|
||||
{
|
||||
outputHistory.add("INFO: " + text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void success(String text)
|
||||
{
|
||||
outputHistory.add("SUCCESS: " + text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void successBold(String text)
|
||||
{
|
||||
outputHistory.add("SUCCESS_BOLD: " + text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void error(String text)
|
||||
{
|
||||
outputHistory.add("ERROR: " + text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void validationError(String text)
|
||||
{
|
||||
outputHistory.add("VALIDATION_ERROR: " + text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void validationSuccess()
|
||||
{
|
||||
outputHistory.add("VALIDATION_SUCCESS");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void warning(String text)
|
||||
{
|
||||
outputHistory.add("WARNING: " + text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void println()
|
||||
{
|
||||
outputHistory.add("PRINTLN:");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void println(String text)
|
||||
{
|
||||
outputHistory.add("PRINTLN: " + text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public String promptText(String label, String defaultValue)
|
||||
{
|
||||
promptHistory.add(label);
|
||||
String input = nextInput();
|
||||
if(input == null || input.isEmpty())
|
||||
{
|
||||
return defaultValue != null ? defaultValue : "";
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void showValue(String label, String value)
|
||||
{
|
||||
outputHistory.add("SHOW_VALUE: " + label + "=" + value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void showValue(String label, String value, String annotation)
|
||||
{
|
||||
outputHistory.add("SHOW_VALUE: " + label + "=" + value + " (" + annotation + ")");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public <T> T promptSelect(String label,
|
||||
List<T> items,
|
||||
Function<T, String> displayFn,
|
||||
Function<T, String> descriptionFn,
|
||||
int defaultIndex)
|
||||
{
|
||||
promptHistory.add(label);
|
||||
String input = nextInput();
|
||||
|
||||
int selectedIndex = defaultIndex;
|
||||
if(input != null && !input.isEmpty())
|
||||
{
|
||||
try
|
||||
{
|
||||
int parsed = Integer.parseInt(input.trim()) - 1;
|
||||
if(parsed >= 0 && parsed < items.size())
|
||||
{
|
||||
selectedIndex = parsed;
|
||||
}
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
// Use default
|
||||
}
|
||||
}
|
||||
|
||||
return items.get(selectedIndex);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
schemaVersion: 2
|
||||
id: test-computed-vars
|
||||
name: Computed Variables Test Template
|
||||
version: 1.0.0
|
||||
description: Template for testing computed variable evaluation
|
||||
|
||||
prompts:
|
||||
- name: packageName
|
||||
message: Package name
|
||||
type: text
|
||||
defaultValue: com.example.app
|
||||
required: true
|
||||
- name: projectName
|
||||
message: Project name
|
||||
type: text
|
||||
defaultValue: my-project
|
||||
required: true
|
||||
|
||||
computed:
|
||||
- name: packagePath
|
||||
expression: "$str.replace($packageName, '.', '/')"
|
||||
- name: projectNameKebab
|
||||
expression: "$str.kebab($projectName)"
|
||||
- name: projectNamePascal
|
||||
expression: "$str.pascal($projectName)"
|
||||
|
||||
transforms: []
|
||||
postGen: []
|
||||
ignore: []
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
# $projectName
|
||||
|
||||
## Computed Variables Test
|
||||
|
||||
- Package Name: $packageName
|
||||
- Package Path: $packagePath
|
||||
- Kebab Case: $projectNameKebab
|
||||
- Pascal Case: $projectNamePascal
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package $packageName;
|
||||
|
||||
/**
|
||||
* Main application class.
|
||||
* Project: $projectName
|
||||
* Project (kebab): $projectNameKebab
|
||||
* Project (pascal): $projectNamePascal
|
||||
*/
|
||||
public class App
|
||||
{
|
||||
public static void main(String[] args)
|
||||
{
|
||||
System.out.println("Hello from $projectName!");
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
schemaVersion: 2
|
||||
id: test-full-substitution
|
||||
name: Full Substitution Test Template
|
||||
version: 1.0.0
|
||||
description: Template for testing variable substitution in file content
|
||||
|
||||
prompts:
|
||||
- name: projectName
|
||||
message: Project name
|
||||
type: text
|
||||
defaultValue: my-project
|
||||
required: true
|
||||
- name: packageName
|
||||
message: Package name
|
||||
type: text
|
||||
defaultValue: com.example
|
||||
required: true
|
||||
- name: groupId
|
||||
message: Maven group ID
|
||||
type: text
|
||||
defaultValue: com.example
|
||||
required: true
|
||||
- name: artifactId
|
||||
message: Maven artifact ID
|
||||
type: text
|
||||
defaultValue: my-app
|
||||
required: true
|
||||
- name: version
|
||||
message: Project version
|
||||
type: text
|
||||
defaultValue: 1.0.0-SNAPSHOT
|
||||
required: true
|
||||
|
||||
computed:
|
||||
- name: packagePath
|
||||
expression: "$str.replace($packageName, '.', '/')"
|
||||
|
||||
transforms: []
|
||||
postGen: []
|
||||
ignore: []
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
# $projectName
|
||||
|
||||
A project using package `$packageName`.
|
||||
|
||||
## Maven Coordinates
|
||||
|
||||
- Group ID: $groupId
|
||||
- Artifact ID: $artifactId
|
||||
- Version: $version
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>$groupId</groupId>
|
||||
<artifactId>$artifactId</artifactId>
|
||||
<version>$version</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>$projectName</name>
|
||||
<description>Generated project for $packageName</description>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
</project>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package $packageName;
|
||||
|
||||
/**
|
||||
* Main application class for $projectName.
|
||||
*/
|
||||
public class App
|
||||
{
|
||||
public static void main(String[] args)
|
||||
{
|
||||
System.out.println("Hello from $projectName!");
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
schemaVersion: 2
|
||||
id: test-minimal
|
||||
name: Minimal Test Template
|
||||
version: 1.0.0
|
||||
description: Minimal template for fast E2E tests
|
||||
prompts: []
|
||||
computed: []
|
||||
transforms: []
|
||||
postGen: []
|
||||
ignore: []
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# Minimal Test Project
|
||||
|
||||
This is a minimal test template with no variables.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
schemaVersion: 2
|
||||
id: test-transform-error
|
||||
name: Transform Error Test Template
|
||||
version: 1.0.0
|
||||
description: Template with invalid transform type
|
||||
|
||||
prompts: []
|
||||
computed: []
|
||||
|
||||
transforms:
|
||||
- type: bogus_transform_type
|
||||
pattern: "**/*.txt"
|
||||
|
||||
postGen: []
|
||||
ignore: []
|
||||
+1
@@ -0,0 +1 @@
|
||||
Test file for transform error testing.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
schemaVersion: 2
|
||||
id: test-transforms
|
||||
name: Transform Test Template
|
||||
version: 1.0.0
|
||||
description: Template for testing directory and file transforms
|
||||
|
||||
prompts:
|
||||
- name: packageName
|
||||
message: Package name
|
||||
type: text
|
||||
defaultValue: com.example
|
||||
required: true
|
||||
|
||||
computed:
|
||||
- name: packagePath
|
||||
expression: "$str.replace($packageName, '.', '/')"
|
||||
|
||||
transforms:
|
||||
- type: delete
|
||||
pattern: "**/.gitkeep"
|
||||
|
||||
postGen: []
|
||||
ignore: []
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# Transform Test
|
||||
|
||||
Package: $packageName
|
||||
+1
@@ -0,0 +1 @@
|
||||
# This file should be deleted by the delete transform
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package $packageName;
|
||||
|
||||
public class App
|
||||
{
|
||||
public static void main(String[] args)
|
||||
{
|
||||
System.out.println("Hello!");
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
schemaVersion: 2
|
||||
id: test-variable-typo
|
||||
name: Variable Typo Test Template
|
||||
version: 1.0.0
|
||||
description: Template with intentional variable typo for error testing
|
||||
|
||||
prompts:
|
||||
- name: projectName
|
||||
message: Project name
|
||||
type: text
|
||||
defaultValue: my-project
|
||||
required: true
|
||||
- name: packageName
|
||||
message: Package name
|
||||
type: text
|
||||
defaultValue: com.example
|
||||
required: true
|
||||
|
||||
computed: []
|
||||
transforms: []
|
||||
postGen: []
|
||||
ignore: []
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# $projetcName
|
||||
|
||||
This template has a typo in the variable name (projetcName instead of projectName).
|
||||
It should trigger the "Did you mean?" suggestion.
|
||||
|
||||
Package: $pacakgeName
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
schemaVersion: 2
|
||||
id: test-with-prompts
|
||||
name: Test Template with Prompts
|
||||
version: 1.0.0
|
||||
description: Template for testing interactive prompts
|
||||
|
||||
prompts:
|
||||
- name: projectName
|
||||
message: Project name
|
||||
type: text
|
||||
defaultValue: my-project
|
||||
required: true
|
||||
- name: packageName
|
||||
message: Package name
|
||||
type: text
|
||||
defaultValue: com.example
|
||||
required: true
|
||||
- name: includeTests
|
||||
message: Include tests?
|
||||
type: confirm
|
||||
defaultValue: "true"
|
||||
required: false
|
||||
|
||||
computed:
|
||||
- name: packagePath
|
||||
expression: "$str.replace($packageName, '.', '/')"
|
||||
|
||||
transforms: []
|
||||
postGen: []
|
||||
ignore: []
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# $projectName
|
||||
|
||||
Package: $packageName
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
schemaVersion: 2
|
||||
id: test-yaml-error
|
||||
name: YAML Error Test Template
|
||||
version: 1.0.0
|
||||
description: Template with intentional YAML syntax error
|
||||
|
||||
prompts:
|
||||
- name: projectName
|
||||
message: Project name
|
||||
- this_is_invalid # Intentional YAML error at line 9
|
||||
type: text
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
<parent>
|
||||
<groupId>io.qrun.qctl</groupId>
|
||||
<artifactId>qctl-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
<artifactId>qctl-qbit</artifactId>
|
||||
<name>qctl-qbit</name>
|
||||
|
||||
@@ -16,6 +16,7 @@ package io.qrun.qctl.qbit;
|
||||
import java.nio.file.Path;
|
||||
import io.qrun.qctl.qbit.lock.Lockfile;
|
||||
import io.qrun.qctl.qbit.lock.LockfileIO;
|
||||
import io.qrun.qctl.shared.ExitCodes;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
|
||||
@@ -64,7 +65,7 @@ public class ResolveCommand implements Runnable
|
||||
catch(Exception e)
|
||||
{
|
||||
System.err.println("qbit resolve error: " + e.getMessage());
|
||||
System.exit(1);
|
||||
System.exit(ExitCodes.GENERIC);
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-1
@@ -6,7 +6,7 @@
|
||||
<parent>
|
||||
<groupId>io.qrun.qctl</groupId>
|
||||
<artifactId>qctl-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
<artifactId>qctl-qqq</artifactId>
|
||||
<name>qctl-qqq</name>
|
||||
@@ -21,5 +21,56 @@
|
||||
<artifactId>qctl-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.qrun.qctl</groupId>
|
||||
<artifactId>qctl-shared</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.velocity</groupId>
|
||||
<artifactId>velocity-engine-core</artifactId>
|
||||
<version>${velocity.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>info.picocli</groupId>
|
||||
<artifactId>picocli-codegen</artifactId>
|
||||
<version>${picocli.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${junit.jupiter.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<version>${assertj.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>${mockito.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>info.picocli</groupId>
|
||||
<artifactId>picocli-codegen</artifactId>
|
||||
<version>${picocli.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,521 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.Callable;
|
||||
import io.qrun.qctl.core.doctor.DependencyChecker;
|
||||
import io.qrun.qctl.core.doctor.DependencyException;
|
||||
import io.qrun.qctl.qqq.error.ErrorFormatter;
|
||||
import io.qrun.qctl.qqq.error.SuggestionEngine;
|
||||
import io.qrun.qctl.qqq.error.TemplateError;
|
||||
import io.qrun.qctl.qqq.registry.TemplateInfo;
|
||||
import io.qrun.qctl.qqq.registry.TemplateRegistry;
|
||||
import io.qrun.qctl.qqq.registry.TemplateRegistryFactory;
|
||||
import io.qrun.qctl.qqq.template.ConsoleUI;
|
||||
import io.qrun.qctl.qqq.template.MergeOptions;
|
||||
import io.qrun.qctl.qqq.template.PostGenHookRunner;
|
||||
import io.qrun.qctl.qqq.template.PromptRunner;
|
||||
import io.qrun.qctl.qqq.template.TemplateEngine;
|
||||
import io.qrun.qctl.qqq.template.TemplateManifest;
|
||||
import io.qrun.qctl.qqq.template.TemplateRenderException;
|
||||
import io.qrun.qctl.qqq.template.TransformExecutor;
|
||||
import io.qrun.qctl.shared.ExitCodes;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Initialize a new project from a template.
|
||||
*
|
||||
* Supports template IDs from hub, local paths, git URLs, or github shorthand.
|
||||
* Renders Velocity templates, prompts for variables, and runs post-gen hooks.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*******************************************************************************/
|
||||
@Command(name = "init", description = "Initialize a new project from a template",
|
||||
mixinStandardHelpOptions = true)
|
||||
public class InitCommand implements Callable<Integer>
|
||||
{
|
||||
private static final String DEFAULT_TEMPLATE = "new-qqq-application";
|
||||
private static final String DEFAULT_PROJECT_DIR = "my-qqq-app";
|
||||
|
||||
@Parameters(
|
||||
index = "0",
|
||||
arity = "0..1",
|
||||
description = "Template name (default: ${DEFAULT-VALUE})",
|
||||
defaultValue = DEFAULT_TEMPLATE)
|
||||
String templateName;
|
||||
|
||||
@Option(names = {"-o", "--output"}, description = "Target directory for the new project")
|
||||
Path targetDir;
|
||||
|
||||
@Option(names = "--template-version", description = "Template version (semver tag)")
|
||||
String version;
|
||||
|
||||
@Option(names = "--var", description = "Set a template variable (key=value)")
|
||||
Map<String, String> variables = new LinkedHashMap<>();
|
||||
|
||||
@Option(names = "--force", description = "Overwrite target directory if it exists")
|
||||
boolean force;
|
||||
|
||||
@Option(names = "--dry-run", description = "Preview changes without writing files")
|
||||
boolean dryRun;
|
||||
|
||||
@Option(names = "--skip-hooks", description = "Skip post-generation hooks")
|
||||
boolean skipHooks;
|
||||
|
||||
@Option(names = "--no-prompt", description = "Disable interactive prompts (use defaults)")
|
||||
boolean noPrompt;
|
||||
|
||||
@Option(names = {"-v", "--verbose"}, description = "Show detailed error information")
|
||||
boolean verbose;
|
||||
|
||||
@Option(names = "--merge", description = "Merge with existing directory (skip existing files)")
|
||||
boolean merge;
|
||||
|
||||
@Option(names = "--include", description = "Include patterns for merge (overwrite matching files)",
|
||||
split = ",")
|
||||
List<String> includePatterns = new ArrayList<>();
|
||||
|
||||
@Option(names = "--exclude", description = "Exclude patterns for merge (protect matching files)",
|
||||
split = ",")
|
||||
List<String> excludePatterns = new ArrayList<>();
|
||||
|
||||
@Option(names = "--interactive", description = "Prompt for each conflicting file in merge mode")
|
||||
boolean interactive;
|
||||
|
||||
@Option(names = "--no-backup", description = "Disable backup creation when overwriting files")
|
||||
boolean noBackup;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Default constructor for picocli.
|
||||
*
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public InitCommand()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public Integer call()
|
||||
{
|
||||
ConsoleUI ui = new ConsoleUI();
|
||||
|
||||
try
|
||||
{
|
||||
ui.header("qctl qqq init");
|
||||
ui.subtitle("Initialize a new QQQ project");
|
||||
ui.println();
|
||||
|
||||
// Step 1: Template selection from registry
|
||||
TemplateRegistry registry = TemplateRegistryFactory.getRegistry();
|
||||
TemplateInfo templateInfo = selectTemplate(registry, ui);
|
||||
|
||||
// Step 2: Get target directory
|
||||
Path effectiveTargetDir = getTargetDirectory(ui);
|
||||
|
||||
// Check target directory
|
||||
if(Files.exists(effectiveTargetDir) && !force && !merge)
|
||||
{
|
||||
ui.error("target directory already exists: " + effectiveTargetDir);
|
||||
ui.println("Use --force to overwrite or --merge to merge with existing.");
|
||||
return ExitCodes.CONFLICT;
|
||||
}
|
||||
|
||||
// Step 3: Download template from registry
|
||||
ui.println();
|
||||
ui.info("Downloading template...");
|
||||
Path templatePath = registry.downloadTemplate(templateInfo.id(), version);
|
||||
|
||||
// Get manifest from template info
|
||||
TemplateManifest manifest = templateInfo.toManifest();
|
||||
|
||||
// Check minimum qctl version
|
||||
if(manifest.minimumQctlVersion() != null)
|
||||
{
|
||||
String currentVersion = getVersionFromProperties();
|
||||
if(!isVersionSatisfied(currentVersion, manifest.minimumQctlVersion()))
|
||||
{
|
||||
ui.error("Template requires qctl " + manifest.minimumQctlVersion() + " or later");
|
||||
ui.println("Current version: " + currentVersion);
|
||||
ui.println("Please upgrade qctl to use this template.");
|
||||
return ExitCodes.VALIDATION;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Collect variables (prompts + CLI overrides)
|
||||
Map<String, String> allVars = new LinkedHashMap<>();
|
||||
if(!noPrompt && manifest.prompts() != null && !manifest.prompts().isEmpty())
|
||||
{
|
||||
PromptRunner promptRunner = new PromptRunner();
|
||||
allVars.putAll(promptRunner.run(manifest.prompts(), variables));
|
||||
}
|
||||
allVars.putAll(variables);
|
||||
|
||||
// Step 5: Evaluate computed variables
|
||||
TemplateEngine engine = new TemplateEngine();
|
||||
allVars = new LinkedHashMap<>(engine.evaluateComputed(manifest.computed(), allVars));
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// Build merge options //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
MergeOptions mergeOptions = merge
|
||||
? new MergeOptions(true, includePatterns, excludePatterns, interactive, !noBackup)
|
||||
: MergeOptions.disabled();
|
||||
|
||||
if(dryRun)
|
||||
{
|
||||
ui.println();
|
||||
ui.println("[DRY-RUN] Would create files:");
|
||||
engine.renderDryRun(templatePath, effectiveTargetDir, allVars, manifest, mergeOptions);
|
||||
}
|
||||
else
|
||||
{
|
||||
if(force && Files.exists(effectiveTargetDir))
|
||||
{
|
||||
ui.info("Overwriting existing directory...");
|
||||
}
|
||||
if(merge && Files.exists(effectiveTargetDir))
|
||||
{
|
||||
ui.info("Merging with existing directory...");
|
||||
}
|
||||
Files.createDirectories(effectiveTargetDir);
|
||||
int fileCount = engine.render(templatePath, effectiveTargetDir, allVars, manifest,
|
||||
mergeOptions, ui);
|
||||
ui.println();
|
||||
ui.success("Created " + fileCount + " files in " + effectiveTargetDir);
|
||||
|
||||
// Run transforms (rename/delete)
|
||||
if(manifest.transforms() != null && !manifest.transforms().isEmpty())
|
||||
{
|
||||
ui.info("Applying transforms...");
|
||||
TransformExecutor transformExecutor = new TransformExecutor();
|
||||
transformExecutor.execute(effectiveTargetDir, manifest.transforms(), allVars);
|
||||
}
|
||||
|
||||
// Run post-gen hooks
|
||||
if(!skipHooks && manifest.postGen() != null)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// Pre-flight dependency checks before running hooks //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
try
|
||||
{
|
||||
checkHookDependencies(manifest.postGen(), ui);
|
||||
}
|
||||
catch(DependencyException e)
|
||||
{
|
||||
ui.warning("Skipping post-gen hooks: " + e.getMessage());
|
||||
ui.println("Run 'qctl doctor' for installation help.");
|
||||
skipHooks = true;
|
||||
}
|
||||
|
||||
if(!skipHooks)
|
||||
{
|
||||
PostGenHookRunner hookRunner = new PostGenHookRunner();
|
||||
hookRunner.run(effectiveTargetDir, manifest.postGen());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.println();
|
||||
ui.successBold("Project initialized successfully!");
|
||||
ui.println();
|
||||
ui.println("Next steps:");
|
||||
ui.println(" cd " + effectiveTargetDir);
|
||||
ui.println(" mvn clean verify");
|
||||
|
||||
return ExitCodes.SUCCESS;
|
||||
}
|
||||
catch(TemplateRenderException e)
|
||||
{
|
||||
ErrorFormatter formatter = new ErrorFormatter(verbose);
|
||||
TemplateError error = new TemplateError(
|
||||
TemplateError.ErrorCategory.RENDER_ERROR,
|
||||
e.getMessage(),
|
||||
"Check template syntax and ensure all variables are defined"
|
||||
);
|
||||
System.err.print(formatter.format(error));
|
||||
return error.getExitCode();
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
ErrorFormatter formatter = new ErrorFormatter(verbose);
|
||||
String hint = e instanceof UnknownHostException
|
||||
? "Check your network connection and try again"
|
||||
: null;
|
||||
System.err.print(formatter.format(e, hint));
|
||||
return ExitCodes.GENERIC;
|
||||
}
|
||||
catch(TemplateError e)
|
||||
{
|
||||
ErrorFormatter formatter = new ErrorFormatter(verbose);
|
||||
System.err.print(formatter.format(e));
|
||||
return e.getExitCode();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
ErrorFormatter formatter = new ErrorFormatter(verbose);
|
||||
System.err.print(formatter.format(e, null));
|
||||
return ExitCodes.GENERIC;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Select template - use provided name or prompt interactively.
|
||||
*
|
||||
* @param registry template registry
|
||||
* @param ui console UI
|
||||
* @return selected template info
|
||||
* @throws IOException if registry cannot be fetched
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
private TemplateInfo selectTemplate(TemplateRegistry registry, ConsoleUI ui) throws IOException
|
||||
{
|
||||
List<TemplateInfo> templates = registry.listTemplates();
|
||||
|
||||
// If template name provided and found, use it
|
||||
Optional<TemplateInfo> entry = registry.getTemplate(templateName);
|
||||
if(entry.isPresent())
|
||||
{
|
||||
TemplateInfo selected = entry.get();
|
||||
ui.showValue("Template", selected.name(), selected.id());
|
||||
return selected;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Template not found - show error with suggestions //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
List<String> templateIds = templates.stream().map(TemplateInfo::id).toList();
|
||||
SuggestionEngine suggestionEngine = new SuggestionEngine();
|
||||
List<String> suggestions = suggestionEngine.findSimilar(templateName, templateIds);
|
||||
|
||||
ui.error("Template '" + templateName + "' not found.");
|
||||
if(!suggestions.isEmpty())
|
||||
{
|
||||
ui.println();
|
||||
ui.println("Did you mean?");
|
||||
for(String suggestion : suggestions)
|
||||
{
|
||||
ui.println(" " + suggestion);
|
||||
}
|
||||
}
|
||||
ui.println();
|
||||
|
||||
// Find default index
|
||||
int defaultIndex = 0;
|
||||
for(int i = 0; i < templates.size(); i++)
|
||||
{
|
||||
if(DEFAULT_TEMPLATE.equals(templates.get(i).id()))
|
||||
{
|
||||
defaultIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ui.promptSelect(
|
||||
"Select a template:",
|
||||
templates,
|
||||
TemplateInfo::name,
|
||||
TemplateInfo::description,
|
||||
defaultIndex
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get target directory - use provided value or prompt interactively.
|
||||
*
|
||||
* @param ui console UI
|
||||
* @return target directory path
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
private Path getTargetDirectory(ConsoleUI ui)
|
||||
{
|
||||
if(targetDir != null)
|
||||
{
|
||||
ui.showValue("Project directory", targetDir.toString());
|
||||
return targetDir;
|
||||
}
|
||||
|
||||
if(noPrompt)
|
||||
{
|
||||
ui.showValue("Project directory", DEFAULT_PROJECT_DIR);
|
||||
return Path.of(DEFAULT_PROJECT_DIR);
|
||||
}
|
||||
|
||||
String input = ui.promptText("Project directory", DEFAULT_PROJECT_DIR);
|
||||
return Path.of(input);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get version from version.properties resource file.
|
||||
*
|
||||
* @return version string from properties or "0.0.0-dev" if not found
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private String getVersionFromProperties()
|
||||
{
|
||||
Properties props = new Properties();
|
||||
try(InputStream is = getClass().getResourceAsStream("/version.properties"))
|
||||
{
|
||||
if(is != null)
|
||||
{
|
||||
props.load(is);
|
||||
}
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
// Fall back to default
|
||||
}
|
||||
return props.getProperty("version", "0.0.0-dev");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if current version satisfies the minimum version requirement.
|
||||
*
|
||||
* @param current current version string (e.g., "0.2.0")
|
||||
* @param minimum minimum required version (e.g., "0.1.0")
|
||||
* @return true if current >= minimum
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private boolean isVersionSatisfied(String current, String minimum)
|
||||
{
|
||||
int[] currentParts = parseVersion(current);
|
||||
int[] minimumParts = parseVersion(minimum);
|
||||
|
||||
for(int i = 0; i < 3; i++)
|
||||
{
|
||||
if(currentParts[i] > minimumParts[i])
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if(currentParts[i] < minimumParts[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Parse a version string into major, minor, patch components.
|
||||
*
|
||||
* @param version version string (e.g., "1.2.3" or "1.2.3-dev")
|
||||
* @return array of [major, minor, patch]
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private int[] parseVersion(String version)
|
||||
{
|
||||
int[] parts = {0, 0, 0};
|
||||
if(version == null || version.isEmpty())
|
||||
{
|
||||
return parts;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Strip any suffix after dash (e.g., "1.2.3-dev" -> "1.2.3") //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
String cleanVersion = version.contains("-") ? version.split("-")[0] : version;
|
||||
String[] segments = cleanVersion.split("\\.");
|
||||
|
||||
for(int i = 0; i < Math.min(segments.length, 3); i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
parts[i] = Integer.parseInt(segments[i]);
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
parts[i] = 0;
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check dependencies required by post-gen hooks before running them.
|
||||
*
|
||||
* Examines hook commands and verifies required tools are installed:
|
||||
* - git commands require Git
|
||||
* - mvn commands require Java 21+ and Maven
|
||||
*
|
||||
* @param hooks list of post-gen hooks to check
|
||||
* @param ui console UI for status messages
|
||||
* @throws DependencyException if a required dependency is missing
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private void checkHookDependencies(List<TemplateManifest.PostGenHook> hooks, ConsoleUI ui)
|
||||
throws DependencyException
|
||||
{
|
||||
boolean needsGit = false;
|
||||
boolean needsMaven = false;
|
||||
|
||||
for(TemplateManifest.PostGenHook hook : hooks)
|
||||
{
|
||||
if(hook.command() == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
String cmd = hook.command().trim().toLowerCase();
|
||||
if(cmd.startsWith("git "))
|
||||
{
|
||||
needsGit = true;
|
||||
}
|
||||
if(cmd.startsWith("mvn ") || cmd.startsWith("./mvnw ") || cmd.contains("maven"))
|
||||
{
|
||||
needsMaven = true;
|
||||
}
|
||||
}
|
||||
|
||||
if(needsGit)
|
||||
{
|
||||
DependencyChecker.requireGit();
|
||||
}
|
||||
|
||||
if(needsMaven)
|
||||
{
|
||||
DependencyChecker.requireJava(21);
|
||||
DependencyChecker.requireMaven();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import io.qrun.qctl.qqq.error.ErrorFormatter;
|
||||
import io.qrun.qctl.qqq.registry.TemplateInfo;
|
||||
import io.qrun.qctl.qqq.registry.TemplateRegistry;
|
||||
import io.qrun.qctl.qqq.registry.TemplateRegistryFactory;
|
||||
import io.qrun.qctl.shared.ExitCodes;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* List available project templates from the registry.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*******************************************************************************/
|
||||
@Command(name = "list", description = "List available project templates")
|
||||
public class ListCommand implements Callable<Integer>
|
||||
{
|
||||
private static final String ANSI_BOLD = "\u001B[1m";
|
||||
private static final String ANSI_CYAN = "\u001B[36m";
|
||||
private static final String ANSI_DIM = "\u001B[2m";
|
||||
private static final String ANSI_GREEN = "\u001B[32m";
|
||||
private static final String ANSI_RESET = "\u001B[0m";
|
||||
|
||||
@Option(names = "--refresh", description = "Bypass cache and fetch fresh data")
|
||||
boolean refresh;
|
||||
|
||||
@Option(names = {"-v", "--verbose"}, description = "Show detailed error information")
|
||||
boolean verbose;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Default constructor for picocli.
|
||||
*
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public ListCommand()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public Integer call()
|
||||
{
|
||||
try
|
||||
{
|
||||
System.out.println("Fetching templates from registry...\n");
|
||||
TemplateRegistry registry = TemplateRegistryFactory.getRegistry();
|
||||
|
||||
if(refresh)
|
||||
{
|
||||
registry.refresh();
|
||||
}
|
||||
|
||||
List<TemplateInfo> templates = registry.listTemplates();
|
||||
|
||||
if(templates.isEmpty())
|
||||
{
|
||||
System.out.println("No templates available.");
|
||||
return ExitCodes.SUCCESS;
|
||||
}
|
||||
|
||||
System.out.println(ANSI_BOLD + "Available Templates:" + ANSI_RESET + "\n");
|
||||
|
||||
for(TemplateInfo template : templates)
|
||||
{
|
||||
System.out.println(ANSI_CYAN + " " + template.id() + ANSI_RESET
|
||||
+ " " + ANSI_GREEN + "v" + template.version() + ANSI_RESET);
|
||||
System.out.println(" " + template.name());
|
||||
System.out.println(" " + ANSI_DIM + template.description() + ANSI_RESET);
|
||||
if(template.tags() != null && !template.tags().isEmpty())
|
||||
{
|
||||
System.out.println(" Tags: " + String.join(", ", template.tags()));
|
||||
}
|
||||
System.out.println();
|
||||
}
|
||||
|
||||
System.out.println("Use: qctl qqq init <template-id> -o <target-dir>");
|
||||
|
||||
return ExitCodes.SUCCESS;
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
ErrorFormatter formatter = new ErrorFormatter(verbose);
|
||||
String hint = e instanceof UnknownHostException
|
||||
? "Check your network connection and try again"
|
||||
: "Run with --refresh to bypass cache";
|
||||
System.err.print(formatter.format(e, hint));
|
||||
return ExitCodes.NETWORK;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,16 @@
|
||||
package io.qrun.qctl.qqq;
|
||||
|
||||
|
||||
import io.qrun.qctl.core.VersionProvider;
|
||||
import picocli.CommandLine.Command;
|
||||
|
||||
|
||||
@Command(name = "qqq", description = "Scaffolding commands", mixinStandardHelpOptions = true)
|
||||
@Command(
|
||||
name = "qqq",
|
||||
description = "Scaffolding commands",
|
||||
mixinStandardHelpOptions = true,
|
||||
versionProvider = VersionProvider.class,
|
||||
subcommands = { InitCommand.class, ListCommand.class })
|
||||
public class QqqCommand implements Runnable
|
||||
{
|
||||
/***************************************************************************
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.error;
|
||||
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Formats errors with consistent styling and recovery suggestions.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class ErrorFormatter
|
||||
{
|
||||
private static final String ANSI_RED = "\u001B[31m";
|
||||
private static final String ANSI_YELLOW = "\u001B[33m";
|
||||
private static final String ANSI_CYAN = "\u001B[36m";
|
||||
private static final String ANSI_DIM = "\u001B[2m";
|
||||
private static final String ANSI_RESET = "\u001B[0m";
|
||||
|
||||
private final boolean verbose;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create an error formatter.
|
||||
*
|
||||
* @param verbose whether to include stack traces
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public ErrorFormatter(boolean verbose)
|
||||
{
|
||||
this.verbose = verbose;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Format a template error for display.
|
||||
*
|
||||
* @param error the template error
|
||||
* @return formatted error message
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String format(TemplateError error)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
// Error message
|
||||
sb.append(ANSI_RED).append("error: ").append(ANSI_RESET);
|
||||
sb.append(error.getMessage()).append("\n");
|
||||
|
||||
// Suggestions (did you mean?)
|
||||
if(error.hasSuggestions())
|
||||
{
|
||||
sb.append("\n");
|
||||
sb.append(ANSI_YELLOW).append("Did you mean?").append(ANSI_RESET).append("\n");
|
||||
for(String suggestion : error.getSuggestions())
|
||||
{
|
||||
sb.append(" ").append(ANSI_CYAN).append(suggestion).append(ANSI_RESET).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Hint
|
||||
if(error.getHint() != null)
|
||||
{
|
||||
sb.append("\n");
|
||||
sb.append(ANSI_DIM).append(error.getHint()).append(ANSI_RESET).append("\n");
|
||||
}
|
||||
|
||||
// Stack trace (verbose mode)
|
||||
if(verbose && error.getCause() != null)
|
||||
{
|
||||
sb.append("\n");
|
||||
sb.append(ANSI_DIM).append("Stack trace:").append(ANSI_RESET).append("\n");
|
||||
sb.append(getStackTrace(error.getCause()));
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Format a generic exception.
|
||||
*
|
||||
* @param error the exception
|
||||
* @param hint optional hint for recovery
|
||||
* @return formatted error message
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String format(Exception error, String hint)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
sb.append(ANSI_RED).append("error: ").append(ANSI_RESET);
|
||||
sb.append(error.getMessage()).append("\n");
|
||||
|
||||
if(hint != null)
|
||||
{
|
||||
sb.append("\n");
|
||||
sb.append(ANSI_DIM).append(hint).append(ANSI_RESET).append("\n");
|
||||
}
|
||||
|
||||
if(verbose)
|
||||
{
|
||||
sb.append("\n");
|
||||
sb.append(ANSI_DIM).append("Stack trace:").append(ANSI_RESET).append("\n");
|
||||
sb.append(getStackTrace(error));
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get stack trace as string.
|
||||
*
|
||||
* @param throwable the exception
|
||||
* @return stack trace string
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private String getStackTrace(Throwable throwable)
|
||||
{
|
||||
StringWriter sw = new StringWriter();
|
||||
PrintWriter pw = new PrintWriter(sw);
|
||||
throwable.printStackTrace(pw);
|
||||
return sw.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.error;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Provides fuzzy matching suggestions for typos and similar names.
|
||||
*
|
||||
* Uses Levenshtein distance and prefix matching to suggest alternatives.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class SuggestionEngine
|
||||
{
|
||||
private static final int MAX_SUGGESTIONS = 3;
|
||||
private static final int MAX_DISTANCE = 3;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Find similar strings from a list of candidates.
|
||||
*
|
||||
* @param input the user's input
|
||||
* @param candidates list of valid options
|
||||
* @return list of similar options, sorted by relevance
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public List<String> findSimilar(String input, List<String> candidates)
|
||||
{
|
||||
if(input == null || input.isEmpty() || candidates == null || candidates.isEmpty())
|
||||
{
|
||||
return List.of();
|
||||
}
|
||||
|
||||
String lowerInput = input.toLowerCase();
|
||||
List<ScoredCandidate> scored = new ArrayList<>();
|
||||
|
||||
for(String candidate : candidates)
|
||||
{
|
||||
int score = calculateScore(lowerInput, candidate.toLowerCase());
|
||||
if(score <= MAX_DISTANCE)
|
||||
{
|
||||
scored.add(new ScoredCandidate(candidate, score));
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort(Comparator.comparingInt(ScoredCandidate::score));
|
||||
|
||||
return scored.stream()
|
||||
.limit(MAX_SUGGESTIONS)
|
||||
.map(ScoredCandidate::value)
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Calculate similarity score (lower is better).
|
||||
*
|
||||
* Combines Levenshtein distance with prefix bonus.
|
||||
*
|
||||
* @param input user input (lowercase)
|
||||
* @param candidate candidate value (lowercase)
|
||||
* @return similarity score
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private int calculateScore(String input, String candidate)
|
||||
{
|
||||
int distance = levenshteinDistance(input, candidate);
|
||||
|
||||
// Bonus for prefix match (reduce distance)
|
||||
if(candidate.startsWith(input) || input.startsWith(candidate))
|
||||
{
|
||||
distance = Math.max(0, distance - 1);
|
||||
}
|
||||
|
||||
// Bonus for containing the input
|
||||
if(candidate.contains(input))
|
||||
{
|
||||
distance = Math.max(0, distance - 1);
|
||||
}
|
||||
|
||||
return distance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Calculate Levenshtein distance between two strings.
|
||||
*
|
||||
* @param s1 first string
|
||||
* @param s2 second string
|
||||
* @return edit distance
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private int levenshteinDistance(String s1, String s2)
|
||||
{
|
||||
int[][] dp = new int[s1.length() + 1][s2.length() + 1];
|
||||
|
||||
for(int i = 0; i <= s1.length(); i++)
|
||||
{
|
||||
dp[i][0] = i;
|
||||
}
|
||||
for(int j = 0; j <= s2.length(); j++)
|
||||
{
|
||||
dp[0][j] = j;
|
||||
}
|
||||
|
||||
for(int i = 1; i <= s1.length(); i++)
|
||||
{
|
||||
for(int j = 1; j <= s2.length(); j++)
|
||||
{
|
||||
int cost = s1.charAt(i - 1) == s2.charAt(j - 1) ? 0 : 1;
|
||||
dp[i][j] = Math.min(
|
||||
Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1),
|
||||
dp[i - 1][j - 1] + cost
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return dp[s1.length()][s2.length()];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Scored candidate for sorting.
|
||||
***************************************************************************/
|
||||
private record ScoredCandidate(String value, int score)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.error;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import io.qrun.qctl.shared.ExitCodes;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Structured error with category, message, and recovery suggestions.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class TemplateError extends RuntimeException
|
||||
{
|
||||
private final ErrorCategory category;
|
||||
private final List<String> suggestions;
|
||||
private final String hint;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Error categories mapping to exit codes.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public enum ErrorCategory
|
||||
{
|
||||
TEMPLATE_NOT_FOUND(ExitCodes.NOT_FOUND),
|
||||
VARIABLE_NOT_FOUND(ExitCodes.VALIDATION),
|
||||
MANIFEST_INVALID(ExitCodes.VALIDATION),
|
||||
RENDER_ERROR(ExitCodes.VALIDATION),
|
||||
TRANSFORM_ERROR(ExitCodes.VALIDATION),
|
||||
NETWORK_ERROR(ExitCodes.NETWORK),
|
||||
VERSION_MISMATCH(ExitCodes.VALIDATION),
|
||||
DIRECTORY_EXISTS(ExitCodes.CONFLICT),
|
||||
IO_ERROR(ExitCodes.GENERIC);
|
||||
|
||||
private final int exitCode;
|
||||
|
||||
|
||||
|
||||
/*************************************************************************
|
||||
* Create an error category with exit code.
|
||||
*
|
||||
* @param exitCode the exit code for this category
|
||||
* @since 0.2.0
|
||||
*************************************************************************/
|
||||
ErrorCategory(int exitCode)
|
||||
{
|
||||
this.exitCode = exitCode;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*************************************************************************
|
||||
* Get the exit code for this category.
|
||||
*
|
||||
* @return exit code
|
||||
* @since 0.2.0
|
||||
*************************************************************************/
|
||||
public int getExitCode()
|
||||
{
|
||||
return exitCode;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a template error.
|
||||
*
|
||||
* @param category error category
|
||||
* @param message error message
|
||||
* @param suggestions list of suggestions (e.g., similar names)
|
||||
* @param hint actionable hint for recovery
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TemplateError(ErrorCategory category, String message, List<String> suggestions, String hint)
|
||||
{
|
||||
super(message);
|
||||
this.category = category;
|
||||
this.suggestions = suggestions != null ? suggestions : List.of();
|
||||
this.hint = hint;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a template error without suggestions.
|
||||
*
|
||||
* @param category error category
|
||||
* @param message error message
|
||||
* @param hint actionable hint
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TemplateError(ErrorCategory category, String message, String hint)
|
||||
{
|
||||
this(category, message, null, hint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a template error with only message.
|
||||
*
|
||||
* @param category error category
|
||||
* @param message error message
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TemplateError(ErrorCategory category, String message)
|
||||
{
|
||||
this(category, message, null, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a template error wrapping a cause.
|
||||
*
|
||||
* @param category error category
|
||||
* @param message error message
|
||||
* @param cause the underlying cause
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TemplateError(ErrorCategory category, String message, Throwable cause)
|
||||
{
|
||||
super(message, cause);
|
||||
this.category = category;
|
||||
this.suggestions = List.of();
|
||||
this.hint = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get the error category.
|
||||
*
|
||||
* @return error category
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public ErrorCategory getCategory()
|
||||
{
|
||||
return category;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get the exit code for this error.
|
||||
*
|
||||
* @return exit code
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public int getExitCode()
|
||||
{
|
||||
return category.getExitCode();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get suggestions for recovery.
|
||||
*
|
||||
* @return list of suggestions
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public List<String> getSuggestions()
|
||||
{
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get actionable hint.
|
||||
*
|
||||
* @return hint or null
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String getHint()
|
||||
{
|
||||
return hint;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if error has suggestions.
|
||||
*
|
||||
* @return true if suggestions available
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public boolean hasSuggestions()
|
||||
{
|
||||
return !suggestions.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.registry;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import io.qrun.qctl.qqq.template.TemplateResolver;
|
||||
import io.qrun.qctl.qqq.template.TemplatesHub;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Template registry backed by GitHub templates-hub repository.
|
||||
*
|
||||
* Fetches template index from raw.githubusercontent.com and clones templates
|
||||
* from GitHub repositories. Includes caching with configurable TTL.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class GitHubTemplateRegistry implements TemplateRegistry
|
||||
{
|
||||
private static final Duration DEFAULT_CACHE_TTL = Duration.ofMinutes(15);
|
||||
|
||||
private final TemplatesHub hub;
|
||||
private final TemplateResolver resolver;
|
||||
private final Duration cacheTtl;
|
||||
|
||||
private List<TemplateInfo> cachedTemplates;
|
||||
private Instant cacheExpiry;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Construct with default cache TTL.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public GitHubTemplateRegistry()
|
||||
{
|
||||
this(DEFAULT_CACHE_TTL);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Construct with custom cache TTL.
|
||||
*
|
||||
* @param cacheTtl cache duration
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public GitHubTemplateRegistry(Duration cacheTtl)
|
||||
{
|
||||
this.hub = new TemplatesHub();
|
||||
this.resolver = new TemplateResolver();
|
||||
this.cacheTtl = cacheTtl;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public List<TemplateInfo> listTemplates() throws IOException
|
||||
{
|
||||
if(cachedTemplates != null && cacheExpiry != null && Instant.now().isBefore(cacheExpiry))
|
||||
{
|
||||
return cachedTemplates;
|
||||
}
|
||||
|
||||
TemplatesHub.TemplatesIndex index = hub.fetchIndex();
|
||||
if(index.templates() == null)
|
||||
{
|
||||
cachedTemplates = List.of();
|
||||
}
|
||||
else
|
||||
{
|
||||
cachedTemplates = index.templates().stream()
|
||||
.map(entry -> convertToTemplateInfo(entry, index.registry()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
cacheExpiry = Instant.now().plus(cacheTtl);
|
||||
return cachedTemplates;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public Optional<TemplateInfo> getTemplate(String id) throws IOException
|
||||
{
|
||||
return listTemplates().stream()
|
||||
.filter(t -> t.id().equals(id))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public Path downloadTemplate(String id, String version) throws IOException
|
||||
{
|
||||
Optional<TemplateInfo> template = getTemplate(id);
|
||||
if(template.isEmpty())
|
||||
{
|
||||
throw new IOException("Template not found: " + id);
|
||||
}
|
||||
|
||||
return resolver.resolve(template.get().downloadUrl(), version);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void refresh()
|
||||
{
|
||||
cachedTemplates = null;
|
||||
cacheExpiry = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Convert hub entry to TemplateInfo.
|
||||
*
|
||||
* @param entry hub template entry
|
||||
* @param registry base registry URL
|
||||
* @return template info
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private TemplateInfo convertToTemplateInfo(TemplatesHub.TemplateEntry entry, String registry)
|
||||
{
|
||||
return new TemplateInfo(
|
||||
entry.id(),
|
||||
entry.name(),
|
||||
entry.version(),
|
||||
entry.description(),
|
||||
entry.getGitUrl(registry),
|
||||
entry.schemaVersion(),
|
||||
entry.minimumQctlVersion(),
|
||||
entry.tags(),
|
||||
entry.maintainer(),
|
||||
entry.prompts(),
|
||||
entry.computed(),
|
||||
entry.transforms(),
|
||||
entry.postGen(),
|
||||
entry.ignore()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.registry;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import io.qrun.qctl.qqq.template.ComputedVariable;
|
||||
import io.qrun.qctl.qqq.template.TemplateManifest;
|
||||
import io.qrun.qctl.qqq.template.TemplateResolver;
|
||||
import io.qrun.qctl.qqq.template.Transform;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Mock template registry with embedded template data.
|
||||
*
|
||||
* Provides stable template listing without network dependencies.
|
||||
* Used for development, testing, and as a fallback when Voyage is unavailable.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class MockTemplateRegistry implements TemplateRegistry
|
||||
{
|
||||
private static final String GITHUB_REGISTRY = "https://github.com/QRun-IO";
|
||||
private static final List<TemplateInfo> MOCK_TEMPLATES = List.of(
|
||||
new TemplateInfo(
|
||||
"new-qqq-application",
|
||||
"QQQ Application Starter",
|
||||
"1.0.0",
|
||||
"Complete QQQ application with entities, database, and dashboard",
|
||||
GITHUB_REGISTRY + "/new-qqq-application-template.git",
|
||||
2,
|
||||
"0.2.0",
|
||||
List.of("qqq", "java", "starter"),
|
||||
"QRun-IO",
|
||||
List.of(
|
||||
new TemplateManifest.Prompt("projectName", "Project name", "text", "my-qqq-app", null, null, true),
|
||||
new TemplateManifest.Prompt("groupId", "Maven group ID", "text", "com.example", null, null, true),
|
||||
new TemplateManifest.Prompt("artifactId", "Maven artifact ID", "text", "my-qqq-app", null, null, true),
|
||||
new TemplateManifest.Prompt("packageName", "Java package name", "text", "com.example.myapp", null, null, true)
|
||||
),
|
||||
List.of(
|
||||
new ComputedVariable("packagePath", "$str.replace($packageName, '.', '/')")
|
||||
),
|
||||
List.of(
|
||||
new Transform("delete", "**/.gitkeep", null)
|
||||
),
|
||||
List.of(
|
||||
new TemplateManifest.PostGenHook("Initialize git repository", "git init", null, true, "init"),
|
||||
new TemplateManifest.PostGenHook("Build project", "mvn clean compile -DskipTests", null, true, "build")
|
||||
),
|
||||
List.of(".git", ".github", ".idea", "target", "*.iml", "LICENSE", "README.md")
|
||||
),
|
||||
new TemplateInfo(
|
||||
"qqq-web-api",
|
||||
"QQQ Web API",
|
||||
"1.0.0",
|
||||
"REST API application with Javalin middleware and OpenAPI docs",
|
||||
GITHUB_REGISTRY + "/qqq-web-api-template.git",
|
||||
2,
|
||||
"0.2.0",
|
||||
List.of("qqq", "java", "api", "rest"),
|
||||
"QRun-IO",
|
||||
List.of(
|
||||
new TemplateManifest.Prompt("projectName", "Project name", "text", "my-api", null, null, true),
|
||||
new TemplateManifest.Prompt("groupId", "Maven group ID", "text", "com.example", null, null, true),
|
||||
new TemplateManifest.Prompt("artifactId", "Maven artifact ID", "text", "my-api", null, null, true),
|
||||
new TemplateManifest.Prompt("packageName", "Java package name", "text", "com.example.api", null, null, true)
|
||||
),
|
||||
List.of(new ComputedVariable("packagePath", "$str.replace($packageName, '.', '/')")),
|
||||
List.of(new Transform("delete", "**/.gitkeep", null)),
|
||||
List.of(),
|
||||
List.of(".git", ".github", "target")
|
||||
),
|
||||
new TemplateInfo(
|
||||
"qqq-cli-tool",
|
||||
"QQQ CLI Tool",
|
||||
"1.0.0",
|
||||
"Command-line tool with Picocli and native image support",
|
||||
GITHUB_REGISTRY + "/qqq-cli-tool-template.git",
|
||||
2,
|
||||
"0.2.0",
|
||||
List.of("qqq", "java", "cli", "native"),
|
||||
"QRun-IO",
|
||||
List.of(
|
||||
new TemplateManifest.Prompt("projectName", "Project name", "text", "my-cli", null, null, true),
|
||||
new TemplateManifest.Prompt("groupId", "Maven group ID", "text", "com.example", null, null, true),
|
||||
new TemplateManifest.Prompt("artifactId", "Maven artifact ID", "text", "my-cli", null, null, true),
|
||||
new TemplateManifest.Prompt("packageName", "Java package name", "text", "com.example.cli", null, null, true),
|
||||
new TemplateManifest.Prompt("commandName", "Main command name", "text", "mycli", null, null, true)
|
||||
),
|
||||
List.of(new ComputedVariable("packagePath", "$str.replace($packageName, '.', '/')")),
|
||||
List.of(new Transform("delete", "**/.gitkeep", null)),
|
||||
List.of(),
|
||||
List.of(".git", ".github", "target")
|
||||
)
|
||||
);
|
||||
|
||||
private final TemplateResolver resolver;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Default constructor.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public MockTemplateRegistry()
|
||||
{
|
||||
this.resolver = new TemplateResolver();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public List<TemplateInfo> listTemplates()
|
||||
{
|
||||
return MOCK_TEMPLATES;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public Optional<TemplateInfo> getTemplate(String id)
|
||||
{
|
||||
return MOCK_TEMPLATES.stream()
|
||||
.filter(t -> t.id().equals(id))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public Path downloadTemplate(String id, String version) throws IOException
|
||||
{
|
||||
Optional<TemplateInfo> template = getTemplate(id);
|
||||
if(template.isEmpty())
|
||||
{
|
||||
throw new IOException("Template not found: " + id);
|
||||
}
|
||||
|
||||
return resolver.resolve(template.get().downloadUrl(), version);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void refresh()
|
||||
{
|
||||
// No-op for mock registry - data is embedded
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.registry;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import io.qrun.qctl.qqq.template.ComputedVariable;
|
||||
import io.qrun.qctl.qqq.template.TemplateManifest;
|
||||
import io.qrun.qctl.qqq.template.Transform;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Template metadata from the registry.
|
||||
*
|
||||
* Contains all information needed to display and initialize a template.
|
||||
*
|
||||
* @param id unique template identifier
|
||||
* @param name display name
|
||||
* @param version template version (semver)
|
||||
* @param description brief description
|
||||
* @param downloadUrl URL to fetch template archive or git repo
|
||||
* @param schemaVersion manifest schema version
|
||||
* @param minimumQctlVersion minimum qctl version required
|
||||
* @param tags categorization tags
|
||||
* @param maintainer template maintainer
|
||||
* @param prompts interactive prompts for template variables
|
||||
* @param computed computed variables derived from prompts
|
||||
* @param transforms file transforms (rename/delete)
|
||||
* @param postGen post-generation hooks
|
||||
* @param ignore patterns to ignore during rendering
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public record TemplateInfo(
|
||||
String id,
|
||||
String name,
|
||||
String version,
|
||||
String description,
|
||||
String downloadUrl,
|
||||
Integer schemaVersion,
|
||||
String minimumQctlVersion,
|
||||
List<String> tags,
|
||||
String maintainer,
|
||||
List<TemplateManifest.Prompt> prompts,
|
||||
List<ComputedVariable> computed,
|
||||
List<Transform> transforms,
|
||||
List<TemplateManifest.PostGenHook> postGen,
|
||||
List<String> ignore
|
||||
)
|
||||
{
|
||||
/***************************************************************************
|
||||
* Convert to TemplateManifest for rendering.
|
||||
*
|
||||
* @return template manifest
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TemplateManifest toManifest()
|
||||
{
|
||||
return new TemplateManifest(
|
||||
schemaVersion,
|
||||
id,
|
||||
name,
|
||||
version,
|
||||
description,
|
||||
minimumQctlVersion,
|
||||
prompts,
|
||||
computed,
|
||||
transforms,
|
||||
postGen,
|
||||
ignore
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.registry;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Registry for fetching and managing project templates.
|
||||
*
|
||||
* Provides abstraction over template sources (GitHub, Voyage API, mock).
|
||||
* Implementations handle caching, authentication, and download logic.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public interface TemplateRegistry
|
||||
{
|
||||
/***************************************************************************
|
||||
* List all available templates.
|
||||
*
|
||||
* @return list of template metadata
|
||||
* @throws IOException if registry cannot be reached
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
List<TemplateInfo> listTemplates() throws IOException;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get template by ID.
|
||||
*
|
||||
* @param id template identifier
|
||||
* @return template metadata if found
|
||||
* @throws IOException if registry cannot be reached
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
Optional<TemplateInfo> getTemplate(String id) throws IOException;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Download template to local path.
|
||||
*
|
||||
* @param id template identifier
|
||||
* @param version specific version or null for latest
|
||||
* @return path to downloaded template directory
|
||||
* @throws IOException if download fails
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
Path downloadTemplate(String id, String version) throws IOException;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Refresh the template cache.
|
||||
*
|
||||
* Forces a fresh fetch from the registry on next call.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
void refresh();
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.registry;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Factory for creating template registry instances.
|
||||
*
|
||||
* Selects the appropriate registry implementation based on configuration.
|
||||
* Currently supports: GitHub (default), Mock (for testing/offline).
|
||||
* Future: Voyage API registry.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public final class TemplateRegistryFactory
|
||||
{
|
||||
private static final String REGISTRY_TYPE_ENV = "QCTL_REGISTRY_TYPE";
|
||||
private static final String REGISTRY_TYPE_GITHUB = "github";
|
||||
private static final String REGISTRY_TYPE_MOCK = "mock";
|
||||
|
||||
private static TemplateRegistry instance;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Private constructor - utility class.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private TemplateRegistryFactory()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get the configured template registry.
|
||||
*
|
||||
* Registry type is determined by QCTL_REGISTRY_TYPE environment variable:
|
||||
* - "github" (default): GitHub templates-hub repository
|
||||
* - "mock": Embedded mock templates for testing/offline
|
||||
* - "voyage": Voyage API (future)
|
||||
*
|
||||
* @return template registry instance
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static TemplateRegistry getRegistry()
|
||||
{
|
||||
if(instance == null)
|
||||
{
|
||||
instance = createRegistry();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a new registry instance based on configuration.
|
||||
*
|
||||
* @return new template registry
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private static TemplateRegistry createRegistry()
|
||||
{
|
||||
String registryType = System.getenv(REGISTRY_TYPE_ENV);
|
||||
if(registryType == null || registryType.isBlank())
|
||||
{
|
||||
registryType = REGISTRY_TYPE_GITHUB;
|
||||
}
|
||||
|
||||
return switch(registryType.toLowerCase())
|
||||
{
|
||||
case REGISTRY_TYPE_MOCK -> new MockTemplateRegistry();
|
||||
case REGISTRY_TYPE_GITHUB -> new GitHubTemplateRegistry();
|
||||
default -> new GitHubTemplateRegistry();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Reset the cached registry instance.
|
||||
*
|
||||
* Used for testing to allow switching registry types.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static void reset()
|
||||
{
|
||||
instance = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Set a specific registry instance.
|
||||
*
|
||||
* Used for testing to inject mock registries.
|
||||
*
|
||||
* @param registry registry to use
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static void setRegistry(TemplateRegistry registry)
|
||||
{
|
||||
instance = registry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* A computed variable definition for template manifests.
|
||||
*
|
||||
* Computed variables are derived from other variables using Velocity expressions.
|
||||
* They are evaluated in order after prompts are collected.
|
||||
*
|
||||
* @param name variable name to create
|
||||
* @param expression Velocity expression to evaluate
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ComputedVariable(
|
||||
String name,
|
||||
String expression
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.io.Console;
|
||||
import java.util.List;
|
||||
import java.util.Scanner;
|
||||
import java.util.function.Function;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Console UI utilities for interactive prompts.
|
||||
*
|
||||
* Provides consistent styling and input handling for CLI interactions.
|
||||
* Falls back to stdin when console is unavailable.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*******************************************************************************/
|
||||
public class ConsoleUI
|
||||
{
|
||||
private static final String ANSI_BOLD = "\u001B[1m";
|
||||
private static final String ANSI_CYAN = "\u001B[36m";
|
||||
private static final String ANSI_RESET = "\u001B[0m";
|
||||
private static final String ANSI_DIM = "\u001B[2m";
|
||||
private static final String ANSI_GREEN = "\u001B[32m";
|
||||
private static final String ANSI_RED = "\u001B[31m";
|
||||
private static final String ANSI_YELLOW = "\u001B[33m";
|
||||
|
||||
private final Console console;
|
||||
private final Scanner scanner;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a new ConsoleUI.
|
||||
*
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public ConsoleUI()
|
||||
{
|
||||
this.console = System.console();
|
||||
this.scanner = new Scanner(System.in);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Print a header line.
|
||||
*
|
||||
* @param title the title text
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public void header(String title)
|
||||
{
|
||||
System.out.println();
|
||||
System.out.println(ANSI_BOLD + title + ANSI_RESET);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Print a subtitle/description.
|
||||
*
|
||||
* @param text the subtitle text
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public void subtitle(String text)
|
||||
{
|
||||
System.out.println(ANSI_DIM + text + ANSI_RESET);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Print an info message.
|
||||
*
|
||||
* @param text the message text
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public void info(String text)
|
||||
{
|
||||
System.out.println(ANSI_DIM + text + ANSI_RESET);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Print a success message.
|
||||
*
|
||||
* @param text the message text
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public void success(String text)
|
||||
{
|
||||
System.out.println(ANSI_GREEN + text + ANSI_RESET);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Print a bold success message.
|
||||
*
|
||||
* @param text the message text
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public void successBold(String text)
|
||||
{
|
||||
System.out.println(ANSI_GREEN + ANSI_BOLD + text + ANSI_RESET);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Print an error message.
|
||||
*
|
||||
* @param text the message text
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public void error(String text)
|
||||
{
|
||||
System.err.println("error: " + text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Print a validation error message inline.
|
||||
*
|
||||
* @param text the error message
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public void validationError(String text)
|
||||
{
|
||||
System.out.println(" " + ANSI_RED + "✗ " + text + ANSI_RESET);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Print a validation success indicator.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public void validationSuccess()
|
||||
{
|
||||
System.out.println(" " + ANSI_GREEN + "✓" + ANSI_RESET);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Print a warning message.
|
||||
*
|
||||
* @param text the warning message
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public void warning(String text)
|
||||
{
|
||||
System.out.println(ANSI_YELLOW + "warning: " + text + ANSI_RESET);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Print a blank line.
|
||||
*
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public void println()
|
||||
{
|
||||
System.out.println();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Print text.
|
||||
*
|
||||
* @param text the text to print
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public void println(String text)
|
||||
{
|
||||
System.out.println(text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Prompt for text input.
|
||||
*
|
||||
* @param label the prompt label
|
||||
* @param defaultValue default value (may be null)
|
||||
* @return user input or default
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public String promptText(String label, String defaultValue)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(ANSI_CYAN).append("? ").append(ANSI_RESET);
|
||||
sb.append(ANSI_BOLD).append(label).append(ANSI_RESET);
|
||||
if(defaultValue != null)
|
||||
{
|
||||
sb.append(" ").append(ANSI_DIM).append("(").append(defaultValue).append(")").append(ANSI_RESET);
|
||||
}
|
||||
sb.append(": ");
|
||||
|
||||
System.out.print(sb);
|
||||
|
||||
String input = readLine();
|
||||
if(input == null || input.isBlank())
|
||||
{
|
||||
return defaultValue != null ? defaultValue : "";
|
||||
}
|
||||
return input.trim();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Display a value that was pre-set (not prompted).
|
||||
*
|
||||
* @param label the label
|
||||
* @param value the value
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public void showValue(String label, String value)
|
||||
{
|
||||
System.out.println(ANSI_CYAN + "? " + ANSI_RESET + ANSI_BOLD + label + ANSI_RESET + ": " + value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Display a value with a dimmed annotation.
|
||||
*
|
||||
* @param label the label
|
||||
* @param value the value
|
||||
* @param annotation additional info in parentheses
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public void showValue(String label, String value, String annotation)
|
||||
{
|
||||
System.out.println(ANSI_CYAN + "? " + ANSI_RESET + ANSI_BOLD + label + ANSI_RESET
|
||||
+ ": " + value + " " + ANSI_DIM + "(" + annotation + ")" + ANSI_RESET);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Prompt for selection from a list.
|
||||
*
|
||||
* @param <T> the type of items
|
||||
* @param label the prompt label
|
||||
* @param items the list of items
|
||||
* @param displayFn function to get display text for each item
|
||||
* @param descriptionFn function to get description for each item (may return null)
|
||||
* @param defaultIndex default selection index (0-based)
|
||||
* @return selected item
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public <T> T promptSelect(String label,
|
||||
List<T> items,
|
||||
Function<T, String> displayFn,
|
||||
Function<T, String> descriptionFn,
|
||||
int defaultIndex)
|
||||
{
|
||||
System.out.println(ANSI_CYAN + "? " + ANSI_RESET + ANSI_BOLD + label + ANSI_RESET);
|
||||
|
||||
for(int i = 0; i < items.size(); i++)
|
||||
{
|
||||
T item = items.get(i);
|
||||
String display = displayFn.apply(item);
|
||||
String desc = descriptionFn != null ? descriptionFn.apply(item) : null;
|
||||
boolean isDefault = (i == defaultIndex);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(" ").append(i + 1).append(") ").append(display);
|
||||
if(desc != null)
|
||||
{
|
||||
sb.append(ANSI_DIM).append(" - ").append(desc).append(ANSI_RESET);
|
||||
}
|
||||
if(isDefault)
|
||||
{
|
||||
sb.append(" (default)");
|
||||
}
|
||||
System.out.println(sb);
|
||||
}
|
||||
|
||||
System.out.print(" Enter number [1-" + items.size() + "]: ");
|
||||
String input = readLine();
|
||||
|
||||
int selectedIndex = defaultIndex;
|
||||
if(input != null && !input.isBlank())
|
||||
{
|
||||
try
|
||||
{
|
||||
int parsed = Integer.parseInt(input.trim()) - 1;
|
||||
if(parsed >= 0 && parsed < items.size())
|
||||
{
|
||||
selectedIndex = parsed;
|
||||
}
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
// Use default
|
||||
}
|
||||
}
|
||||
|
||||
return items.get(selectedIndex);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Read a line from console or stdin.
|
||||
*
|
||||
* @return user input line
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
private String readLine()
|
||||
{
|
||||
if(console != null)
|
||||
{
|
||||
return console.readLine();
|
||||
}
|
||||
else if(scanner.hasNextLine())
|
||||
{
|
||||
return scanner.nextLine();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Generates unified diff output for file comparison.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class FileDiffer
|
||||
{
|
||||
private static final String ANSI_RED = "\u001B[31m";
|
||||
private static final String ANSI_GREEN = "\u001B[32m";
|
||||
private static final String ANSI_CYAN = "\u001B[36m";
|
||||
private static final String ANSI_RESET = "\u001B[0m";
|
||||
|
||||
private static final int CONTEXT_LINES = 3;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Generate a unified diff between two strings.
|
||||
*
|
||||
* @param existing the existing file content
|
||||
* @param updated the new file content
|
||||
* @param filename the filename for the header
|
||||
* @return formatted diff output
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String diff(String existing, String updated, String filename)
|
||||
{
|
||||
String[] oldLines = existing.split("\n", -1);
|
||||
String[] newLines = updated.split("\n", -1);
|
||||
|
||||
List<DiffLine> diffLines = computeDiff(oldLines, newLines);
|
||||
|
||||
return formatDiff(diffLines, filename, oldLines.length, newLines.length);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Compute the diff between two arrays of lines.
|
||||
*
|
||||
* @param oldLines original lines
|
||||
* @param newLines new lines
|
||||
* @return list of diff lines
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private List<DiffLine> computeDiff(String[] oldLines, String[] newLines)
|
||||
{
|
||||
List<DiffLine> result = new ArrayList<>();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Simple line-by-line diff using longest common subsequence //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
int[][] lcs = computeLCS(oldLines, newLines);
|
||||
int i = oldLines.length;
|
||||
int j = newLines.length;
|
||||
|
||||
List<DiffLine> tempResult = new ArrayList<>();
|
||||
|
||||
while(i > 0 || j > 0)
|
||||
{
|
||||
if(i > 0 && j > 0 && oldLines[i - 1].equals(newLines[j - 1]))
|
||||
{
|
||||
tempResult.add(new DiffLine(DiffType.CONTEXT, oldLines[i - 1], i, j));
|
||||
i--;
|
||||
j--;
|
||||
}
|
||||
else if(j > 0 && (i == 0 || lcs[i][j - 1] >= lcs[i - 1][j]))
|
||||
{
|
||||
tempResult.add(new DiffLine(DiffType.ADD, newLines[j - 1], -1, j));
|
||||
j--;
|
||||
}
|
||||
else
|
||||
{
|
||||
tempResult.add(new DiffLine(DiffType.DELETE, oldLines[i - 1], i, -1));
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Reverse to get correct order //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
for(int k = tempResult.size() - 1; k >= 0; k--)
|
||||
{
|
||||
result.add(tempResult.get(k));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Compute the longest common subsequence matrix.
|
||||
*
|
||||
* @param oldLines original lines
|
||||
* @param newLines new lines
|
||||
* @return LCS matrix
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private int[][] computeLCS(String[] oldLines, String[] newLines)
|
||||
{
|
||||
int m = oldLines.length;
|
||||
int n = newLines.length;
|
||||
int[][] lcs = new int[m + 1][n + 1];
|
||||
|
||||
for(int i = 1; i <= m; i++)
|
||||
{
|
||||
for(int j = 1; j <= n; j++)
|
||||
{
|
||||
if(oldLines[i - 1].equals(newLines[j - 1]))
|
||||
{
|
||||
lcs[i][j] = lcs[i - 1][j - 1] + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lcs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Format the diff lines into unified diff output.
|
||||
*
|
||||
* @param diffLines the diff lines
|
||||
* @param filename the filename
|
||||
* @param oldCount original line count
|
||||
* @param newCount new line count
|
||||
* @return formatted diff string
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private String formatDiff(List<DiffLine> diffLines, String filename,
|
||||
int oldCount, int newCount)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Header //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
sb.append(ANSI_RED).append("--- existing ").append(filename)
|
||||
.append(ANSI_RESET).append("\n");
|
||||
sb.append(ANSI_GREEN).append("+++ template ").append(filename)
|
||||
.append(ANSI_RESET).append("\n");
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Find hunks (groups of changes with context) //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
List<Hunk> hunks = findHunks(diffLines);
|
||||
|
||||
for(Hunk hunk : hunks)
|
||||
{
|
||||
sb.append(ANSI_CYAN)
|
||||
.append("@@ -").append(hunk.oldStart).append(",").append(hunk.oldCount)
|
||||
.append(" +").append(hunk.newStart).append(",").append(hunk.newCount)
|
||||
.append(" @@").append(ANSI_RESET).append("\n");
|
||||
|
||||
for(DiffLine line : hunk.lines)
|
||||
{
|
||||
switch(line.type)
|
||||
{
|
||||
case ADD:
|
||||
sb.append(ANSI_GREEN).append("+").append(line.content)
|
||||
.append(ANSI_RESET).append("\n");
|
||||
break;
|
||||
case DELETE:
|
||||
sb.append(ANSI_RED).append("-").append(line.content)
|
||||
.append(ANSI_RESET).append("\n");
|
||||
break;
|
||||
case CONTEXT:
|
||||
sb.append(" ").append(line.content).append("\n");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(hunks.isEmpty())
|
||||
{
|
||||
sb.append("(no differences)\n");
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Find hunks (groups of changes with surrounding context).
|
||||
*
|
||||
* @param diffLines all diff lines
|
||||
* @return list of hunks
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private List<Hunk> findHunks(List<DiffLine> diffLines)
|
||||
{
|
||||
List<Hunk> hunks = new ArrayList<>();
|
||||
List<Integer> changeIndices = new ArrayList<>();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Find all change indices //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
for(int i = 0; i < diffLines.size(); i++)
|
||||
{
|
||||
if(diffLines.get(i).type != DiffType.CONTEXT)
|
||||
{
|
||||
changeIndices.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if(changeIndices.isEmpty())
|
||||
{
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Group changes into hunks //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
int hunkStart = Math.max(0, changeIndices.get(0) - CONTEXT_LINES);
|
||||
int hunkEnd = Math.min(diffLines.size() - 1, changeIndices.get(0) + CONTEXT_LINES);
|
||||
|
||||
for(int i = 1; i < changeIndices.size(); i++)
|
||||
{
|
||||
int changeIdx = changeIndices.get(i);
|
||||
int changeStart = changeIdx - CONTEXT_LINES;
|
||||
|
||||
if(changeStart <= hunkEnd + 1)
|
||||
{
|
||||
hunkEnd = Math.min(diffLines.size() - 1, changeIdx + CONTEXT_LINES);
|
||||
}
|
||||
else
|
||||
{
|
||||
hunks.add(createHunk(diffLines, hunkStart, hunkEnd));
|
||||
hunkStart = Math.max(0, changeIdx - CONTEXT_LINES);
|
||||
hunkEnd = Math.min(diffLines.size() - 1, changeIdx + CONTEXT_LINES);
|
||||
}
|
||||
}
|
||||
|
||||
hunks.add(createHunk(diffLines, hunkStart, hunkEnd));
|
||||
|
||||
return hunks;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a hunk from a range of diff lines.
|
||||
*
|
||||
* @param diffLines all diff lines
|
||||
* @param start start index
|
||||
* @param end end index
|
||||
* @return the hunk
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private Hunk createHunk(List<DiffLine> diffLines, int start, int end)
|
||||
{
|
||||
List<DiffLine> hunkLines = new ArrayList<>();
|
||||
int oldStart = 1;
|
||||
int oldCount = 0;
|
||||
int newStart = 1;
|
||||
int newCount = 0;
|
||||
|
||||
boolean foundOldStart = false;
|
||||
boolean foundNewStart = false;
|
||||
|
||||
for(int i = start; i <= end && i < diffLines.size(); i++)
|
||||
{
|
||||
DiffLine line = diffLines.get(i);
|
||||
hunkLines.add(line);
|
||||
|
||||
switch(line.type)
|
||||
{
|
||||
case CONTEXT:
|
||||
if(!foundOldStart && line.oldLine > 0)
|
||||
{
|
||||
oldStart = line.oldLine;
|
||||
foundOldStart = true;
|
||||
}
|
||||
if(!foundNewStart && line.newLine > 0)
|
||||
{
|
||||
newStart = line.newLine;
|
||||
foundNewStart = true;
|
||||
}
|
||||
oldCount++;
|
||||
newCount++;
|
||||
break;
|
||||
case DELETE:
|
||||
if(!foundOldStart && line.oldLine > 0)
|
||||
{
|
||||
oldStart = line.oldLine;
|
||||
foundOldStart = true;
|
||||
}
|
||||
oldCount++;
|
||||
break;
|
||||
case ADD:
|
||||
if(!foundNewStart && line.newLine > 0)
|
||||
{
|
||||
newStart = line.newLine;
|
||||
foundNewStart = true;
|
||||
}
|
||||
newCount++;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new Hunk(hunkLines, oldStart, oldCount, newStart, newCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Types of diff lines.
|
||||
***************************************************************************/
|
||||
private enum DiffType
|
||||
{
|
||||
CONTEXT,
|
||||
ADD,
|
||||
DELETE
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* A single line in the diff.
|
||||
***************************************************************************/
|
||||
private record DiffLine(DiffType type, String content, int oldLine, int newLine)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* A hunk (group of changes with context).
|
||||
***************************************************************************/
|
||||
private record Hunk(List<DiffLine> lines, int oldStart, int oldCount,
|
||||
int newStart, int newCount)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Configuration options for merge mode during template rendering.
|
||||
*
|
||||
* @param enabled whether merge mode is active
|
||||
* @param includePatterns glob patterns for files to include (overwrite if exist)
|
||||
* @param excludePatterns glob patterns for files to exclude (never overwrite)
|
||||
* @param interactive whether to prompt for each conflicting file
|
||||
* @param createBackups whether to create backups before overwriting
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public record MergeOptions(
|
||||
boolean enabled,
|
||||
List<String> includePatterns,
|
||||
List<String> excludePatterns,
|
||||
boolean interactive,
|
||||
boolean createBackups
|
||||
)
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
* Create disabled merge options (normal overwrite behavior).
|
||||
*
|
||||
* @return disabled merge options
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static MergeOptions disabled()
|
||||
{
|
||||
return new MergeOptions(false, List.of(), List.of(), false, false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create default merge options (skip existing, create backups).
|
||||
*
|
||||
* @return default merge options
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static MergeOptions defaults()
|
||||
{
|
||||
return new MergeOptions(true, List.of(), List.of(), false, true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if a file path matches the include patterns.
|
||||
*
|
||||
* @param relativePath the relative file path to check
|
||||
* @return true if the file matches include patterns (or no patterns defined)
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public boolean matchesInclude(String relativePath)
|
||||
{
|
||||
if(includePatterns == null || includePatterns.isEmpty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return matchesAnyPattern(relativePath, includePatterns);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if a file path matches the exclude patterns.
|
||||
*
|
||||
* @param relativePath the relative file path to check
|
||||
* @return true if the file matches exclude patterns
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public boolean matchesExclude(String relativePath)
|
||||
{
|
||||
if(excludePatterns == null || excludePatterns.isEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return matchesAnyPattern(relativePath, excludePatterns);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if a file path matches any of the given glob patterns.
|
||||
*
|
||||
* @param relativePath the relative file path to check
|
||||
* @param patterns the glob patterns to match against
|
||||
* @return true if the file matches any pattern
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private boolean matchesAnyPattern(String relativePath, List<String> patterns)
|
||||
{
|
||||
for(String pattern : patterns)
|
||||
{
|
||||
if(matchesGlob(relativePath, pattern))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Match a path against a glob pattern.
|
||||
*
|
||||
* @param path the path to check
|
||||
* @param pattern the glob pattern
|
||||
* @return true if the path matches
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private boolean matchesGlob(String path, String pattern)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Convert glob to regex: //
|
||||
// ** matches any path segments //
|
||||
// * matches any characters except path separator //
|
||||
// ? matches single character //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
String regex = pattern
|
||||
.replace(".", "\\.")
|
||||
.replace("**", "<<<DOUBLESTAR>>>")
|
||||
.replace("*", "[^/]*")
|
||||
.replace("<<<DOUBLESTAR>>>", ".*")
|
||||
.replace("?", ".");
|
||||
|
||||
return path.matches(regex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Handles file conflict resolution during merge operations.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class MergeStrategy
|
||||
{
|
||||
private static final DateTimeFormatter BACKUP_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss");
|
||||
|
||||
private final MergeOptions options;
|
||||
private final ConsoleUI ui;
|
||||
private final FileDiffer differ;
|
||||
|
||||
private Boolean overwriteAll = null;
|
||||
private Boolean skipAll = null;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a merge strategy with the given options.
|
||||
*
|
||||
* @param options merge configuration
|
||||
* @param ui console UI for interactive prompts
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public MergeStrategy(MergeOptions options, ConsoleUI ui)
|
||||
{
|
||||
this.options = options;
|
||||
this.ui = ui;
|
||||
this.differ = new FileDiffer();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Determine the action for a file during merge.
|
||||
*
|
||||
* @param relativePath relative path of the file
|
||||
* @param targetPath full target path
|
||||
* @param newContent the new content to be written
|
||||
* @return the merge action to take
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public MergeAction determineAction(String relativePath, Path targetPath, String newContent)
|
||||
throws IOException
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// If merge mode is disabled, always write //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(!options.enabled())
|
||||
{
|
||||
return MergeAction.WRITE;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Check exclude patterns first //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(options.matchesExclude(relativePath))
|
||||
{
|
||||
return MergeAction.SKIP;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// If file doesn't exist, always write //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(!Files.exists(targetPath))
|
||||
{
|
||||
return MergeAction.WRITE;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// File exists - check include patterns //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
boolean hasIncludePatterns = options.includePatterns() != null
|
||||
&& !options.includePatterns().isEmpty();
|
||||
|
||||
if(hasIncludePatterns)
|
||||
{
|
||||
if(options.matchesInclude(relativePath))
|
||||
{
|
||||
return handleConflict(relativePath, targetPath, newContent);
|
||||
}
|
||||
else
|
||||
{
|
||||
return MergeAction.SKIP;
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// No include patterns - skip existing files by default //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
return MergeAction.SKIP;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Handle a file conflict (file exists and should be considered).
|
||||
*
|
||||
* @param relativePath relative path of the file
|
||||
* @param targetPath full target path
|
||||
* @param newContent the new content
|
||||
* @return the merge action
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private MergeAction handleConflict(String relativePath, Path targetPath, String newContent)
|
||||
throws IOException
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Check if user chose "all" option previously //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(Boolean.TRUE.equals(overwriteAll))
|
||||
{
|
||||
return MergeAction.OVERWRITE;
|
||||
}
|
||||
if(Boolean.TRUE.equals(skipAll))
|
||||
{
|
||||
return MergeAction.SKIP;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Interactive mode - prompt user //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(options.interactive())
|
||||
{
|
||||
return promptForAction(relativePath, targetPath, newContent);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Non-interactive with include pattern - overwrite //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
return MergeAction.OVERWRITE;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Prompt user for action on conflicting file.
|
||||
*
|
||||
* @param relativePath relative path of the file
|
||||
* @param targetPath full target path
|
||||
* @param newContent the new content
|
||||
* @return the merge action
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private MergeAction promptForAction(String relativePath, Path targetPath, String newContent)
|
||||
throws IOException
|
||||
{
|
||||
String existingContent = Files.readString(targetPath);
|
||||
|
||||
while(true)
|
||||
{
|
||||
ui.println();
|
||||
ui.warning("File exists: " + relativePath);
|
||||
ui.println(" [o] Overwrite");
|
||||
ui.println(" [s] Skip");
|
||||
ui.println(" [d] Diff (show changes)");
|
||||
ui.println(" [a] Overwrite all remaining");
|
||||
ui.println(" [n] Skip all remaining");
|
||||
ui.println();
|
||||
|
||||
String choice = ui.promptText("Choice", "s").toLowerCase();
|
||||
|
||||
switch(choice)
|
||||
{
|
||||
case "o":
|
||||
return MergeAction.OVERWRITE;
|
||||
|
||||
case "s":
|
||||
return MergeAction.SKIP;
|
||||
|
||||
case "d":
|
||||
String diff = differ.diff(existingContent, newContent, relativePath);
|
||||
ui.println();
|
||||
ui.println(diff);
|
||||
break;
|
||||
|
||||
case "a":
|
||||
overwriteAll = true;
|
||||
return MergeAction.OVERWRITE;
|
||||
|
||||
case "n":
|
||||
skipAll = true;
|
||||
return MergeAction.SKIP;
|
||||
|
||||
default:
|
||||
ui.error("Invalid choice. Please enter o, s, d, a, or n.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a backup of the existing file before overwriting.
|
||||
*
|
||||
* @param targetPath the file to backup
|
||||
* @throws IOException if backup fails
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public void createBackup(Path targetPath) throws IOException
|
||||
{
|
||||
if(!options.createBackups() || !Files.exists(targetPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
String timestamp = LocalDateTime.now().format(BACKUP_FORMATTER);
|
||||
String backupName = targetPath.getFileName().toString() + "." + timestamp + ".bak";
|
||||
Path backupPath = targetPath.resolveSibling(backupName);
|
||||
|
||||
Files.copy(targetPath, backupPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Actions that can be taken for a file during merge.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public enum MergeAction
|
||||
{
|
||||
/*************************************************************************
|
||||
* Write the file (new file, no conflict).
|
||||
*************************************************************************/
|
||||
WRITE,
|
||||
|
||||
/*************************************************************************
|
||||
* Overwrite existing file.
|
||||
*************************************************************************/
|
||||
OVERWRITE,
|
||||
|
||||
/*************************************************************************
|
||||
* Skip the file (keep existing).
|
||||
*************************************************************************/
|
||||
SKIP
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Runs post-generation hooks after template rendering.
|
||||
*
|
||||
* Executes commands like mvn compile, git init, npm install.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*******************************************************************************/
|
||||
public class PostGenHookRunner
|
||||
{
|
||||
private static final String ANSI_BOLD = "\u001B[1m";
|
||||
private static final String ANSI_GREEN = "\u001B[32m";
|
||||
private static final String ANSI_RED = "\u001B[31m";
|
||||
private static final String ANSI_RESET = "\u001B[0m";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Run post-generation hooks.
|
||||
*
|
||||
* @param projectDir generated project directory
|
||||
* @param hooks list of hooks to run
|
||||
* @throws IOException if a required hook fails
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public void run(Path projectDir, List<TemplateManifest.PostGenHook> hooks)
|
||||
throws IOException
|
||||
{
|
||||
System.out.println("\n" + ANSI_BOLD + "Running post-generation hooks:" + ANSI_RESET);
|
||||
|
||||
for(TemplateManifest.PostGenHook hook : hooks)
|
||||
{
|
||||
runHook(projectDir, hook);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Run a single hook.
|
||||
*
|
||||
* @param projectDir project directory
|
||||
* @param hook hook definition
|
||||
* @throws IOException if required hook fails
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
private void runHook(Path projectDir, TemplateManifest.PostGenHook hook)
|
||||
throws IOException
|
||||
{
|
||||
String name = hook.name() != null ? hook.name() : hook.command();
|
||||
boolean optional = hook.optional() != null && hook.optional();
|
||||
|
||||
System.out.println("\n " + ANSI_BOLD + name + ANSI_RESET);
|
||||
System.out.println(" $ " + hook.command());
|
||||
|
||||
Path workDir = projectDir;
|
||||
if(hook.workDir() != null && !hook.workDir().isBlank())
|
||||
{
|
||||
workDir = projectDir.resolve(hook.workDir());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ProcessBuilder pb = createProcessBuilder(hook.command());
|
||||
pb.directory(workDir.toFile());
|
||||
pb.inheritIO();
|
||||
|
||||
Process process = pb.start();
|
||||
int exitCode = process.waitFor();
|
||||
|
||||
if(exitCode == 0)
|
||||
{
|
||||
System.out.println(" " + ANSI_GREEN + "OK" + ANSI_RESET);
|
||||
}
|
||||
else
|
||||
{
|
||||
System.out.println(" " + ANSI_RED + "FAILED (exit " + exitCode + ")" + ANSI_RESET);
|
||||
if(!optional)
|
||||
{
|
||||
throw new IOException("Hook failed: " + name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Hook interrupted: " + name, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a ProcessBuilder for a command.
|
||||
*
|
||||
* @param command command string
|
||||
* @return process builder
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
private ProcessBuilder createProcessBuilder(String command)
|
||||
{
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
if(os.contains("win"))
|
||||
{
|
||||
return new ProcessBuilder("cmd", "/c", command);
|
||||
}
|
||||
else
|
||||
{
|
||||
return new ProcessBuilder("sh", "-c", command);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Runs interactive prompts to collect template variables.
|
||||
*
|
||||
* Uses ConsoleUI for all interactive input. Supports text input and selections.
|
||||
* Validates input against prompt rules and re-prompts on failure.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*******************************************************************************/
|
||||
public class PromptRunner
|
||||
{
|
||||
private final ConsoleUI ui;
|
||||
private final PromptValidator validator;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a new PromptRunner with default ConsoleUI.
|
||||
*
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public PromptRunner()
|
||||
{
|
||||
this.ui = new ConsoleUI();
|
||||
this.validator = new PromptValidator();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a new PromptRunner with provided ConsoleUI.
|
||||
*
|
||||
* @param ui the console UI to use
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public PromptRunner(ConsoleUI ui)
|
||||
{
|
||||
this.ui = ui;
|
||||
this.validator = new PromptValidator();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Run prompts and collect user input.
|
||||
*
|
||||
* @param prompts list of prompt definitions
|
||||
* @param overrides CLI-provided variable overrides
|
||||
* @return collected variables
|
||||
* @throws IOException if prompt interaction fails
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public Map<String, String> run(List<TemplateManifest.Prompt> prompts,
|
||||
Map<String, String> overrides) throws IOException
|
||||
{
|
||||
Map<String, String> results = new LinkedHashMap<>();
|
||||
|
||||
ui.println();
|
||||
ui.header("Configure your project:");
|
||||
ui.println();
|
||||
|
||||
for(TemplateManifest.Prompt prompt : prompts)
|
||||
{
|
||||
// Check if provided via CLI
|
||||
if(overrides.containsKey(prompt.name()))
|
||||
{
|
||||
String value = overrides.get(prompt.name());
|
||||
String label = prompt.message() != null ? prompt.message() : prompt.name();
|
||||
PromptValidator.ValidationResult result = validator.validate(prompt, value);
|
||||
|
||||
if(result.valid())
|
||||
{
|
||||
ui.showValue(label, value);
|
||||
results.put(prompt.name(), value);
|
||||
}
|
||||
else
|
||||
{
|
||||
ui.showValue(label, value);
|
||||
ui.validationError(result.errorMessage());
|
||||
ui.warning("Invalid --var value, prompting interactively");
|
||||
String corrected = promptForValue(prompt);
|
||||
results.put(prompt.name(), corrected);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
String value = promptForValue(prompt);
|
||||
results.put(prompt.name(), value);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Prompt for a single value with validation.
|
||||
*
|
||||
* Re-prompts on validation failure until valid input is provided.
|
||||
*
|
||||
* @param prompt prompt definition
|
||||
* @return validated user input value
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
private String promptForValue(TemplateManifest.Prompt prompt)
|
||||
{
|
||||
String type = prompt.type() != null ? prompt.type() : "text";
|
||||
String message = prompt.message() != null ? prompt.message() : prompt.name();
|
||||
|
||||
if("select".equals(type) && prompt.choices() != null)
|
||||
{
|
||||
return promptSelect(message, prompt.choices(), prompt.defaultValue());
|
||||
}
|
||||
|
||||
boolean hasValidation = prompt.isRequired() || prompt.validation() != null;
|
||||
|
||||
while(true)
|
||||
{
|
||||
String value = ui.promptText(message, prompt.defaultValue());
|
||||
PromptValidator.ValidationResult result = validator.validate(prompt, value);
|
||||
|
||||
if(result.valid())
|
||||
{
|
||||
if(hasValidation)
|
||||
{
|
||||
ui.validationSuccess();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
ui.validationError(result.errorMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Prompt for selection from choices.
|
||||
*
|
||||
* @param message the prompt message
|
||||
* @param choices list of choices
|
||||
* @param defaultValue default choice
|
||||
* @return selected value
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
private String promptSelect(String message, List<String> choices, String defaultValue)
|
||||
{
|
||||
int defaultIndex = 0;
|
||||
if(defaultValue != null)
|
||||
{
|
||||
for(int i = 0; i < choices.size(); i++)
|
||||
{
|
||||
if(defaultValue.equals(choices.get(i)))
|
||||
{
|
||||
defaultIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ui.promptSelect(
|
||||
message,
|
||||
choices,
|
||||
s -> s,
|
||||
s -> null,
|
||||
defaultIndex
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Validation rules for template prompts.
|
||||
*
|
||||
* Supports string validation (pattern, length) and integer validation (range).
|
||||
*
|
||||
* @param pattern regex pattern for string validation
|
||||
* @param minLength minimum string length
|
||||
* @param maxLength maximum string length
|
||||
* @param min minimum value for integers
|
||||
* @param max maximum value for integers
|
||||
* @param values allowed values (enum validation)
|
||||
* @param message custom error message
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record PromptValidation(
|
||||
String pattern,
|
||||
Integer minLength,
|
||||
Integer maxLength,
|
||||
Integer min,
|
||||
Integer max,
|
||||
List<String> values,
|
||||
String message
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Validates prompt values against validation rules.
|
||||
*
|
||||
* Supports pattern matching, length constraints, numeric ranges, and enum values.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class PromptValidator
|
||||
{
|
||||
/***************************************************************************
|
||||
* Validation result containing success status and error message.
|
||||
*
|
||||
* @param valid true if validation passed
|
||||
* @param errorMessage error message if validation failed
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public record ValidationResult(boolean valid, String errorMessage)
|
||||
{
|
||||
/*************************************************************************
|
||||
* Create a successful validation result.
|
||||
*
|
||||
* @return success result
|
||||
* @since 0.2.0
|
||||
*************************************************************************/
|
||||
public static ValidationResult success()
|
||||
{
|
||||
return new ValidationResult(true, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*************************************************************************
|
||||
* Create a failed validation result.
|
||||
*
|
||||
* @param message the error message
|
||||
* @return failure result
|
||||
* @since 0.2.0
|
||||
*************************************************************************/
|
||||
public static ValidationResult failure(String message)
|
||||
{
|
||||
return new ValidationResult(false, message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Validate a value against prompt rules.
|
||||
*
|
||||
* @param prompt the prompt definition with validation rules
|
||||
* @param value the value to validate
|
||||
* @return validation result
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public ValidationResult validate(TemplateManifest.Prompt prompt, String value)
|
||||
{
|
||||
// Check required
|
||||
if(prompt.isRequired() && (value == null || value.isBlank()))
|
||||
{
|
||||
return ValidationResult.failure("This field is required");
|
||||
}
|
||||
|
||||
// If empty and not required, skip further validation
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
return ValidationResult.success();
|
||||
}
|
||||
|
||||
// Get validation rules
|
||||
PromptValidation validation = prompt.validation();
|
||||
if(validation == null)
|
||||
{
|
||||
return ValidationResult.success();
|
||||
}
|
||||
|
||||
// Determine type
|
||||
String type = prompt.type() != null ? prompt.type().toLowerCase() : "text";
|
||||
|
||||
// Type-specific validation
|
||||
return switch(type)
|
||||
{
|
||||
case "integer", "number" -> validateInteger(validation, value);
|
||||
default -> validateString(validation, value);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Validate a string value.
|
||||
*
|
||||
* @param validation validation rules
|
||||
* @param value the value to validate
|
||||
* @return validation result
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private ValidationResult validateString(PromptValidation validation, String value)
|
||||
{
|
||||
// Pattern validation
|
||||
if(validation.pattern() != null)
|
||||
{
|
||||
Optional<ValidationResult> patternResult = validatePattern(validation.pattern(), value, validation.message());
|
||||
if(patternResult.isPresent())
|
||||
{
|
||||
return patternResult.get();
|
||||
}
|
||||
}
|
||||
|
||||
// Length validation
|
||||
if(validation.minLength() != null && value.length() < validation.minLength())
|
||||
{
|
||||
String message = validation.message() != null
|
||||
? validation.message()
|
||||
: "Must be at least " + validation.minLength() + " characters";
|
||||
return ValidationResult.failure(message);
|
||||
}
|
||||
|
||||
if(validation.maxLength() != null && value.length() > validation.maxLength())
|
||||
{
|
||||
String message = validation.message() != null
|
||||
? validation.message()
|
||||
: "Must be at most " + validation.maxLength() + " characters";
|
||||
return ValidationResult.failure(message);
|
||||
}
|
||||
|
||||
// Enum validation
|
||||
if(validation.values() != null && !validation.values().isEmpty())
|
||||
{
|
||||
if(!validation.values().contains(value))
|
||||
{
|
||||
String message = validation.message() != null
|
||||
? validation.message()
|
||||
: "Must be one of: " + String.join(", ", validation.values());
|
||||
return ValidationResult.failure(message);
|
||||
}
|
||||
}
|
||||
|
||||
return ValidationResult.success();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Validate an integer value.
|
||||
*
|
||||
* @param validation validation rules
|
||||
* @param value the value to validate
|
||||
* @return validation result
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private ValidationResult validateInteger(PromptValidation validation, String value)
|
||||
{
|
||||
// Parse as integer
|
||||
int intValue;
|
||||
try
|
||||
{
|
||||
intValue = Integer.parseInt(value.trim());
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
return ValidationResult.failure("Must be a valid integer");
|
||||
}
|
||||
|
||||
// Min validation
|
||||
if(validation.min() != null && intValue < validation.min())
|
||||
{
|
||||
String message = validation.message() != null
|
||||
? validation.message()
|
||||
: "Must be at least " + validation.min();
|
||||
return ValidationResult.failure(message);
|
||||
}
|
||||
|
||||
// Max validation
|
||||
if(validation.max() != null && intValue > validation.max())
|
||||
{
|
||||
String message = validation.message() != null
|
||||
? validation.message()
|
||||
: "Must be at most " + validation.max();
|
||||
return ValidationResult.failure(message);
|
||||
}
|
||||
|
||||
return ValidationResult.success();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Validate against a regex pattern.
|
||||
*
|
||||
* @param pattern the regex pattern
|
||||
* @param value the value to validate
|
||||
* @param customMessage custom error message
|
||||
* @return validation result if failed, empty if passed
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private Optional<ValidationResult> validatePattern(String pattern, String value, String customMessage)
|
||||
{
|
||||
try
|
||||
{
|
||||
Pattern regex = Pattern.compile(pattern);
|
||||
if(!regex.matcher(value).matches())
|
||||
{
|
||||
String message = customMessage != null
|
||||
? customMessage
|
||||
: "Must match pattern: " + pattern;
|
||||
return Optional.of(ValidationResult.failure(message));
|
||||
}
|
||||
}
|
||||
catch(PatternSyntaxException e)
|
||||
{
|
||||
return Optional.of(ValidationResult.failure("Invalid validation pattern: " + pattern));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Velocity tool for string transformations.
|
||||
*
|
||||
* Provides methods for common case conversions used in templates.
|
||||
* Exposed in Velocity context as $str.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class StringTool
|
||||
{
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Convert string to lowercase.
|
||||
*
|
||||
* @param value input string
|
||||
* @return lowercase string, or empty string if null/empty
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String lower(String value)
|
||||
{
|
||||
if(value == null || value.isEmpty())
|
||||
{
|
||||
return "";
|
||||
}
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Convert string to uppercase.
|
||||
*
|
||||
* @param value input string
|
||||
* @return uppercase string, or empty string if null/empty
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String upper(String value)
|
||||
{
|
||||
if(value == null || value.isEmpty())
|
||||
{
|
||||
return "";
|
||||
}
|
||||
return value.toUpperCase();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Convert string to camelCase (first letter lowercase).
|
||||
*
|
||||
* @param value input string
|
||||
* @return camelCase string, or empty string if null/empty
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String camel(String value)
|
||||
{
|
||||
if(value == null || value.isEmpty())
|
||||
{
|
||||
return "";
|
||||
}
|
||||
return Character.toLowerCase(value.charAt(0)) + value.substring(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Convert string to PascalCase (first letter uppercase).
|
||||
*
|
||||
* @param value input string
|
||||
* @return PascalCase string, or empty string if null/empty
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String pascal(String value)
|
||||
{
|
||||
if(value == null || value.isEmpty())
|
||||
{
|
||||
return "";
|
||||
}
|
||||
return Character.toUpperCase(value.charAt(0)) + value.substring(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Convert camelCase to kebab-case.
|
||||
*
|
||||
* @param value input string in camelCase or PascalCase
|
||||
* @return kebab-case string, or empty string if null/empty
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String kebab(String value)
|
||||
{
|
||||
if(value == null || value.isEmpty())
|
||||
{
|
||||
return "";
|
||||
}
|
||||
return value.replaceAll("([a-z])([A-Z])", "$1-$2").toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Convert camelCase to snake_case.
|
||||
*
|
||||
* @param value input string in camelCase or PascalCase
|
||||
* @return snake_case string, or empty string if null/empty
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String snake(String value)
|
||||
{
|
||||
if(value == null || value.isEmpty())
|
||||
{
|
||||
return "";
|
||||
}
|
||||
return value.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Replace all occurrences of target with replacement.
|
||||
*
|
||||
* @param value input string
|
||||
* @param target string to find
|
||||
* @param replacement string to replace with
|
||||
* @return string with replacements, or empty if input is null/empty
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String replace(String value, String target, String replacement)
|
||||
{
|
||||
if(value == null || value.isEmpty())
|
||||
{
|
||||
return "";
|
||||
}
|
||||
if(target == null || replacement == null)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
return value.replace(target, replacement);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import io.qrun.qctl.qqq.error.SuggestionEngine;
|
||||
import org.apache.velocity.VelocityContext;
|
||||
import org.apache.velocity.app.VelocityEngine;
|
||||
import org.apache.velocity.exception.MethodInvocationException;
|
||||
import org.apache.velocity.exception.ParseErrorException;
|
||||
import org.apache.velocity.exception.ResourceNotFoundException;
|
||||
import org.apache.velocity.runtime.RuntimeConstants;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Renders templates using Apache Velocity.
|
||||
*
|
||||
* Supports path substitution ($packagePath/src) and content templating with
|
||||
* full Velocity syntax including control flow and macros.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*******************************************************************************/
|
||||
public class TemplateEngine
|
||||
{
|
||||
private static final String TEMPLATE_DIR = "template";
|
||||
private static final Set<String> BINARY_EXTENSIONS = Set.of(
|
||||
".jar", ".class", ".png", ".jpg", ".jpeg", ".gif", ".ico",
|
||||
".zip", ".tar", ".gz", ".war", ".ear", ".pdf"
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// Files that should be copied verbatim without template processing //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
private static final Set<String> VERBATIM_FILENAMES = Set.of(
|
||||
"mvnw", "mvnw.cmd", "gradlew", "gradlew.bat"
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// Pattern to detect undefined Velocity references ($var or ${var}) //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
private static final Pattern UNDEFINED_REF_PATTERN =
|
||||
Pattern.compile("\\$(!)?\\{?([a-zA-Z][a-zA-Z0-9_]*)\\}?");
|
||||
|
||||
private final VelocityEngine velocityEngine;
|
||||
private final StringTool stringTool;
|
||||
private final SuggestionEngine suggestionEngine;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a new template engine.
|
||||
*
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public TemplateEngine()
|
||||
{
|
||||
this.velocityEngine = createVelocityEngine();
|
||||
this.stringTool = new StringTool();
|
||||
this.suggestionEngine = new SuggestionEngine();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create and configure the Velocity engine.
|
||||
*
|
||||
* @return configured VelocityEngine
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private VelocityEngine createVelocityEngine()
|
||||
{
|
||||
Properties props = new Properties();
|
||||
props.setProperty(RuntimeConstants.INPUT_ENCODING, "UTF-8");
|
||||
props.setProperty(RuntimeConstants.RESOURCE_LOADERS, "string");
|
||||
props.setProperty("resource.loader.string.class",
|
||||
"org.apache.velocity.runtime.resource.loader.StringResourceLoader");
|
||||
|
||||
VelocityEngine engine = new VelocityEngine();
|
||||
engine.init(props);
|
||||
return engine;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Render templates to the target directory.
|
||||
*
|
||||
* @param templateDir template source directory
|
||||
* @param targetDir target output directory
|
||||
* @param variables template variables
|
||||
* @param manifest template manifest
|
||||
* @return number of files created
|
||||
* @throws IOException if file operations fail
|
||||
* @throws TemplateRenderException if template rendering fails
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public int render(Path templateDir, Path targetDir, Map<String, String> variables,
|
||||
TemplateManifest manifest) throws IOException, TemplateRenderException
|
||||
{
|
||||
return render(templateDir, targetDir, variables, manifest, MergeOptions.disabled(), null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Render templates with merge support.
|
||||
*
|
||||
* @param templateDir template source directory
|
||||
* @param targetDir target output directory
|
||||
* @param variables template variables
|
||||
* @param manifest template manifest
|
||||
* @param mergeOptions merge configuration
|
||||
* @param ui console UI for interactive prompts (may be null)
|
||||
* @return number of files created/modified
|
||||
* @throws IOException if file operations fail
|
||||
* @throws TemplateRenderException if template rendering fails
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public int render(Path templateDir, Path targetDir, Map<String, String> variables,
|
||||
TemplateManifest manifest, MergeOptions mergeOptions, ConsoleUI ui)
|
||||
throws IOException, TemplateRenderException
|
||||
{
|
||||
Path sourceDir = resolveTemplateSource(templateDir);
|
||||
Set<String> ignorePatterns = manifest.ignore() != null
|
||||
? new HashSet<>(manifest.ignore())
|
||||
: new HashSet<>();
|
||||
ignorePatterns.add(".git");
|
||||
ignorePatterns.add("template.yaml");
|
||||
|
||||
ConsoleUI effectiveUi = ui != null ? ui : new ConsoleUI();
|
||||
MergeStrategy mergeStrategy = new MergeStrategy(mergeOptions, effectiveUi);
|
||||
|
||||
AtomicInteger fileCount = new AtomicInteger();
|
||||
AtomicReference<Exception> renderError = new AtomicReference<>();
|
||||
|
||||
Files.walkFileTree(sourceDir, new SimpleFileVisitor<>()
|
||||
{
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
|
||||
throws IOException
|
||||
{
|
||||
if(shouldIgnore(sourceDir.relativize(dir).toString(), ignorePatterns))
|
||||
{
|
||||
return FileVisitResult.SKIP_SUBTREE;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Path relativePath = sourceDir.relativize(dir);
|
||||
String renderedPath = renderPath(relativePath.toString(), variables);
|
||||
Path targetPath = targetDir.resolve(renderedPath);
|
||||
Files.createDirectories(targetPath);
|
||||
}
|
||||
catch(TemplateRenderException e)
|
||||
{
|
||||
renderError.set(e);
|
||||
return FileVisitResult.TERMINATE;
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
|
||||
throws IOException
|
||||
{
|
||||
Path relativePath = sourceDir.relativize(file);
|
||||
if(shouldIgnore(relativePath.toString(), ignorePatterns))
|
||||
{
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
String renderedPath = renderPath(relativePath.toString(), variables);
|
||||
Path targetPath = targetDir.resolve(renderedPath);
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// Prepare content for merge decision //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
String rendered;
|
||||
if(isBinaryFile(file) || isVerbatimFile(file))
|
||||
{
|
||||
rendered = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
String content = Files.readString(file);
|
||||
rendered = renderContent(content, variables);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// Check merge strategy //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
MergeStrategy.MergeAction action = mergeStrategy.determineAction(
|
||||
renderedPath, targetPath, rendered);
|
||||
|
||||
switch(action)
|
||||
{
|
||||
case SKIP:
|
||||
effectiveUi.println(" [skip] " + renderedPath);
|
||||
return FileVisitResult.CONTINUE;
|
||||
|
||||
case OVERWRITE:
|
||||
mergeStrategy.createBackup(targetPath);
|
||||
effectiveUi.println(" [overwrite] " + renderedPath);
|
||||
break;
|
||||
|
||||
case WRITE:
|
||||
default:
|
||||
effectiveUi.println(" " + renderedPath);
|
||||
break;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// Write the file //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
if(isBinaryFile(file) || isVerbatimFile(file))
|
||||
{
|
||||
Files.copy(file, targetPath,
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
else
|
||||
{
|
||||
Files.writeString(targetPath, rendered);
|
||||
}
|
||||
fileCount.incrementAndGet();
|
||||
}
|
||||
catch(TemplateRenderException e)
|
||||
{
|
||||
renderError.set(e);
|
||||
return FileVisitResult.TERMINATE;
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
|
||||
if(renderError.get() != null)
|
||||
{
|
||||
Exception e = renderError.get();
|
||||
if(e instanceof TemplateRenderException tre)
|
||||
{
|
||||
throw tre;
|
||||
}
|
||||
throw new TemplateRenderException("Template rendering failed", e);
|
||||
}
|
||||
|
||||
return fileCount.get();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Preview template rendering without writing files.
|
||||
*
|
||||
* @param templateDir template source directory
|
||||
* @param targetDir target output directory
|
||||
* @param variables template variables
|
||||
* @param manifest template manifest
|
||||
* @throws IOException if file operations fail
|
||||
* @throws TemplateRenderException if path rendering fails
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public void renderDryRun(Path templateDir, Path targetDir, Map<String, String> variables,
|
||||
TemplateManifest manifest) throws IOException, TemplateRenderException
|
||||
{
|
||||
renderDryRun(templateDir, targetDir, variables, manifest, MergeOptions.disabled());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Preview template rendering with merge support.
|
||||
*
|
||||
* @param templateDir template source directory
|
||||
* @param targetDir target output directory
|
||||
* @param variables template variables
|
||||
* @param manifest template manifest
|
||||
* @param mergeOptions merge configuration
|
||||
* @throws IOException if file operations fail
|
||||
* @throws TemplateRenderException if path rendering fails
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public void renderDryRun(Path templateDir, Path targetDir, Map<String, String> variables,
|
||||
TemplateManifest manifest, MergeOptions mergeOptions)
|
||||
throws IOException, TemplateRenderException
|
||||
{
|
||||
Path sourceDir = resolveTemplateSource(templateDir);
|
||||
Set<String> ignorePatterns = manifest.ignore() != null
|
||||
? new HashSet<>(manifest.ignore())
|
||||
: new HashSet<>();
|
||||
ignorePatterns.add(".git");
|
||||
ignorePatterns.add("template.yaml");
|
||||
|
||||
AtomicReference<TemplateRenderException> renderError = new AtomicReference<>();
|
||||
|
||||
Files.walkFileTree(sourceDir, new SimpleFileVisitor<>()
|
||||
{
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
|
||||
{
|
||||
if(shouldIgnore(sourceDir.relativize(dir).toString(), ignorePatterns))
|
||||
{
|
||||
return FileVisitResult.SKIP_SUBTREE;
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
|
||||
{
|
||||
Path relativePath = sourceDir.relativize(file);
|
||||
if(shouldIgnore(relativePath.toString(), ignorePatterns))
|
||||
{
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
String renderedPath = renderPath(relativePath.toString(), variables);
|
||||
Path targetPath = targetDir.resolve(renderedPath);
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// Check merge status for preview //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
String status = "";
|
||||
if(mergeOptions.enabled())
|
||||
{
|
||||
if(mergeOptions.matchesExclude(renderedPath))
|
||||
{
|
||||
status = "[skip] ";
|
||||
}
|
||||
else if(Files.exists(targetPath))
|
||||
{
|
||||
boolean hasIncludes = mergeOptions.includePatterns() != null
|
||||
&& !mergeOptions.includePatterns().isEmpty();
|
||||
if(hasIncludes && mergeOptions.matchesInclude(renderedPath))
|
||||
{
|
||||
status = "[overwrite] ";
|
||||
}
|
||||
else
|
||||
{
|
||||
status = "[skip] ";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
System.out.println(" " + status + targetPath);
|
||||
}
|
||||
catch(TemplateRenderException e)
|
||||
{
|
||||
renderError.set(e);
|
||||
return FileVisitResult.TERMINATE;
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
|
||||
if(renderError.get() != null)
|
||||
{
|
||||
throw renderError.get();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Render a path string with Velocity variable substitution.
|
||||
*
|
||||
* @param path path string with $var placeholders
|
||||
* @param variables template variables
|
||||
* @return rendered path
|
||||
* @throws TemplateRenderException if rendering fails
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public String renderPath(String path, Map<String, String> variables)
|
||||
throws TemplateRenderException
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Convert __VARNAME__ placeholders to $VARNAME for Velocity. //
|
||||
// This allows templates to use __VARNAME__ in paths, which is safer //
|
||||
// for shells and build tools that might interpret $ as a variable. //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
String normalizedPath = path.replaceAll("__([a-zA-Z][a-zA-Z0-9]*)__", "\\$$1");
|
||||
return renderContent(normalizedPath, variables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Render content using Velocity with string variables.
|
||||
*
|
||||
* @param content template content
|
||||
* @param variables template variables (String values)
|
||||
* @return rendered content
|
||||
* @throws TemplateRenderException if rendering fails
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public String renderContent(String content, Map<String, String> variables)
|
||||
throws TemplateRenderException
|
||||
{
|
||||
VelocityContext context = createContext(variables);
|
||||
return evaluateTemplate(content, context);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Render content using Velocity with object variables.
|
||||
*
|
||||
* Supports complex objects like lists for #foreach directives.
|
||||
*
|
||||
* @param content template content
|
||||
* @param variables template variables (Object values)
|
||||
* @return rendered content
|
||||
* @throws TemplateRenderException if rendering fails
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String renderContentWithObjects(String content, Map<String, Object> variables)
|
||||
throws TemplateRenderException
|
||||
{
|
||||
VelocityContext context = new VelocityContext();
|
||||
context.put("str", stringTool);
|
||||
for(Map.Entry<String, Object> entry : variables.entrySet())
|
||||
{
|
||||
context.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
return evaluateTemplate(content, context);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Evaluate computed variables and merge with input variables.
|
||||
*
|
||||
* Computed variables are evaluated in order. Each computed variable
|
||||
* can reference previously defined variables (prompts or earlier computed).
|
||||
* If a computed variable references an undefined variable, rendering fails.
|
||||
*
|
||||
* @param computed list of computed variable definitions (may be null)
|
||||
* @param variables input variables from prompts
|
||||
* @return merged map containing original and computed variables
|
||||
* @throws TemplateRenderException if evaluation fails
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public Map<String, String> evaluateComputed(List<ComputedVariable> computed,
|
||||
Map<String, String> variables)
|
||||
throws TemplateRenderException
|
||||
{
|
||||
if(computed == null || computed.isEmpty())
|
||||
{
|
||||
return variables;
|
||||
}
|
||||
|
||||
Map<String, String> result = new HashMap<>(variables);
|
||||
|
||||
for(ComputedVariable cv : computed)
|
||||
{
|
||||
String value = renderContent(cv.expression(), result);
|
||||
result.put(cv.name(), value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a Velocity context with string variables and tools.
|
||||
*
|
||||
* @param variables template variables
|
||||
* @return configured VelocityContext
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private VelocityContext createContext(Map<String, String> variables)
|
||||
{
|
||||
VelocityContext context = new VelocityContext();
|
||||
context.put("str", stringTool);
|
||||
for(Map.Entry<String, String> entry : variables.entrySet())
|
||||
{
|
||||
context.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Evaluate a template string with the given context.
|
||||
*
|
||||
* @param template template string
|
||||
* @param context Velocity context
|
||||
* @return rendered string
|
||||
* @throws TemplateRenderException if rendering fails
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private String evaluateTemplate(String template, VelocityContext context)
|
||||
throws TemplateRenderException
|
||||
{
|
||||
try
|
||||
{
|
||||
StringWriter writer = new StringWriter();
|
||||
boolean result = velocityEngine.evaluate(context, writer, "template", template);
|
||||
|
||||
if(!result)
|
||||
{
|
||||
throw new TemplateRenderException("Template evaluation failed");
|
||||
}
|
||||
|
||||
String output = writer.toString();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// Check for undefined references that weren't resolved //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
checkForUndefinedReferences(template, output, context);
|
||||
|
||||
return output;
|
||||
}
|
||||
catch(ParseErrorException e)
|
||||
{
|
||||
throw new TemplateRenderException("Template syntax error: " + e.getMessage(), e);
|
||||
}
|
||||
catch(MethodInvocationException e)
|
||||
{
|
||||
throw new TemplateRenderException("Template method error: " + e.getMessage(), e);
|
||||
}
|
||||
catch(ResourceNotFoundException e)
|
||||
{
|
||||
throw new TemplateRenderException("Template resource not found: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check for undefined variable references in the output.
|
||||
*
|
||||
* If a variable reference like $name appears in both input and output
|
||||
* unchanged, it means the variable was not defined.
|
||||
*
|
||||
* @param template original template
|
||||
* @param output rendered output
|
||||
* @param context Velocity context
|
||||
* @throws TemplateRenderException if undefined references found
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private void checkForUndefinedReferences(String template, String output,
|
||||
VelocityContext context)
|
||||
throws TemplateRenderException
|
||||
{
|
||||
Matcher matcher = UNDEFINED_REF_PATTERN.matcher(output);
|
||||
while(matcher.find())
|
||||
{
|
||||
String varName = matcher.group(2);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// Skip if it's a tool reference (like $str) //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
if("str".equals(varName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// Skip if it looks like a Maven property (${name.something}) //
|
||||
// These are intentionally left as literals in templates //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
int endPos = matcher.end();
|
||||
if(endPos < output.length() && output.charAt(endPos) == '.')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// If the variable is not in context, it's undefined //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
if(context.get(varName) == null)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// Check if this exact reference was in the original template //
|
||||
/////////////////////////////////////////////////////////////////
|
||||
String fullRef = matcher.group(0);
|
||||
if(template.contains(fullRef))
|
||||
{
|
||||
//////////////////////////////////////////////////////////////
|
||||
// Collect available variables and find similar ones //
|
||||
//////////////////////////////////////////////////////////////
|
||||
List<String> available = collectAvailableVariables(context);
|
||||
List<String> suggestions = suggestionEngine.findSimilar(varName, available);
|
||||
throw new TemplateRenderException(varName, suggestions, available);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Collect all available variable names from the context.
|
||||
*
|
||||
* @param context Velocity context
|
||||
* @return list of variable names
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private List<String> collectAvailableVariables(VelocityContext context)
|
||||
{
|
||||
List<String> variables = new ArrayList<>();
|
||||
for(Object key : context.getKeys())
|
||||
{
|
||||
String name = key.toString();
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// Skip internal tools like $str //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
if(!"str".equals(name))
|
||||
{
|
||||
variables.add(name);
|
||||
}
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Resolve the template source directory.
|
||||
*
|
||||
* @param templateDir template root directory
|
||||
* @return source directory for templates
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
private Path resolveTemplateSource(Path templateDir)
|
||||
{
|
||||
Path templateSubdir = templateDir.resolve(TEMPLATE_DIR);
|
||||
if(Files.isDirectory(templateSubdir))
|
||||
{
|
||||
return templateSubdir;
|
||||
}
|
||||
return templateDir;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if a file should be ignored.
|
||||
*
|
||||
* @param path relative path
|
||||
* @param patterns ignore patterns
|
||||
* @return true if file should be ignored
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
private boolean shouldIgnore(String path, Set<String> patterns)
|
||||
{
|
||||
for(String pattern : patterns)
|
||||
{
|
||||
if(path.equals(pattern) || path.startsWith(pattern + "/"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if(pattern.startsWith("*."))
|
||||
{
|
||||
String ext = pattern.substring(1);
|
||||
if(path.endsWith(ext))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if a file is binary.
|
||||
*
|
||||
* @param file file path
|
||||
* @return true if file is binary
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
private boolean isBinaryFile(Path file)
|
||||
{
|
||||
String name = file.getFileName().toString().toLowerCase();
|
||||
for(String ext : BINARY_EXTENSIONS)
|
||||
{
|
||||
if(name.endsWith(ext))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if a file should be copied verbatim without template processing.
|
||||
*
|
||||
* @param file file path
|
||||
* @return true if file should be copied verbatim
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private boolean isVerbatimFile(Path file)
|
||||
{
|
||||
String name = file.getFileName().toString();
|
||||
return VERBATIM_FILENAMES.contains(name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Inner class to hold exception reference for use in visitor.
|
||||
*
|
||||
* @param <T> exception type
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private static class AtomicReference<T>
|
||||
{
|
||||
private T value;
|
||||
|
||||
|
||||
|
||||
/*************************************************************************
|
||||
* Set the value.
|
||||
*
|
||||
* @param value value to set
|
||||
* @since 0.2.0
|
||||
*************************************************************************/
|
||||
public void set(T value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*************************************************************************
|
||||
* Get the value.
|
||||
*
|
||||
* @return stored value
|
||||
* @since 0.2.0
|
||||
*************************************************************************/
|
||||
public T get()
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.core.JsonLocation;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Represents a template manifest (template.yaml).
|
||||
*
|
||||
* Schema v2 adds: schemaVersion, minimumQctlVersion, prompts[].required,
|
||||
* postGen[].phase. Backward compatible with v1 manifests.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*******************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record TemplateManifest(
|
||||
Integer schemaVersion,
|
||||
String id,
|
||||
String name,
|
||||
String version,
|
||||
String description,
|
||||
String minimumQctlVersion,
|
||||
List<Prompt> prompts,
|
||||
List<ComputedVariable> computed,
|
||||
List<Transform> transforms,
|
||||
List<PostGenHook> postGen,
|
||||
List<String> ignore
|
||||
)
|
||||
{
|
||||
private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory());
|
||||
private static final String MANIFEST_FILE = "template.yaml";
|
||||
private static final int DEFAULT_SCHEMA_VERSION = 1;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get effective schema version (defaults to 1 for backward compatibility).
|
||||
*
|
||||
* @return schema version
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public int getEffectiveSchemaVersion()
|
||||
{
|
||||
return schemaVersion != null ? schemaVersion : DEFAULT_SCHEMA_VERSION;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Load a template manifest from a directory.
|
||||
*
|
||||
* @param templateDir directory containing template.yaml
|
||||
* @return parsed manifest
|
||||
* @throws IOException if manifest cannot be read
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public static TemplateManifest load(Path templateDir) throws IOException
|
||||
{
|
||||
Path manifestPath = templateDir.resolve(MANIFEST_FILE);
|
||||
if(!Files.exists(manifestPath))
|
||||
{
|
||||
throw new IOException("template.yaml not found in " + templateDir);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return YAML.readValue(manifestPath.toFile(), TemplateManifest.class);
|
||||
}
|
||||
catch(JsonProcessingException e)
|
||||
{
|
||||
throw new IOException(formatYamlError(e, manifestPath), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Format a YAML parsing error with line and column information.
|
||||
*
|
||||
* @param e the JSON processing exception
|
||||
* @param manifestPath path to the manifest file
|
||||
* @return formatted error message
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private static String formatYamlError(JsonProcessingException e, Path manifestPath)
|
||||
{
|
||||
JsonLocation location = e.getLocation();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("YAML parse error in ").append(manifestPath.getFileName());
|
||||
|
||||
if(location != null)
|
||||
{
|
||||
int line = location.getLineNr();
|
||||
int col = location.getColumnNr();
|
||||
sb.append(" (line ").append(line);
|
||||
if(col >= 0)
|
||||
{
|
||||
sb.append(", column ").append(col);
|
||||
}
|
||||
sb.append(")");
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Extract the original error message without the location suffix //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
String originalMessage = e.getOriginalMessage();
|
||||
if(originalMessage != null && !originalMessage.isEmpty())
|
||||
{
|
||||
sb.append(": ").append(originalMessage);
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* A prompt definition for collecting user input.
|
||||
*
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Prompt(
|
||||
String name,
|
||||
String message,
|
||||
String type,
|
||||
String defaultValue,
|
||||
List<String> choices,
|
||||
PromptValidation validation,
|
||||
Boolean required
|
||||
)
|
||||
{
|
||||
/*************************************************************************
|
||||
* Check if this prompt is required.
|
||||
*
|
||||
* @return true if required (defaults to true if not specified)
|
||||
* @since 0.2.0
|
||||
*************************************************************************/
|
||||
public boolean isRequired()
|
||||
{
|
||||
return required == null || required;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* A post-generation hook definition.
|
||||
*
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record PostGenHook(
|
||||
String name,
|
||||
String command,
|
||||
String workDir,
|
||||
Boolean optional,
|
||||
String phase
|
||||
)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Exception thrown when template rendering fails.
|
||||
*
|
||||
* Provides context about the template error including location information
|
||||
* when available from the underlying template engine.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class TemplateRenderException extends Exception
|
||||
{
|
||||
private final String undefinedVariable;
|
||||
private final List<String> suggestions;
|
||||
private final List<String> availableVariables;
|
||||
private final String filePath;
|
||||
private final Integer lineNumber;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a new exception with a message.
|
||||
*
|
||||
* @param message error message
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TemplateRenderException(String message)
|
||||
{
|
||||
super(message);
|
||||
this.undefinedVariable = null;
|
||||
this.suggestions = List.of();
|
||||
this.availableVariables = List.of();
|
||||
this.filePath = null;
|
||||
this.lineNumber = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a new exception with a message and cause.
|
||||
*
|
||||
* @param message error message
|
||||
* @param cause underlying cause
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TemplateRenderException(String message, Throwable cause)
|
||||
{
|
||||
super(message, cause);
|
||||
this.undefinedVariable = null;
|
||||
this.suggestions = List.of();
|
||||
this.availableVariables = List.of();
|
||||
this.filePath = null;
|
||||
this.lineNumber = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create exception for undefined variable with suggestions.
|
||||
*
|
||||
* @param undefinedVariable the undefined variable name
|
||||
* @param suggestions similar variable names
|
||||
* @param availableVariables all available variable names
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TemplateRenderException(String undefinedVariable, List<String> suggestions,
|
||||
List<String> availableVariables)
|
||||
{
|
||||
super(buildMessage(undefinedVariable, suggestions, availableVariables));
|
||||
this.undefinedVariable = undefinedVariable;
|
||||
this.suggestions = suggestions != null ? suggestions : List.of();
|
||||
this.availableVariables = availableVariables != null ? availableVariables : List.of();
|
||||
this.filePath = null;
|
||||
this.lineNumber = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create exception with file path context.
|
||||
*
|
||||
* @param message error message
|
||||
* @param filePath path to the file being processed
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TemplateRenderException(String message, String filePath)
|
||||
{
|
||||
super(message + " (in " + filePath + ")");
|
||||
this.undefinedVariable = null;
|
||||
this.suggestions = List.of();
|
||||
this.availableVariables = List.of();
|
||||
this.filePath = filePath;
|
||||
this.lineNumber = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create exception with file path and line number.
|
||||
*
|
||||
* @param message error message
|
||||
* @param filePath path to the file
|
||||
* @param lineNumber line number where error occurred
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public TemplateRenderException(String message, String filePath, Integer lineNumber)
|
||||
{
|
||||
super(buildMessageWithLocation(message, filePath, lineNumber));
|
||||
this.undefinedVariable = null;
|
||||
this.suggestions = List.of();
|
||||
this.availableVariables = List.of();
|
||||
this.filePath = filePath;
|
||||
this.lineNumber = lineNumber;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Build error message for undefined variable.
|
||||
*
|
||||
* @param varName undefined variable name
|
||||
* @param suggestions similar variable names
|
||||
* @param available all available variables
|
||||
* @return formatted error message
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private static String buildMessage(String varName, List<String> suggestions,
|
||||
List<String> available)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Undefined variable: ").append(varName);
|
||||
|
||||
if(suggestions != null && !suggestions.isEmpty())
|
||||
{
|
||||
sb.append("\n\nDid you mean?");
|
||||
for(String s : suggestions)
|
||||
{
|
||||
sb.append("\n - ").append(s);
|
||||
}
|
||||
}
|
||||
|
||||
if(available != null && !available.isEmpty())
|
||||
{
|
||||
sb.append("\n\nAvailable variables:");
|
||||
for(String v : available)
|
||||
{
|
||||
sb.append("\n - ").append(v);
|
||||
}
|
||||
}
|
||||
|
||||
sb.append("\n\nTip: Variable names are case-sensitive");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Build error message with file location.
|
||||
*
|
||||
* @param message base message
|
||||
* @param filePath file path
|
||||
* @param lineNumber line number (may be null)
|
||||
* @return formatted message with location
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private static String buildMessageWithLocation(String message, String filePath,
|
||||
Integer lineNumber)
|
||||
{
|
||||
if(filePath == null)
|
||||
{
|
||||
return message;
|
||||
}
|
||||
if(lineNumber != null)
|
||||
{
|
||||
return message + " (" + filePath + ":" + lineNumber + ")";
|
||||
}
|
||||
return message + " (in " + filePath + ")";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get the undefined variable name if this is an undefined variable error.
|
||||
*
|
||||
* @return variable name or null
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String getUndefinedVariable()
|
||||
{
|
||||
return undefinedVariable;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get suggestions for similar variable names.
|
||||
*
|
||||
* @return list of suggestions (may be empty)
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public List<String> getSuggestions()
|
||||
{
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get all available variable names.
|
||||
*
|
||||
* @return list of available variables (may be empty)
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public List<String> getAvailableVariables()
|
||||
{
|
||||
return availableVariables;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get the file path where error occurred.
|
||||
*
|
||||
* @return file path or null
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String getFilePath()
|
||||
{
|
||||
return filePath;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get the line number where error occurred.
|
||||
*
|
||||
* @return line number or null
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public Integer getLineNumber()
|
||||
{
|
||||
return lineNumber;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Resolves template sources to local paths.
|
||||
*
|
||||
* Supports: local paths, git URLs, github.com/org/repo shorthand.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*******************************************************************************/
|
||||
public class TemplateResolver
|
||||
{
|
||||
private static final String GITHUB_PREFIX = "github.com/";
|
||||
private static final String GIT_SUFFIX = ".git";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Resolve a template source to a local directory path.
|
||||
*
|
||||
* @param source template source (path, git URL, or github shorthand)
|
||||
* @param version optional version (semver tag)
|
||||
* @return local path to template directory
|
||||
* @throws IOException if template cannot be resolved
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public Path resolve(String source, String version) throws IOException
|
||||
{
|
||||
// Local path
|
||||
Path localPath = Path.of(source);
|
||||
if(Files.isDirectory(localPath))
|
||||
{
|
||||
System.out.println("Using local template: " + localPath.toAbsolutePath());
|
||||
return localPath.toAbsolutePath();
|
||||
}
|
||||
|
||||
// Git URL or GitHub shorthand
|
||||
String gitUrl = toGitUrl(source);
|
||||
return cloneTemplate(gitUrl, version);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Convert source to a full git URL.
|
||||
*
|
||||
* @param source template source
|
||||
* @return git clone URL
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
private String toGitUrl(String source)
|
||||
{
|
||||
// Already a git URL
|
||||
if(source.startsWith("git@") || source.startsWith("https://") || source.startsWith("git://"))
|
||||
{
|
||||
return source;
|
||||
}
|
||||
|
||||
// GitHub shorthand: github.com/org/repo or just org/repo
|
||||
if(source.startsWith(GITHUB_PREFIX))
|
||||
{
|
||||
String repo = source.substring(GITHUB_PREFIX.length());
|
||||
return "https://github.com/" + repo + (repo.endsWith(GIT_SUFFIX) ? "" : GIT_SUFFIX);
|
||||
}
|
||||
|
||||
// Assume GitHub shorthand if it looks like org/repo
|
||||
if(source.matches("[\\w.-]+/[\\w.-]+"))
|
||||
{
|
||||
return "https://github.com/" + source + GIT_SUFFIX;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Invalid template source: " + source);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Clone a git repository to a temporary directory.
|
||||
*
|
||||
* @param gitUrl git clone URL
|
||||
* @param version optional version tag
|
||||
* @return path to cloned template
|
||||
* @throws IOException if clone fails
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
private Path cloneTemplate(String gitUrl, String version) throws IOException
|
||||
{
|
||||
Path tempDir = Files.createTempDirectory("qctl-template-");
|
||||
System.out.println("Cloning: " + gitUrl);
|
||||
|
||||
try
|
||||
{
|
||||
ProcessBuilder pb = new ProcessBuilder("git", "clone", "--depth", "1");
|
||||
if(version != null && !version.isBlank())
|
||||
{
|
||||
pb.command().add("--branch");
|
||||
pb.command().add(version.startsWith("v") ? version : "v" + version);
|
||||
}
|
||||
pb.command().add(gitUrl);
|
||||
pb.command().add(tempDir.toString());
|
||||
pb.inheritIO();
|
||||
|
||||
Process process = pb.start();
|
||||
int exitCode = process.waitFor();
|
||||
if(exitCode != 0)
|
||||
{
|
||||
throw new IOException("git clone failed with exit code " + exitCode);
|
||||
}
|
||||
return tempDir;
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("git clone interrupted", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Fetches and manages the templates index from templates-hub.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*******************************************************************************/
|
||||
public class TemplatesHub
|
||||
{
|
||||
private static final String HUB_URL =
|
||||
"https://raw.githubusercontent.com/QRun-IO/templates-hub/main/templates.yaml";
|
||||
private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory());
|
||||
private static final int TIMEOUT_SECONDS = 30;
|
||||
|
||||
private TemplatesIndex cachedIndex;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Fetch the templates index from the hub.
|
||||
*
|
||||
* @return templates index
|
||||
* @throws IOException if fetch fails
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public TemplatesIndex fetchIndex() throws IOException
|
||||
{
|
||||
if(cachedIndex != null)
|
||||
{
|
||||
return cachedIndex;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(TIMEOUT_SECONDS))
|
||||
.build();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(HUB_URL))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request,
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if(response.statusCode() != 200)
|
||||
{
|
||||
throw new IOException("Failed to fetch templates index: HTTP " + response.statusCode());
|
||||
}
|
||||
|
||||
cachedIndex = YAML.readValue(response.body(), TemplatesIndex.class);
|
||||
return cachedIndex;
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted while fetching templates index", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Find a template by ID.
|
||||
*
|
||||
* @param templateId template ID
|
||||
* @return template entry if found
|
||||
* @throws IOException if fetch fails
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
public Optional<TemplateEntry> findById(String templateId) throws IOException
|
||||
{
|
||||
TemplatesIndex index = fetchIndex();
|
||||
if(index.templates() == null)
|
||||
{
|
||||
return Optional.empty();
|
||||
}
|
||||
return index.templates().stream()
|
||||
.filter(t -> t.id().equals(templateId))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Templates index from templates-hub.
|
||||
*
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record TemplatesIndex(
|
||||
String version,
|
||||
String registry,
|
||||
List<TemplateEntry> templates
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* A template entry from the index.
|
||||
*
|
||||
* @since 0.1.0
|
||||
***************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record TemplateEntry(
|
||||
Integer schemaVersion,
|
||||
String id,
|
||||
String name,
|
||||
String description,
|
||||
String repo,
|
||||
String version,
|
||||
String minimumQctlVersion,
|
||||
List<String> tags,
|
||||
String maintainer,
|
||||
List<TemplateManifest.Prompt> prompts,
|
||||
List<ComputedVariable> computed,
|
||||
List<Transform> transforms,
|
||||
List<TemplateManifest.PostGenHook> postGen,
|
||||
List<String> ignore
|
||||
)
|
||||
{
|
||||
/*************************************************************************
|
||||
* Get the git clone URL for this template.
|
||||
*
|
||||
* @param registry base registry URL
|
||||
* @return git clone URL
|
||||
* @since 0.1.0
|
||||
*************************************************************************/
|
||||
public String getGitUrl(String registry)
|
||||
{
|
||||
return registry + "/" + repo + ".git";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Transform definition for post-render file operations.
|
||||
*
|
||||
* Supports two transform types:
|
||||
* - rename: Rename files matching a glob pattern
|
||||
* - delete: Delete files matching a glob pattern
|
||||
*
|
||||
* @param type transform type (rename or delete)
|
||||
* @param pattern glob pattern to match files (e.g., "**\/*.java")
|
||||
* @param replacement replacement pattern for rename (uses $varName substitution)
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Transform(
|
||||
String type,
|
||||
String pattern,
|
||||
String replacement
|
||||
)
|
||||
{
|
||||
/***************************************************************************
|
||||
* Transform type constants.
|
||||
***************************************************************************/
|
||||
public static final String TYPE_RENAME = "rename";
|
||||
public static final String TYPE_DELETE = "delete";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Validate this transform definition.
|
||||
*
|
||||
* @throws IllegalArgumentException if transform is invalid
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public void validate()
|
||||
{
|
||||
if(type == null || type.isBlank())
|
||||
{
|
||||
throw new IllegalArgumentException("Transform type is required");
|
||||
}
|
||||
|
||||
if(!TYPE_RENAME.equals(type) && !TYPE_DELETE.equals(type))
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid transform type: " + type + ". Must be 'rename' or 'delete'");
|
||||
}
|
||||
|
||||
if(pattern == null || pattern.isBlank())
|
||||
{
|
||||
throw new IllegalArgumentException("Transform pattern is required");
|
||||
}
|
||||
|
||||
if(TYPE_RENAME.equals(type) && (replacement == null || replacement.isBlank()))
|
||||
{
|
||||
throw new IllegalArgumentException("Replacement is required for rename transforms");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.PathMatcher;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Executes post-render transforms on generated files.
|
||||
*
|
||||
* Supports rename and delete operations using glob patterns.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class TransformExecutor
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// Pattern for variable substitution in replacement strings: ${varName} //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\{([a-zA-Z][a-zA-Z0-9_]*)}");
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Execute a list of transforms on the target directory.
|
||||
*
|
||||
* @param targetDir target directory containing rendered files
|
||||
* @param transforms list of transforms to execute (may be null)
|
||||
* @param variables template variables for replacement substitution
|
||||
* @throws IOException if file operations fail
|
||||
* @throws TemplateRenderException if transform is invalid
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public void execute(Path targetDir, List<Transform> transforms, Map<String, String> variables)
|
||||
throws IOException, TemplateRenderException
|
||||
{
|
||||
if(transforms == null || transforms.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for(Transform transform : transforms)
|
||||
{
|
||||
executeTransform(targetDir, transform, variables);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Execute a single transform.
|
||||
*
|
||||
* @param targetDir target directory
|
||||
* @param transform transform to execute
|
||||
* @param variables template variables
|
||||
* @throws IOException if file operations fail
|
||||
* @throws TemplateRenderException if transform is invalid
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private void executeTransform(Path targetDir, Transform transform, Map<String, String> variables)
|
||||
throws IOException, TemplateRenderException
|
||||
{
|
||||
String type = transform.type();
|
||||
|
||||
try
|
||||
{
|
||||
if(Transform.TYPE_DELETE.equals(type))
|
||||
{
|
||||
executeDelete(targetDir, transform.pattern());
|
||||
}
|
||||
else if(Transform.TYPE_RENAME.equals(type))
|
||||
{
|
||||
executeRename(targetDir, transform.pattern(), transform.replacement(), variables);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new TemplateRenderException("Invalid transform type: " + type,
|
||||
"manifest.yaml (transforms section)");
|
||||
}
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw new IOException("Transform failed for pattern '" + transform.pattern()
|
||||
+ "' in " + targetDir + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Execute a delete transform.
|
||||
*
|
||||
* @param targetDir target directory
|
||||
* @param pattern glob pattern to match files
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private void executeDelete(Path targetDir, String pattern) throws IOException
|
||||
{
|
||||
List<Path> matchingFiles = findMatchingFiles(targetDir, pattern);
|
||||
|
||||
for(Path file : matchingFiles)
|
||||
{
|
||||
Files.deleteIfExists(file);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Execute a rename transform.
|
||||
*
|
||||
* @param targetDir target directory
|
||||
* @param pattern glob pattern to match files
|
||||
* @param replacement replacement filename (may contain ${var} placeholders)
|
||||
* @param variables template variables for substitution
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private void executeRename(Path targetDir, String pattern, String replacement,
|
||||
Map<String, String> variables) throws IOException
|
||||
{
|
||||
List<Path> matchingFiles = findMatchingFiles(targetDir, pattern);
|
||||
String resolvedReplacement = substituteVariables(replacement, variables);
|
||||
|
||||
for(Path file : matchingFiles)
|
||||
{
|
||||
Path newPath = file.getParent().resolve(resolvedReplacement);
|
||||
Files.move(file, newPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Find all files matching a glob pattern.
|
||||
*
|
||||
* @param targetDir target directory to search
|
||||
* @param pattern glob pattern
|
||||
* @return list of matching file paths
|
||||
* @throws IOException if traversal fails
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private List<Path> findMatchingFiles(Path targetDir, String pattern) throws IOException
|
||||
{
|
||||
List<Path> matches = new ArrayList<>();
|
||||
PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern);
|
||||
|
||||
Files.walkFileTree(targetDir, new SimpleFileVisitor<>()
|
||||
{
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
|
||||
{
|
||||
Path relativePath = targetDir.relativize(file);
|
||||
if(matcher.matches(relativePath))
|
||||
{
|
||||
matches.add(file);
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Substitute ${varName} placeholders in a string.
|
||||
*
|
||||
* @param template string with placeholders
|
||||
* @param variables variable values
|
||||
* @return string with placeholders replaced
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private String substituteVariables(String template, Map<String, String> variables)
|
||||
{
|
||||
if(template == null || variables == null || variables.isEmpty())
|
||||
{
|
||||
return template;
|
||||
}
|
||||
|
||||
Matcher matcher = VARIABLE_PATTERN.matcher(template);
|
||||
StringBuffer result = new StringBuffer();
|
||||
|
||||
while(matcher.find())
|
||||
{
|
||||
String varName = matcher.group(1);
|
||||
String value = variables.getOrDefault(varName, matcher.group(0));
|
||||
matcher.appendReplacement(result, Matcher.quoteReplacement(value));
|
||||
}
|
||||
matcher.appendTail(result);
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
Args=--initialize-at-run-time=com.github.jknack.handlebars.internal.Files \
|
||||
--initialize-at-run-time=com.github.jknack.handlebars.helper.DefaultHelperRegistry \
|
||||
-H:IncludeResources=helpers.nashorn.js \
|
||||
-H:IncludeResources=handlebars-v4.7.7.js \
|
||||
-H:IncludeResources=com/github/jknack/handlebars/.* \
|
||||
-H:ReflectionConfigurationResources=${.}/reflect-config.json \
|
||||
-H:ResourceConfigurationResources=${.}/resource-config.json
|
||||
@@ -0,0 +1,202 @@
|
||||
[
|
||||
{
|
||||
"name": "io.qrun.qctl.qqq.registry.TemplateInfo",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true,
|
||||
"allDeclaredFields": true,
|
||||
"allPublicFields": true
|
||||
},
|
||||
{
|
||||
"name": "io.qrun.qctl.qqq.template.TemplatesHub$TemplatesIndex",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true,
|
||||
"allDeclaredFields": true,
|
||||
"allPublicFields": true
|
||||
},
|
||||
{
|
||||
"name": "io.qrun.qctl.qqq.template.TemplatesHub$TemplateEntry",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true,
|
||||
"allDeclaredFields": true,
|
||||
"allPublicFields": true
|
||||
},
|
||||
{
|
||||
"name": "io.qrun.qctl.qqq.template.TemplateManifest",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true,
|
||||
"allDeclaredFields": true,
|
||||
"allPublicFields": true
|
||||
},
|
||||
{
|
||||
"name": "io.qrun.qctl.qqq.template.TemplateManifest$Prompt",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true,
|
||||
"allDeclaredFields": true,
|
||||
"allPublicFields": true
|
||||
},
|
||||
{
|
||||
"name": "io.qrun.qctl.qqq.template.TemplateManifest$PostGenHook",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true,
|
||||
"allDeclaredFields": true,
|
||||
"allPublicFields": true
|
||||
},
|
||||
{
|
||||
"name": "io.qrun.qctl.qqq.template.ComputedVariable",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true,
|
||||
"allDeclaredFields": true,
|
||||
"allPublicFields": true
|
||||
},
|
||||
{
|
||||
"name": "io.qrun.qctl.qqq.template.Transform",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true,
|
||||
"allDeclaredFields": true,
|
||||
"allPublicFields": true
|
||||
},
|
||||
{
|
||||
"name": "io.qrun.qctl.qqq.template.StringTool",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true,
|
||||
"allDeclaredFields": true,
|
||||
"allPublicFields": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.runtime.resource.ResourceManagerImpl",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.runtime.resource.ResourceCacheImpl",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.runtime.resource.util.StringResourceRepositoryImpl",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.runtime.ParserPoolImpl",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.util.introspection.UberspectImpl",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.util.introspection.TypeConversionHandlerImpl",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.runtime.log.Slf4jLogChute",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.runtime.parser.StandardParser",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.runtime.resource.loader.StringResourceLoader",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.runtime.directive.Foreach",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.runtime.directive.Include",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.runtime.directive.Parse",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.runtime.directive.Macro",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.runtime.directive.Define",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.runtime.directive.Evaluate",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.runtime.directive.Stop",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
},
|
||||
{
|
||||
"name": "org.apache.velocity.runtime.directive.Break",
|
||||
"allDeclaredConstructors": true,
|
||||
"allPublicConstructors": true,
|
||||
"allDeclaredMethods": true,
|
||||
"allPublicMethods": true
|
||||
}
|
||||
]
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user