Author SHA1 Message Date
james.maesandCodex 74f4b88a54 fix: honor non-interactive template defaults and Windows paths (#71)
* fix: honor non-interactive template defaults and Windows paths

Return NOT_FOUND for unknown templates without prompting. Preserve interactive fallback and validate non-interactive overrides.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>

* fix: resolve remote template URLs on Windows

Windows rejects HTTPS URLs as filesystem paths before Git can clone them. Fall through to supported Git URL validation when the local path probe is invalid.

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: Codex (GPT-6) <noreply@openai.com>
2026-10-30 10:54:31 -05:00
james.maesandCodex d84a5783bf ci: use shared native worker on main (#70)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 17:07:07 -05:00
james.maes deafa64779 chore: migrate license from AGPL-3.0 to Apache-2.0 2026-01-05 10:11:43 -06:00
james.maes b5086fbd63 Merge branch 'develop' 2026-01-04 19:43:39 -06:00
james.maes 25927df071 Merge develop into main for v0.2.0 release 2026-01-04 19:22:05 -06:00
james.maes 44ecc8793d docs: Standardize README 2025-12-24 14:21:42 -06:00
james.maes 67478867d9 ci: Add GitHub release configuration 2025-12-24 12:44:44 -06:00
10 changed files with 200 additions and 323 deletions

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
# GitHub Release Configuration
# Auto-generates release notes from PR labels
changelog:
exclude:
labels:
- ignore-for-release
- duplicate
- invalid
authors:
- dependabot
- dependabot[bot]
categories:
- title: Breaking Changes
labels:
- breaking-change
- breaking
- title: New Features
labels:
- enhancement
- feature
- title: Bug Fixes
labels:
- bug
- fix
- title: Dependencies
labels:
- dependencies
- title: Other Changes
labels:
- "*"
+16 -304
View File
@@ -1,313 +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: read
contents: write
packages: write
pull-requests: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up GraalVM
uses: graalvm/setup-graalvm@v1
with:
java-version: '21'
distribution: 'graalvm'
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache Maven packages
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Run tests
run: mvn clean verify -q
build-native:
needs: test
if: github.event_name == 'push'
strategy:
matrix:
include:
- os: ubuntu-latest
artifact: qctl-linux-amd64
- os: ubuntu-24.04-arm
artifact: qctl-linux-arm64
- os: macos-15
artifact: qctl-macos-amd64
- os: macos-14
artifact: qctl-macos-arm64
- os: windows-latest
artifact: qctl-windows-amd64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Set up GraalVM
uses: graalvm/setup-graalvm@v1
with:
java-version: '21'
distribution: 'graalvm'
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache Maven packages
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Build native image
run: mvn clean package -DskipTests -Pnative -q
- name: Rename artifact (Unix)
if: runner.os != 'Windows'
run: mv qctl-cli/target/qctl qctl-cli/target/${{ matrix.artifact }}
- name: Rename artifact (Windows)
if: runner.os == 'Windows'
run: mv qctl-cli/target/qctl.exe qctl-cli/target/${{ matrix.artifact }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: qctl-cli/target/${{ matrix.artifact }}
release:
needs: build-native
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Flatten artifacts
run: |
mkdir -p release
find artifacts -type f -exec mv {} release/ \;
ls -la release/
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: release/*
generate_release_notes: true
docker:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Download Linux AMD64 artifact
uses: actions/download-artifact@v4
with:
name: qctl-linux-amd64
path: .
- name: Download Linux ARM64 artifact
uses: actions/download-artifact@v4
with:
name: qctl-linux-arm64
path: .
- name: Prepare binaries
run: |
chmod +x qctl-linux-amd64 qctl-linux-arm64
# Dockerfile expects qctl-linux-amd64 and qctl-linux-arm64
ls -la qctl-linux-*
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: packaging/docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/qrun-io/qctl:${{ github.ref_name }}
ghcr.io/qrun-io/qctl:latest
update-homebrew:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Checkout homebrew-qctl tap
uses: actions/checkout@v4
with:
repository: QRun-IO/homebrew-qctl
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: homebrew-tap
- name: Update Homebrew formula
env:
VERSION: ${{ github.ref_name }}
run: |
VERSION="${VERSION#v}"
SHA_MACOS_ARM64=$(shasum -a 256 artifacts/qctl-macos-arm64/qctl-macos-arm64 | cut -d' ' -f1)
SHA_MACOS_AMD64=$(shasum -a 256 artifacts/qctl-macos-amd64/qctl-macos-amd64 | cut -d' ' -f1)
SHA_LINUX_ARM64=$(shasum -a 256 artifacts/qctl-linux-arm64/qctl-linux-arm64 | cut -d' ' -f1)
SHA_LINUX_AMD64=$(shasum -a 256 artifacts/qctl-linux-amd64/qctl-linux-amd64 | cut -d' ' -f1)
FORMULA="homebrew-tap/Formula/qctl.rb"
# Update version
sed -i "s/version \"[^\"]*\"/version \"${VERSION}\"/" "$FORMULA"
# Update SHA256 hashes in order: macos-arm64, macos-amd64, linux-arm64, linux-amd64
awk -v v1="$SHA_MACOS_ARM64" -v v2="$SHA_MACOS_AMD64" -v v3="$SHA_LINUX_ARM64" -v v4="$SHA_LINUX_AMD64" '
BEGIN { n = 0 }
/sha256/ {
n++
if (n == 1) sub(/sha256 "[^"]*"/, "sha256 \"" v1 "\"")
else if (n == 2) sub(/sha256 "[^"]*"/, "sha256 \"" v2 "\"")
else if (n == 3) sub(/sha256 "[^"]*"/, "sha256 \"" v3 "\"")
else if (n == 4) sub(/sha256 "[^"]*"/, "sha256 \"" v4 "\"")
}
{ print }
' "$FORMULA" > "$FORMULA.tmp" && mv "$FORMULA.tmp" "$FORMULA"
- name: Commit and push
run: |
cd homebrew-tap
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Formula/qctl.rb
git diff --staged --quiet || git commit -m "Update qctl to ${GITHUB_REF_NAME}"
git push
update-scoop:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: develop
- name: Download Windows artifact
uses: actions/download-artifact@v4
with:
name: qctl-windows-amd64.exe
path: artifacts
- name: Update Scoop manifest
env:
VERSION: ${{ github.ref_name }}
run: |
VERSION="${VERSION#v}"
SHA_WINDOWS=$(shasum -a 256 artifacts/qctl-windows-amd64.exe | cut -d' ' -f1)
MANIFEST="packaging/scoop/qctl.json"
# Update version
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" "$MANIFEST"
# Update download URL version
sed -i "s|/download/v[^/]*/|/download/v${VERSION}/|g" "$MANIFEST"
# Update hash
sed -i "s/\"hash\": \"[^\"]*\"/\"hash\": \"${SHA_WINDOWS}\"/" "$MANIFEST"
- name: Clean up artifacts
run: rm -rf artifacts
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore(scoop): update manifest to ${{ github.ref_name }}"
title: "chore(scoop): update manifest to ${{ github.ref_name }}"
body: "Automated Scoop manifest update for release ${{ github.ref_name }}"
branch: chore/scoop-${{ github.ref_name }}
base: develop
add-paths: packaging/scoop/qctl.json
update-aur:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: develop
- name: Download Linux AMD64 artifact
uses: actions/download-artifact@v4
with:
name: qctl-linux-amd64
path: artifacts
- name: Update AUR PKGBUILD
env:
VERSION: ${{ github.ref_name }}
run: |
VERSION="${VERSION#v}"
SHA_LINUX=$(shasum -a 256 artifacts/qctl-linux-amd64 | cut -d' ' -f1)
PKGBUILD="packaging/aur/PKGBUILD"
# Update version
sed -i "s/pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD"
# Reset pkgrel for new version
sed -i "s/pkgrel=.*/pkgrel=1/" "$PKGBUILD"
# Update hash
sed -i "s/sha256sums_x86_64=('[^']*')/sha256sums_x86_64=('${SHA_LINUX}')/" "$PKGBUILD"
- name: Clean up artifacts
run: rm -rf artifacts
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore(aur): update PKGBUILD to ${{ github.ref_name }}"
title: "chore(aur): update PKGBUILD to ${{ github.ref_name }}"
body: "Automated AUR PKGBUILD update for release ${{ github.ref_name }}"
branch: chore/aur-${{ github.ref_name }}
base: develop
add-paths: packaging/aur/PKGBUILD
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
-8
View File
@@ -189,11 +189,3 @@ mvn test -pl qctl-integration-tests # E2E tests only
## 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`
+17 -1
View File
@@ -1,6 +1,22 @@
# Session State
Last updated: 2026-01-03
Last updated: 2026-10-30
## Native CI qualification fixes (2026-10-30)
Local native CI exposed three defects: `--no-prompt` omitted declared defaults,
unknown templates still entered interactive selection, and Windows backslashes
escaped Velocity path variables. Defaults now apply with validated explicit
overrides; unknown templates return NOT_FOUND without reading stdin; path
separators are normalized before interpolation. Interactive fallback remains
covered separately.
Four non-interactive regressions and a Windows-path regression failed before the
fixes. On macOS27.2 ARM64/GraalVM21.0.12, full Maven verification, native packaging
and verification with the built binary passed235 tests across26 reports with
zero failures/errors/skips. Native tests must run after building the binary;
the earlier build-before-test arrangement silently skipped10 native E2E cases.
Exact-commit Gitea macOS/Windows verification and normal PR review remain pending.
## Current Status
@@ -298,6 +298,82 @@ class InitCommandE2ETest
/***************************************************************************
* Non-interactive generation applies defaults without consuming stdin.
*
* @throws IOException if the generated file cannot be read
* @since 0.2.1
***************************************************************************/
@Test
void testInit_noPrompt_usesDefaults() throws IOException
{
Path outputDir = tempDir.resolve("default-values");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).as(harness.getStderr()).isEqualTo(ExitCodes.SUCCESS);
assertThat(Files.readString(outputDir.resolve("README.md")))
.contains("my-project", "com.example");
}
/***************************************************************************
* Explicit variables override defaults while omitted variables retain them.
*
* @throws IOException if the generated file cannot be read
* @since 0.2.1
***************************************************************************/
@Test
void testInit_noPrompt_overridesOneDefault() throws IOException
{
Path outputDir = tempDir.resolve("override-default");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=custom-project", "-o", outputDir.toString());
assertThat(harness.getExitCode()).as(harness.getStderr()).isEqualTo(ExitCodes.SUCCESS);
assertThat(Files.readString(outputDir.resolve("README.md")))
.contains("custom-project", "com.example");
}
/***************************************************************************
* An unknown template must not fall back to an interactive selection.
*
* @since 0.2.1
***************************************************************************/
@Test
void testInit_noPrompt_unknownTemplateReturnsNotFound()
{
Path outputDir = tempDir.resolve("unknown-template");
harness.execute("qqq", "init", "missing-template", "--no-prompt",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.NOT_FOUND);
assertThat(outputDir).doesNotExist();
}
/***************************************************************************
* Invalid required overrides must fail instead of prompting or using defaults.
*
* @since 0.2.1
***************************************************************************/
@Test
void testInit_noPrompt_rejectsEmptyRequiredOverride()
{
Path outputDir = tempDir.resolve("invalid-override");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=", "-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.VALIDATION);
assertThat(outputDir).doesNotExist();
}
/***************************************************************************
* Test combined flags work together.
*
@@ -613,20 +613,19 @@ class InitCommandFullE2ETest
/***************************************************************************
* 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.
* Interactive selection can accept the first available fallback template.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_unknownTemplate_fallsBackToFirst() throws IOException
void testInit_unknownTemplate_interactiveFallbackToFirst() throws IOException
{
Path outputDir = tempDir.resolve("unknown-template-test");
//////////////////////////////////////////////////////////////////////////
// With --no-prompt, requesting unknown template uses first available //
// The empty input accepts the first interactive selection. //
//////////////////////////////////////////////////////////////////////////
harness.execute("qqq", "init", "non-existent-template", "--no-prompt",
harness.execute("qqq", "init", "non-existent-template",
"-o", outputDir.toString());
//////////////////////////////////////////////////////////////////////////
@@ -37,6 +37,7 @@ 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.PromptValidator;
import io.qrun.qctl.qqq.template.TemplateEngine;
import io.qrun.qctl.qqq.template.TemplateManifest;
import io.qrun.qctl.qqq.template.TemplateRenderException;
@@ -172,6 +173,21 @@ public class InitCommand implements Callable<Integer>
// Step 4: Collect variables (prompts + CLI overrides)
Map<String, String> allVars = new LinkedHashMap<>();
if(noPrompt && manifest.prompts() != null)
{
PromptValidator validator = new PromptValidator();
for(TemplateManifest.Prompt prompt : manifest.prompts())
{
String value = variables.getOrDefault(prompt.name(), prompt.defaultValue());
PromptValidator.ValidationResult result = validator.validate(prompt, value);
if(!result.valid())
{
ui.error(result.errorMessage());
return ExitCodes.VALIDATION;
}
allVars.put(prompt.name(), value);
}
}
if(!noPrompt && manifest.prompts() != null && !manifest.prompts().isEmpty())
{
PromptRunner promptRunner = new PromptRunner();
@@ -318,6 +334,12 @@ public class InitCommand implements Callable<Integer>
List<String> templateIds = templates.stream().map(TemplateInfo::id).toList();
SuggestionEngine suggestionEngine = new SuggestionEngine();
List<String> suggestions = suggestionEngine.findSimilar(templateName, templateIds);
if(noPrompt)
{
throw new TemplateError(TemplateError.ErrorCategory.TEMPLATE_NOT_FOUND,
"Template '" + templateName + "' not found.", suggestions,
"Use 'qctl qqq list' to list available templates");
}
ui.error("Template '" + templateName + "' not found.");
if(!suggestions.isEmpty())
@@ -409,7 +409,9 @@ public class TemplateEngine
// 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");
// Windows separators otherwise escape the following Velocity variable.
String normalizedPath = path.replace('\\', '/')
.replaceAll("__([a-zA-Z][a-zA-Z0-9]*)__", "\\$$1");
return renderContent(normalizedPath, variables);
}
@@ -15,6 +15,7 @@ package io.qrun.qctl.qqq.template;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
@@ -44,11 +45,19 @@ public class TemplateResolver
public Path resolve(String source, String version) throws IOException
{
// Local path
Path localPath = Path.of(source);
if(Files.isDirectory(localPath))
try
{
System.out.println("Using local template: " + localPath.toAbsolutePath());
return localPath.toAbsolutePath();
Path localPath = Path.of(source);
if(Files.isDirectory(localPath))
{
System.out.println("Using local template: " + localPath.toAbsolutePath());
return localPath.toAbsolutePath();
}
}
catch(InvalidPathException e)
{
// Remote URLs are not valid filesystem paths on Windows.
// Validate them below using the supported Git URL formats.
}
// Git URL or GitHub shorthand
@@ -303,6 +303,24 @@ class TemplateEngineTest
/***************************************************************************
* Windows separators must not escape path placeholders in Velocity.
*
* @throws TemplateRenderException if rendering fails
* @since 0.2.1
***************************************************************************/
@Test
void testRenderWindowsPathVariables() throws TemplateRenderException
{
Map<String, String> variables = Map.of("packagePath", "com/acme/orders");
assertThat(engine.renderPath("src\\main\\java\\$packagePath\\App.java", variables))
.isEqualTo("src/main/java/com/acme/orders/App.java");
assertThat(engine.renderPath("src\\main\\java\\__packagePath__\\App.java", variables))
.isEqualTo("src/main/java/com/acme/orders/App.java");
}
/***************************************************************************
* Test binary files are copied without modification.
***************************************************************************/