From 794f2b3bb8460989674ae8780206878408512723 Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 16:53:58 +0300 Subject: [PATCH 01/20] ci: implement reusable npm-release-ops workflow and publish script --- .github/workflows/npm-release-ops.yml | 200 ++++++++++++++++++++++++++ docs/npm-release-ops.md | 61 ++++++++ examples/npm-release-ops.yml | 22 +++ scripts/npm-publish.sh | 75 ++++++++++ 4 files changed, 358 insertions(+) create mode 100644 .github/workflows/npm-release-ops.yml create mode 100644 docs/npm-release-ops.md create mode 100644 examples/npm-release-ops.yml create mode 100755 scripts/npm-publish.sh diff --git a/.github/workflows/npm-release-ops.yml b/.github/workflows/npm-release-ops.yml new file mode 100644 index 0000000..e25334a --- /dev/null +++ b/.github/workflows/npm-release-ops.yml @@ -0,0 +1,200 @@ +name: NPM Release + +on: + workflow_call: + inputs: + node_version: + description: "Node.js: Version to use (default: 20)" + required: false + default: "20" + type: string + build_command: + description: "Build: Command to run before publishing (default: npm run build, set to empty to skip)" + required: false + default: "npm run build" + type: string + test_command: + description: "Test: Command to run before building (default: npm test, set to empty to skip)" + required: false + default: "npm test" + type: string + registry_url: + description: "NPM: Registry URL (default: https://registry.npmjs.org/)" + required: false + default: "https://registry.npmjs.org/" + type: string + provenance: + description: "NPM: Enable provenance for the release (default: true)" + required: false + default: true + type: boolean + release_branch: + description: "Branch: Deployment branch that triggers releases (default: latest)" + required: false + default: "latest" + type: string + enable_gh_release: + description: "GitHub: Whether to create a GitHub release (default: true)" + required: false + default: true + type: boolean + check_version_bump: + description: "NPM: Whether to check if the version is already published (default: true)" + required: false + default: true + type: boolean + working_directory: + description: "Common: Directory containing package.json" + required: false + type: string + default: "." + secrets: + npm_token: + description: "NPM: Token for publishing to the registry" + required: true + slack_webhook_url: + description: "Slack: Webhook URL for notifications (Optional)" + required: false + +jobs: + config: + runs-on: ubuntu-latest + outputs: + current_branch: ${{ steps.branches.outputs.branch_name }} + is_release_branch: ${{ steps.branches.outputs.is_release_branch }} + release_version: ${{ steps.package_version.outputs.version }} + package_name: ${{ steps.package_version.outputs.name }} + has_slack_webhook: ${{ steps.check_slack.outputs.has_webhook }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get Package Info + id: package_version + run: | + VERSION=$(node -p "require('./package.json').version") + NAME=$(node -p "require('./package.json').name") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "name=$NAME" >> $GITHUB_OUTPUT + echo "๐Ÿ“ฆ Package: $NAME@$VERSION" + working-directory: ${{ inputs.working_directory }} + + - name: Determine Branch Information + id: branches + run: | + BRANCH_NAME=${GITHUB_REF#refs/heads/} + echo "branch_name=${BRANCH_NAME}" >> $GITHUB_OUTPUT + + IS_RELEASE_BRANCH=$([[ "${BRANCH_NAME}" == "${{ inputs.release_branch }}" ]] && echo "true" || echo "false") + echo "is_release_branch=${IS_RELEASE_BRANCH}" >> $GITHUB_OUTPUT + echo "๐Ÿ“Œ Branch: ${BRANCH_NAME} (Release: ${IS_RELEASE_BRANCH})" + + - name: Check Slack Webhook + id: check_slack + run: | + if [ -n "${{ secrets.slack_webhook_url }}" ]; then + echo "has_webhook=true" >> $GITHUB_OUTPUT + else + echo "has_webhook=false" >> $GITHUB_OUTPUT + fi + + release: + needs: [config] + if: needs.config.outputs.is_release_branch == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write # Required for provenance + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node_version }} + registry-url: ${{ inputs.registry_url }} + cache: 'npm' + cache-dependency-path: ${{ inputs.working_directory }}/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: ${{ inputs.working_directory }} + + - name: Run Tests + if: inputs.test_command != '' + run: ${{ inputs.test_command }} + working-directory: ${{ inputs.working_directory }} + + - name: Publish to NPM + env: + NODE_AUTH_TOKEN: ${{ secrets.npm_token }} + REGISTRY_URL: ${{ inputs.registry_url }} + PROVENANCE: ${{ inputs.provenance }} + BUILD_COMMAND: ${{ inputs.build_command }} + CHECK_VERSION_BUMP: ${{ inputs.check_version_bump }} + NPM_TAG: ${{ inputs.release_branch }} + WORKING_DIR: ${{ inputs.working_directory }} + run: ./scripts/npm-publish.sh + + - name: Parse Changelog + if: inputs.enable_gh_release == true + id: changelog + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const version = '${{ needs.config.outputs.release_version }}'; + let description = 'Release ' + version; + + if (fs.existsSync('./changes.md')) { + const content = fs.readFileSync('./changes.md', 'utf8'); + const lines = content.split('\n'); + const startMarker = `### ${version}`; + let found = false; + let body = []; + + for (const line of lines) { + if (line.startsWith(startMarker)) { + found = true; + continue; + } + if (found) { + if (line.startsWith('### ')) break; + body.push(line); + } + } + if (body.length > 0) { + description = body.join('\n').trim(); + } + } + core.setOutput('description', description); + + - name: Create GitHub Release + if: inputs.enable_gh_release == true + uses: softprops/action-gh-release@v2 + with: + name: "Release ${{ needs.config.outputs.release_version }}" + tag_name: "v${{ needs.config.outputs.release_version }}" + body: ${{ steps.changelog.outputs.description }} + token: ${{ github.token }} + + - name: Notify Slack + if: always() && needs.config.outputs.has_slack_webhook == 'true' + uses: slackapi/slack-github-action@v1.26.0 + with: + payload: | + { + "text": "NPM Release for ${{ needs.config.outputs.package_name }}: ${{ job.status == 'success' && 'โœ… SUCCESS' || 'โŒ FAILED' }}", + "attachments": [ + { + "color": "${{ job.status == 'success' && '#36a64f' || '#ec000c' }}", + "fields": [ + { "title": "Version", "value": "${{ needs.config.outputs.release_version }}", "short": true }, + { "title": "Commit", "value": "${{ github.sha }}", "short": true } + ] + } + ] + } + env: + SLACK_WEB_HOOK_URL: ${{ secrets.slack_webhook_url }} diff --git a/docs/npm-release-ops.md b/docs/npm-release-ops.md new file mode 100644 index 0000000..0e27657 --- /dev/null +++ b/docs/npm-release-ops.md @@ -0,0 +1,61 @@ +# NPM Release Workflow + + +A reusable workflow to publish npm packages to a registry with security best practices, optional build/test steps, and GitHub release generation. + +## CLI Generator + +### Flow + +1. **Select template** โ†’ npm-release +2. **Setup Node** โ†’ Node version, registry URL +3. **Execution options** โ†’ Build command, test command, provenance +4. **Secrets configuration** โ†’ `NPM_TOKEN` + +### Registry Prompts + +**NPM:** +- Registry URL โ†’ `https://registry.npmjs.org/` +- Provenance โ†’ `true` +- Secret: `NPM_TOKEN` + +## Setup Guide + +### 1. Configure NPM Token +To publish packages, you need an automation token from your npm registry. + +- **npmjs.com**: Go to Access Tokens โ†’ Generate New Token โ†’ Automation. +- **Organization Scopes**: Ensure the token has `read-write` access to the target package. + +### 2. Add Secret to GitHub +Add the generated token as a secret in your repository or organization: +- Name: `NPM_TOKEN` +- Value: *[your-token]* + +### 3. Local Usage +You can use the common release script locally for testing or manual releases: + +```bash +# Set required environment variables +export NPM_TOKEN=your_token +export BUILD_COMMAND="npm run build" + +# Run the script +./scripts/npm-publish.sh +``` + +### 4. GitHub Actions Usage +Call this workflow from your release pipeline (see [example](../examples/npm-release-ops.yml)). + +## Workflow Inputs + +| Input | Description | Default | +|-------|-------------|---------| +| `node_version` | Node.js: Version to use | `20` | +| `build_command` | Build: Command to run before publishing | `npm run build` | +| `registry_url` | NPM: Registry URL | `https://registry.npmjs.org/` | +| `provenance` | NPM: Enable provenance | `true` | +| `release_branch`| Branch: Deployment branch that triggers releases | `latest` | + +## Security and Provenance +This workflow enables **NPM Provenance** by default. This links your published package to the specific GitHub Action run that created it, providing a verifiable chain of custody for your users. diff --git a/examples/npm-release-ops.yml b/examples/npm-release-ops.yml new file mode 100644 index 0000000..cf843d0 --- /dev/null +++ b/examples/npm-release-ops.yml @@ -0,0 +1,22 @@ +name: Release Package + +on: + push: + branches: + - latest + +jobs: + release: + permissions: + contents: write + id-token: write + uses: udx/reusable-workflows/.github/workflows/npm-release-ops.yml@master + with: + node_version: "20" + build_command: "npm run build" + test_command: "npm test" + release_branch: "latest" + provenance: "true" + secrets: + npm_token: ${{ secrets.NPM_TOKEN }} + slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/scripts/npm-publish.sh b/scripts/npm-publish.sh new file mode 100755 index 0000000..4934e5f --- /dev/null +++ b/scripts/npm-publish.sh @@ -0,0 +1,75 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}๐Ÿš€ Starting NPM release process...${NC}" + +# Switch to working directory if specified +if [ -n "$WORKING_DIR" ]; then + echo -e "๐Ÿ“‚ Switching to directory: ${YELLOW}${WORKING_DIR}${NC}" + cd "$WORKING_DIR" +fi + +# Check for package.json +if [ ! -f "package.json" ]; then + echo -e "${RED}โŒ Error: package.json not found in the current directory.${NC}" + exit 1 +fi + +# Extract version and name from package.json +PACKAGE_NAME=$(node -p "require('./package.json').name") +PACKAGE_VERSION=$(node -p "require('./package.json').version") + +if [ -z "$PACKAGE_NAME" ] || [ -z "$PACKAGE_VERSION" ]; then + echo -e "${RED}โŒ Error: Could not extract name or version from package.json.${NC}" + exit 1 +fi + +echo -e "๐Ÿ“ฆ Package: ${YELLOW}${PACKAGE_NAME}${NC}" +echo -e "๐Ÿท๏ธ Version: ${YELLOW}${PACKAGE_VERSION}${NC}" + +# Optional version check +if [ "$CHECK_VERSION_BUMP" != "false" ]; then + echo -e "๐Ÿ” Checking if version ${PACKAGE_VERSION} is already published..." + # npm view returns exit code 1 if package or version doesn't exist + if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then + echo -e "${RED}โŒ Error: Version ${PACKAGE_VERSION} is already published to the registry.${NC}" + echo -e "${YELLOW}Please bump the version in package.json before releasing.${NC}" + exit 1 + else + echo -e "${GREEN}โœ… Version ${PACKAGE_VERSION} is new. Proceeding...${NC}" + fi +fi + +# Build project if command provided +if [ -n "$BUILD_COMMAND" ]; then + echo -e "๐Ÿ—๏ธ Running build: ${YELLOW}${BUILD_COMMAND}${NC}" + eval "$BUILD_COMMAND" +fi + +# Publish to NPM +echo -e "๐Ÿšข Publishing to NPM..." +PUBLISH_ARGS=() + +if [ -n "$REGISTRY_URL" ]; then + PUBLISH_ARGS+=("--registry" "$REGISTRY_URL") +fi + +if [ "$PROVENANCE" == "true" ]; then + PUBLISH_ARGS+=("--provenance") +fi + +if [ -n "$PUBLISH_TAG" ]; then + PUBLISH_ARGS+=("--tag" "$PUBLISH_TAG") +fi + +# If NPM_TOKEN is provided, ensure it's used (though npm usually picks it up from env or .npmrc) +# The orchestration layer (GH Action) should handle .npmrc setup for NODE_AUTH_TOKEN +npm publish "${PUBLISH_ARGS[@]}" + +echo -e "${GREEN}โœ… Successfully published ${PACKAGE_NAME}@${PACKAGE_VERSION}${NC}" From 4d28835b987d6a2a84d73a98118ca6a4888c1027 Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 16:54:07 +0300 Subject: [PATCH 02/20] feat(cli): implement generator with auto-detection and version pinning --- cli/README.md | 41 ++++++ cli/index.js | 277 +++++++++++++++++++++++++++++++++++++ cli/lib/config-detector.js | 51 +++++++ cli/lib/file-generator.js | 183 ++++++++++++++++++++++++ cli/lib/input-grouper.js | 59 ++++++++ cli/lib/template-loader.js | 107 ++++++++++++++ cli/lib/ui.js | 66 +++++++++ cli/package.json | 31 +++++ 8 files changed, 815 insertions(+) create mode 100644 cli/README.md create mode 100644 cli/index.js create mode 100644 cli/lib/config-detector.js create mode 100644 cli/lib/file-generator.js create mode 100644 cli/lib/input-grouper.js create mode 100644 cli/lib/template-loader.js create mode 100644 cli/lib/ui.js create mode 100644 cli/package.json diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..62e11e7 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,41 @@ +# Reusable Workflows CLI + +A minimal-friction CLI to integrate and update GitHub Actions workflows based on the [udx/reusable-workflows](https://github.com/udx/reusable-workflows) templates. + +## Usage + +Run the CLI in the root of your repository to select, configure, and update workflows. + +### On-the-fly (Recommended) +Always runs the latest version without installation: +```bash +npx @udx/reusable-workflows [template-id] [options] +``` + +### Global Installation +For frequent use as a local command: +```bash +npm install -g @udx/reusable-workflows +reusable-workflows [template-id] [options] +``` + +## Features + +- **Smart Auto-Detection**: Scans `.github/workflows` to pre-fill existing configurations. +- **Fast-Path Flow**: Skips redundant prompts and moves straight to a manifest preview. +- **One-Glance Preview**: Review the generated YAML in your terminal before writing. +- **Opt-in Setup Guides**: Documentation (`SETUP-*.md`) is generated only when requested. +- **Non-Interactive Mode**: Support for headless environments and scripts. + +## Options + +- `[template-id]`: Optional positional argument to skip selection (e.g., `docker-ops`). +- `-n, --non-interactive`: Use detected/default values without prompting. +- `-h, --help`: Show usage instructions. + +## Metadata Architecture + +The CLI parses a three-layer structure to provide a dynamic UI: +1. **Workflow (.yml)**: Defines inputs with registry-prefixed metadata. +2. **Docs (.md)**: Provides the generator flow and setup instructions. +3. **Example (.yml)**: Acts as the base template for the generated manifest. diff --git a/cli/index.js b/cli/index.js new file mode 100644 index 0000000..c547482 --- /dev/null +++ b/cli/index.js @@ -0,0 +1,277 @@ +#!/usr/bin/env node + +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import inquirer from 'inquirer'; +import chalk from 'chalk'; + +import { CLI_CONFIG } from './config.js'; +import { TemplateLoader } from './lib/template-loader.js'; +import { InputGrouper } from './lib/input-grouper.js'; +import { UI } from './lib/ui.js'; +import { FileGenerator } from './lib/file-generator.js'; +import { ConfigDetector } from './lib/config-detector.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Main workflow generator orchestrator + * Coordinates template loading, input grouping, and user prompts + */ +class WorkflowGenerator { + constructor() { + this.config = CLI_CONFIG; + this.repoRoot = join(__dirname, '..'); + + // Initialize modules + this.templateLoader = new TemplateLoader(this.repoRoot, this.config); + this.inputGrouper = new InputGrouper(this.config); + this.ui = new UI(this.config); + this.fileGenerator = new FileGenerator(this.config); + this.configDetector = new ConfigDetector(); + + // Load templates + this.templates = this.templateLoader.loadTemplates(); + } + + async run() { + const args = process.argv.slice(2); + const isHelp = args.includes('--help') || args.includes('-h'); + const isNonInteractive = args.includes('--non-interactive') || args.includes('-n'); + + // Support positional template selection: reusable-workflows + const positionalTemplateId = args.find(arg => !arg.startsWith('-')); + + // Support custom output path: --output or -o + const outputIdx = args.findIndex(arg => arg === '--output' || arg === '-o'); + const customOutputPath = outputIdx !== -1 ? args[outputIdx + 1] : null; + + // Support reference pinning: --ref or -r + const refIdx = args.findIndex(arg => arg === '--ref' || arg === '-r'); + const cliRef = refIdx !== -1 ? args[refIdx + 1] : null; + + if (isHelp) { + this.ui.printHelp(); + return; + } + + const icons = this.config.ui.icons; + + // Header + this.ui.printHeader(); + + // Template selection + let templateId = positionalTemplateId; + if (!templateId) { + const result = await inquirer.prompt([ + { + type: 'list', + name: 'templateId', + message: 'Select workflow template:', + pageSize: this.config.ui.pageSize, + choices: this.ui.buildTemplateChoices(this.templates) + } + ]); + templateId = result.templateId; + } + + const template = this.templates.find(t => t.id === templateId); + const inputs = this.templateLoader.parseWorkflowInputs(template); + + // Auto-detection phase + const detectedValues = this.configDetector.detectExistingConfig(templateId); + if (Object.keys(detectedValues).length > 0) { + console.log(chalk.green(`\nโœจ Auto-detected existing configuration from .github/workflows\n`)); + } + // Configuration + const groupedInputs = this.inputGrouper.groupInputsByPrefix(inputs); + + // Version / Ref selection + let versionRef = cliRef; + if (!versionRef && !isNonInteractive) { + const { selectedRef } = await inquirer.prompt([ + { + type: 'input', + name: 'selectedRef', + message: 'Workflow reference (branch/tag):', + default: 'master' + } + ]); + versionRef = selectedRef; + } else if (!versionRef) { + versionRef = 'master'; + } + + // Common inputs + this.ui.printSectionHeader(icons.config, 'Configuration'); + const commonAnswers = await this.promptInputs(groupedInputs.common, detectedValues, isNonInteractive); + + // Optional component groups + const prefixGroups = Object.keys(groupedInputs).filter(key => key !== 'common'); + let groupAnswers = {}; + let selectedGroups = []; + + if (prefixGroups.length > 0) { + if (!isNonInteractive) { + this.ui.printSectionHeader(icons.components, 'Optional Components'); + + const result = await inquirer.prompt([ + { + type: 'checkbox', + name: 'selectedGroups', + message: 'Select components to configure:', + choices: prefixGroups.map(key => ({ + name: groupedInputs[key][0].prefix, + value: key, + checked: selectedGroups.includes(key) || groupedInputs[key].some(input => detectedValues[input.name] !== undefined) + })) + } + ]); + + selectedGroups = result.selectedGroups; + } else { + // In non-interactive mode, select groups that have detected inputs + selectedGroups = prefixGroups.filter(key => + groupedInputs[key].some(input => detectedValues[input.name] !== undefined) + ); + } + + for (const groupKey of selectedGroups) { + const groupInputs = groupedInputs[groupKey]; + const groupName = groupInputs[0].prefix; + + if (!isNonInteractive) { + this.ui.printSectionHeader(icons.group, `${groupName} Configuration`); + } + const answers = await this.promptInputs(groupInputs, detectedValues, isNonInteractive); + groupAnswers = { ...groupAnswers, ...answers }; + } + } + + const allAnswers = { ...commonAnswers, ...groupAnswers }; + + // Preview and Confirmation + if (!isNonInteractive) { + this.ui.printSectionHeader(icons.complete, 'Preview Manifest'); + const manifestContent = this.fileGenerator.previewManifest(template, allAnswers); + console.log(chalk.gray('---')); + console.log(chalk.white(manifestContent)); + console.log(chalk.gray('---')); + + const { confirmAction, generateSetupDoc } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmAction', + message: 'Proceed to generate/update workflow manifest?', + default: true + }, + { + type: 'confirm', + name: 'generateSetupDoc', + message: 'Do you also need a setup guide (SETUP-*.md)?', + default: false, + when: (ans) => ans.confirmAction + } + ]); + + if (!confirmAction) { + console.log(chalk.red('\nโœ— Generation cancelled')); + return { cancelled: true }; + } + var finalGenerateSetup = generateSetupDoc; + } else { + console.log(chalk.green(`\n๐Ÿš€ Non-interactive mode: applying detected/default values...`)); + var finalGenerateSetup = false; // Usually not needed in scripts + } + + // Generate files + const result = await this.fileGenerator.generateFiles( + template, + allAnswers, + selectedGroups, + finalGenerateSetup, + customOutputPath, + versionRef + ); + + if (result.cancelled) { + return { cancelled: true }; + } + + // Results + this.ui.printSectionHeader(icons.complete, 'Generation Complete'); + console.log(chalk.green('โœ“ Generated: ') + chalk.white(result.workflowFile)); + console.log(chalk.green('โœ“ Generated: ') + chalk.white(result.setupFile)); + + this.ui.printSectionHeader(icons.steps, 'Next Steps'); + console.log(chalk.white(' 1. Review ') + chalk.cyan(result.workflowFile)); + console.log(chalk.white(' 2. Follow ') + chalk.cyan(result.setupFile) + chalk.white(' to configure GitHub secrets')); + console.log(chalk.white(' 3. Commit and push to trigger workflow')); + + this.ui.printFooter(); + + return { templateId, answers: allAnswers, files: result }; + } + + async promptInputs(inputs, detectedValues = {}, isNonInteractive = false) { + if (!inputs || inputs.length === 0) { + return {}; + } + + // In non-interactive mode, we skip all prompts and use detected/default values + if (isNonInteractive) { + const fallbackAnswers = {}; + inputs.forEach(input => { + if (detectedValues[input.name] === undefined) { + fallbackAnswers[input.name] = input.default || ''; + } + }); + return { ...detectedValues, ...fallbackAnswers }; + } + + const questions = inputs + .filter(input => detectedValues[input.name] === undefined) + .map(input => { + const question = { + type: 'input', + name: input.name, + message: this.inputGrouper.extractPrompt(input.description), + default: input.default || '' + }; + + if (input.required) { + question.validate = (val) => { + if (val && val.length > 0) return true; + return 'This field is required'; + }; + } + + return question; + }); + + const answers = await inquirer.prompt(questions); + + // Merge detected values back in + return { ...detectedValues, ...answers }; + } +} + +// ============================================================================ +// MAIN +// ============================================================================ +async function main() { + try { + const generator = new WorkflowGenerator(); + await generator.run(); + } catch (error) { + if (error.isTtyError) { + console.error(chalk.red('Prompt couldn\'t be rendered in the current environment')); + } else { + console.error(chalk.red('Error:'), error.message); + } + process.exit(1); + } +} + +main(); diff --git a/cli/lib/config-detector.js b/cli/lib/config-detector.js new file mode 100644 index 0000000..99696aa --- /dev/null +++ b/cli/lib/config-detector.js @@ -0,0 +1,51 @@ +import { readFileSync, readdirSync, existsSync } from 'fs'; +import { join } from 'path'; +import yaml from 'js-yaml'; + +/** + * Scans the current repository for existing reuseable workflow usages + * to auto-detect configuration values and pre-fill answers. + */ +export class ConfigDetector { + constructor() { + this.workflowDir = join(process.cwd(), '.github/workflows'); + } + + /** + * Scans for a specific template usage in the current repository + * @param {string} templateId - The ID of the template (e.g., 'docker-ops') + * @returns {Object} - Detected configuration values + */ + detectExistingConfig(templateId) { + if (!existsSync(this.workflowDir)) { + return {}; + } + + const detectedConfig = {}; + const files = readdirSync(this.workflowDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml')); + + for (const file of files) { + try { + const content = readFileSync(join(this.workflowDir, file), 'utf8'); + const manifest = yaml.load(content); + + // Scan all jobs in the manifest + for (const jobConfig of Object.values(manifest.jobs || {})) { + if (jobConfig.uses && jobConfig.uses.includes(`${templateId}.yml`)) { + // Found usage of this template + if (jobConfig.with) { + Object.assign(detectedConfig, jobConfig.with); + } + // We also want to capture which secrets are used, though they are usually patterns like ${{ secrets.X }} + // but we can at least show what was there. + break; + } + } + } catch (e) { + // Skip unparseable files + } + } + + return detectedConfig; + } +} diff --git a/cli/lib/file-generator.js b/cli/lib/file-generator.js new file mode 100644 index 0000000..d9c77f0 --- /dev/null +++ b/cli/lib/file-generator.js @@ -0,0 +1,183 @@ +import { writeFileSync, existsSync, mkdirSync, readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import yaml from 'js-yaml'; +import inquirer from 'inquirer'; +import chalk from 'chalk'; + +/** + * Generates workflow manifest and setup documentation + */ +export class FileGenerator { + constructor(config) { + this.config = config; + } + + async generateFiles(template, answers, selectedGroups = [], generateSetup = false, customOutputPath = null, ref = 'master') { + const workflowDir = join(process.cwd(), '.github/workflows'); + const workflowFile = customOutputPath + ? (customOutputPath.endsWith('.yml') || customOutputPath.endsWith('.yaml') ? customOutputPath : join(customOutputPath, `${template.id}.yml`)) + : join(workflowDir, `${template.id}.yml`); + const setupFile = join(process.cwd(), `SETUP-${template.id}.md`); + + // Ensure destination directory exists + const finalDir = dirname(workflowFile); + if (!existsSync(finalDir)) { + mkdirSync(finalDir, { recursive: true }); + } + + // Generate workflow manifest + const workflowContent = this.generateWorkflowManifest(template, answers, ref); + writeFileSync(workflowFile, workflowContent, 'utf8'); + + // Generate setup documentation only if requested + if (generateSetup) { + const setupContent = this.generateSetupDoc(template, answers, selectedGroups); + writeFileSync(setupFile, setupContent, 'utf8'); + } + + return { + workflowFile, + setupFile: generateSetup ? setupFile : null, + cancelled: false + }; + } + + previewManifest(template, answers, ref = 'master') { + return this.generateWorkflowManifest(template, answers, ref); + } + + generateWorkflowManifest(template, answers, ref = 'master') { + // Read example file as base + const exampleContent = readFileSync(template.examplesPath, 'utf8'); + const example = yaml.load(exampleContent); + + // Find the job that uses the reusable workflow + let workflowJob = null; + for (const [jobName, jobConfig] of Object.entries(example.jobs || {})) { + if (jobConfig.uses) { + workflowJob = jobConfig; + break; + } + } + + // Update the 'uses' reference + if (workflowJob.uses) { + const base = workflowJob.uses.split('@')[0]; + workflowJob.uses = `${base}@${ref}`; + } + + // Update the 'with' section with user answers and detected values + if (!workflowJob.with) { + workflowJob.with = {}; + } + + // Merge answers into the existing 'with' section + for (const [key, value] of Object.entries(answers)) { + if (value !== undefined) { + workflowJob.with[key] = value; + } + } + + // Generate the manifest + return yaml.dump(example, { + lineWidth: -1, + noRefs: true, + quotingType: '"', + forceQuotes: false + }); + } + + generateSetupDoc(template, answers, selectedGroups) { + const docsContent = readFileSync(template.docsPath, 'utf8'); + + let setup = `# Setup Guide: ${template.name}\n\n`; + setup += `This guide will help you configure the workflow in your repository.\n\n`; + + setup += `## 1. Workflow File\n\n`; + setup += `The workflow manifest has been generated at:\n`; + setup += `\`\`\`\n.github/workflows/${template.id}.yml\n\`\`\`\n\n`; + + setup += `## 2. Configuration Summary\n\n`; + setup += `Your configuration:\n\n`; + for (const [key, value] of Object.entries(answers)) { + setup += `- **${key}**: \`${value}\`\n`; + } + setup += `\n`; + + if (selectedGroups.length > 0) { + setup += `## 3. Selected Components\n\n`; + setup += `You have enabled the following optional components:\n\n`; + selectedGroups.forEach(group => { + setup += `- ${group}\n`; + }); + setup += `\n`; + } + + setup += `## ${selectedGroups.length > 0 ? '4' : '3'}. GitHub Secrets & Variables\n\n`; + setup += `Configure the following in your repository settings:\n\n`; + setup += `**Settings โ†’ Secrets and variables โ†’ Actions**\n\n`; + + // Extract secrets/vars from docs + const secretsSection = this.extractSecretsFromDocs(docsContent); + if (secretsSection) { + setup += secretsSection + '\n'; + } + + setup += `## ${selectedGroups.length > 0 ? '5' : '4'}. Permissions\n\n`; + setup += `Ensure your workflow has the required permissions:\n\n`; + + const permissionsSection = this.extractPermissionsFromDocs(docsContent); + if (permissionsSection) { + setup += permissionsSection + '\n'; + } + + setup += `## ${selectedGroups.length > 0 ? '6' : '5'}. Complete Documentation\n\n`; + setup += `For detailed information, see: [\`docs/${template.id}.md\`](docs/${template.id}.md)\n`; + + return setup; + } + + extractSecretsFromDocs(docsContent) { + // Look for secrets/configuration section + const lines = docsContent.split('\n'); + let inSecretsSection = false; + let secretsContent = ''; + + for (const line of lines) { + if (line.match(/^##\s+(Secrets|Configuration|Inputs)/i)) { + inSecretsSection = true; + continue; + } + if (inSecretsSection && line.match(/^##\s+/)) { + break; + } + if (inSecretsSection) { + secretsContent += line + '\n'; + } + } + + return secretsContent.trim() || 'See documentation for required secrets and variables.'; + } + + extractPermissionsFromDocs(docsContent) { + // Look for permissions section + const lines = docsContent.split('\n'); + let inPermissionsSection = false; + let permissionsContent = ''; + + for (const line of lines) { + if (line.match(/^##\s+Permissions/i)) { + inPermissionsSection = true; + continue; + } + if (inPermissionsSection && line.match(/^##\s+/)) { + break; + } + if (inPermissionsSection) { + permissionsContent += line + '\n'; + } + } + + return permissionsContent.trim() || 'See documentation for required permissions.'; + } +} diff --git a/cli/lib/input-grouper.js b/cli/lib/input-grouper.js new file mode 100644 index 0000000..cab6953 --- /dev/null +++ b/cli/lib/input-grouper.js @@ -0,0 +1,59 @@ +/** + * Groups workflow inputs by prefix pattern + * Dynamically discovers prefixes from input descriptions (e.g., "Docker Hub:", "GCP:", "ACR:") + * No hardcoded domain logic + */ +export class InputGrouper { + constructor(config) { + this.config = config; + } + + groupInputsByPrefix(inputs) { + const groups = { common: [] }; + const pattern = this.config.metadata.inputPrefixPattern; + + // First pass: discover unique prefixes + for (const [name, config] of Object.entries(inputs)) { + const desc = config.description || ''; + const match = desc.match(pattern); + + if (match) { + const prefix = match[1].trim(); + const groupKey = prefix.toLowerCase().replace(/\s+/g, '-'); + if (!groups[groupKey]) { + groups[groupKey] = []; + } + } + } + + // Second pass: assign inputs to groups + for (const [name, config] of Object.entries(inputs)) { + const desc = config.description || ''; + const match = desc.match(pattern); + + if (match) { + const prefix = match[1].trim(); + const groupKey = prefix.toLowerCase().replace(/\s+/g, '-'); + groups[groupKey].push({ name, prefix, ...config }); + } else { + groups.common.push({ name, ...config }); + } + } + + return groups; + } + + extractPrompt(description) { + const pattern = this.config.metadata.inputPrefixPattern; + let prompt = description; + + // Remove prefix if present + const match = prompt.match(pattern); + if (match) { + prompt = prompt.substring(match[0].length).trim(); + } + + // Remove parenthetical notes + return prompt.split('(')[0].trim(); + } +} diff --git a/cli/lib/template-loader.js b/cli/lib/template-loader.js new file mode 100644 index 0000000..c5c3996 --- /dev/null +++ b/cli/lib/template-loader.js @@ -0,0 +1,107 @@ +import { readFileSync, readdirSync, existsSync } from 'fs'; +import { join, basename } from 'path'; +import yaml from 'js-yaml'; +import chalk from 'chalk'; + +/** + * Loads and validates workflow templates + * Each template MUST have all 3 components: workflow.yml + docs.md + example.yml + */ +export class TemplateLoader { + constructor(repoRoot, config) { + this.repoRoot = repoRoot; + this.config = config; + } + + loadTemplates() { + const workflowsDir = join(this.repoRoot, this.config.components.workflow.dir); + const docsDir = join(this.repoRoot, this.config.components.docs.dir); + const examplesDir = join(this.repoRoot, this.config.components.example.dir); + + if (!existsSync(workflowsDir)) { + throw new Error(`Workflows directory not found: ${workflowsDir}`); + } + + const workflowFiles = readdirSync(workflowsDir) + .filter(file => file.endsWith(this.config.components.workflow.ext)); + + const templates = []; + + for (const file of workflowFiles) { + const templateId = basename(file, this.config.components.workflow.ext); + const workflowPath = join(workflowsDir, file); + const docsPath = join(docsDir, `${templateId}${this.config.components.docs.ext}`); + const examplesPath = join(examplesDir, `${templateId}${this.config.components.example.ext}`); + + // Validate: All 3 components must exist + if (!existsSync(docsPath)) { + console.warn(chalk.yellow(`โš  Skipping ${templateId}: missing ${this.config.components.docs.dir}/${templateId}${this.config.components.docs.ext}`)); + continue; + } + if (!existsSync(examplesPath)) { + console.warn(chalk.yellow(`โš  Skipping ${templateId}: missing ${this.config.components.example.dir}/${templateId}${this.config.components.example.ext}`)); + continue; + } + + try { + const workflowContent = readFileSync(workflowPath, 'utf8'); + const workflow = yaml.load(workflowContent); + + if (workflow.on && workflow.on.workflow_call) { + const name = workflow.name || templateId; + const description = this.extractDocsDescription(docsPath); + + templates.push({ + id: templateId, + name, + description, + workflowPath, + docsPath, + examplesPath + }); + } + } catch (error) { + console.warn(chalk.yellow(`โš  Could not parse ${file}: ${error.message}`)); + } + } + + if (templates.length === 0) { + throw new Error('No valid templates found. Each template requires: workflow.yml + docs.md + example.yml'); + } + + return templates; + } + + extractDocsDescription(docsPath) { + try { + const content = readFileSync(docsPath, 'utf8'); + const match = content.match(this.config.metadata.docsShortDescription); + if (match) { + return match[1].trim(); + } + + // Fallback: first non-header line + const lines = content.split('\n'); + let foundTitle = false; + for (const line of lines) { + if (line.startsWith('# ')) { + foundTitle = true; + continue; + } + if (foundTitle && line.trim() && !line.startsWith('#') && !line.startsWith('```') && !line.startsWith(' + +Build, scan, and publish Docker images to multiple registries with security scanning and SBOM generation. Reusable workflow for building, scanning, and publishing Docker images to multiple registries. @@ -10,6 +13,47 @@ Reusable workflow for building, scanning, and publishing Docker images to multip - Slack notifications - Automatic versioning (package.json or GitVersion) +## CLI Generator + +Generate workflow configuration interactively: + +```bash +npm install -g @udx/reusable-workflows +reusable-workflows +``` + +### Flow + +1. **Select template** โ†’ docker-ops +2. **Common inputs** โ†’ Image name, release branch, dockerfile path, build platforms +3. **Select registries** โ†’ Docker Hub, GCP, ACR (multi-select) +4. **Registry configuration** โ†’ Prompted for selected registries only +5. **Output** โ†’ `.github/workflows/docker-ops.yml` + `SETUP-docker-ops.md` + +### Registry Prompts + +**Docker Hub:** +- Username โ†’ `${{ vars.DOCKER_USERNAME }}` +- Organization +- Repository +- Secret: `DOCKER_TOKEN` + +**GCP Artifact Registry:** +- Region +- Project ID +- Repository name +- Workload Identity Provider +- Service account email +- Permissions: `id-token: write` + +**Azure Container Registry:** +- Registry (e.g., myregistry.azurecr.io) +- Repository +- Client ID +- Tenant ID +- Subscription ID +- Permissions: `id-token: write` + ## Quick Start ```yaml diff --git a/docs/wp-gh-release-ops.md b/docs/wp-gh-release-ops.md index cda2842..68051fc 100644 --- a/docs/wp-gh-release-ops.md +++ b/docs/wp-gh-release-ops.md @@ -1,4 +1,5 @@ # Publish WP Plugin Release on GitHub Workflow + Reusable workflow for generating and publishing WordPress plugin release on GitHub. @@ -8,6 +9,23 @@ Reusable workflow for generating and publishing WordPress plugin release on GitH - Parse changelog from changes.md depending on the version - SBOM generation +## CLI Generator + +### Flow + +1. **Select template** โ†’ wp-gh-release-ops +2. **WordPress setup** โ†’ Plugin version +3. **GitHub setup** โ†’ Release tag, pre-release options + +### Registry Prompts + +**WordPress:** +- Release version + +**GitHub:** +- Release tag +- Pre-release version + ## Quick Start ```yaml From c11e6caf407b11ba6f8305be46cbff6fff4ace56 Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 16:54:33 +0300 Subject: [PATCH 05/20] ci: reorganize internal workflows and add cli release dispatcher --- .github/workflows/internal.yml | 20 ++++++++++++++++++++ .github/workflows/internal/release-cli.yml | 17 +++++++++++++++++ .github/workflows/internal/test-cli.yml | 18 ++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 .github/workflows/internal.yml create mode 100644 .github/workflows/internal/release-cli.yml create mode 100644 .github/workflows/internal/test-cli.yml diff --git a/.github/workflows/internal.yml b/.github/workflows/internal.yml new file mode 100644 index 0000000..44fb839 --- /dev/null +++ b/.github/workflows/internal.yml @@ -0,0 +1,20 @@ +name: Repository Ops + +on: + push: + paths: + - 'cli/**' + - 'scripts/**' + - '.github/workflows/internal/**' + workflow_dispatch: + +jobs: + test: + name: Test CLI + uses: ./.github/workflows/internal/test-cli.yml + + release: + name: Release CLI + if: github.ref == 'refs/heads/master' + uses: ./.github/workflows/internal/release-cli.yml + secrets: inherit diff --git a/.github/workflows/internal/release-cli.yml b/.github/workflows/internal/release-cli.yml new file mode 100644 index 0000000..637375a --- /dev/null +++ b/.github/workflows/internal/release-cli.yml @@ -0,0 +1,17 @@ +name: Release CLI +on: + workflow_call: + secrets: + npm_token: + required: true + +jobs: + release: + uses: ./.github/workflows/npm-release-ops.yml + with: + registry_url: https://registry.npmjs.org/ + build_command: "" # CLI doesn't need build + test_command: "npm test" + working_directory: "cli" + secrets: + npm_token: ${{ secrets.npm_token }} diff --git a/.github/workflows/internal/test-cli.yml b/.github/workflows/internal/test-cli.yml new file mode 100644 index 0000000..5220e3d --- /dev/null +++ b/.github/workflows/internal/test-cli.yml @@ -0,0 +1,18 @@ +name: Test CLI +on: + workflow_call: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + working-directory: cli + - run: npm test + working-directory: cli + - name: Run UX Tests + run: ./scripts/test-cli-ux.sh From dfd3b2b81435e5e49afdb1dc4310cb6a5fcbb996 Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 16:55:58 +0300 Subject: [PATCH 06/20] chore: add .gitignore for node and temporary artifacts --- .gitignore | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1f0e46 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +.npm/ + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Project +cli/test/ux-temp/ +SETUP-*.md From 2c1613833e1645de5540c855a982b2e0ca5c2690 Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 17:46:56 +0300 Subject: [PATCH 07/20] refactor: flatten internal workflows and consolidate CLI ops --- .../release-cli.yml => _release-cli.yml} | 12 +- .github/workflows/internal.yml | 20 - .github/workflows/internal/test-cli.yml | 18 - .github/workflows/npm-release-ops.yml | 36 +- cli/config.js | 39 + cli/lib/template-loader.js | 3 +- cli/package-lock.json | 666 ++++++++++++++++++ 7 files changed, 741 insertions(+), 53 deletions(-) rename .github/workflows/{internal/release-cli.yml => _release-cli.yml} (68%) delete mode 100644 .github/workflows/internal.yml delete mode 100644 .github/workflows/internal/test-cli.yml create mode 100644 cli/config.js create mode 100644 cli/package-lock.json diff --git a/.github/workflows/internal/release-cli.yml b/.github/workflows/_release-cli.yml similarity index 68% rename from .github/workflows/internal/release-cli.yml rename to .github/workflows/_release-cli.yml index 637375a..8fc9f4c 100644 --- a/.github/workflows/internal/release-cli.yml +++ b/.github/workflows/_release-cli.yml @@ -1,9 +1,11 @@ -name: Release CLI +name: CLI Ops on: - workflow_call: - secrets: - npm_token: - required: true + push: + paths: + - 'cli/**' + - 'scripts/**' + - '.github/workflows/_release-cli.yml' + workflow_dispatch: jobs: release: diff --git a/.github/workflows/internal.yml b/.github/workflows/internal.yml deleted file mode 100644 index 44fb839..0000000 --- a/.github/workflows/internal.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Repository Ops - -on: - push: - paths: - - 'cli/**' - - 'scripts/**' - - '.github/workflows/internal/**' - workflow_dispatch: - -jobs: - test: - name: Test CLI - uses: ./.github/workflows/internal/test-cli.yml - - release: - name: Release CLI - if: github.ref == 'refs/heads/master' - uses: ./.github/workflows/internal/release-cli.yml - secrets: inherit diff --git a/.github/workflows/internal/test-cli.yml b/.github/workflows/internal/test-cli.yml deleted file mode 100644 index 5220e3d..0000000 --- a/.github/workflows/internal/test-cli.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Test CLI -on: - workflow_call: - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - run: npm ci - working-directory: cli - - run: npm test - working-directory: cli - - name: Run UX Tests - run: ./scripts/test-cli-ux.sh diff --git a/.github/workflows/npm-release-ops.yml b/.github/workflows/npm-release-ops.yml index e25334a..8399a9c 100644 --- a/.github/workflows/npm-release-ops.yml +++ b/.github/workflows/npm-release-ops.yml @@ -98,22 +98,18 @@ jobs: echo "has_webhook=false" >> $GITHUB_OUTPUT fi - release: - needs: [config] - if: needs.config.outputs.is_release_branch == 'true' + test: + needs: config runs-on: ubuntu-latest - permissions: - contents: write - id-token: write # Required for provenance + if: inputs.test_command != '' steps: - - name: Checkout code + - name: Checkout uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ inputs.node_version }} - registry-url: ${{ inputs.registry_url }} cache: 'npm' cache-dependency-path: ${{ inputs.working_directory }}/package-lock.json @@ -122,10 +118,32 @@ jobs: working-directory: ${{ inputs.working_directory }} - name: Run Tests - if: inputs.test_command != '' run: ${{ inputs.test_command }} working-directory: ${{ inputs.working_directory }} + release: + needs: [config, test] + # Only run release on the designated release branch + if: | + always() && + needs.config.outputs.is_release_branch == 'true' && + (needs.test.result == 'success' || needs.test.result == 'skipped') + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write # Required for provenance + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node_version }} + registry-url: ${{ inputs.registry_url }} + cache: 'npm' + cache-dependency-path: ${{ inputs.working_directory }}/package-lock.json + - name: Publish to NPM env: NODE_AUTH_TOKEN: ${{ secrets.npm_token }} diff --git a/cli/config.js b/cli/config.js new file mode 100644 index 0000000..79b97c2 --- /dev/null +++ b/cli/config.js @@ -0,0 +1,39 @@ +// ============================================================================ +// CLI CONFIGURATION - Adjust these as needed +// ============================================================================ + +export const CLI_CONFIG = { + // Template components - each template MUST have all 3 + components: { + workflow: { dir: '.github/workflows', ext: '.yml' }, + docs: { dir: 'docs', ext: '.md' }, + example: { dir: 'examples', ext: '.yml' } + }, + + // Metadata extraction patterns + metadata: { + // Extract short description from docs: + docsShortDescription: //, + + // Group inputs by prefix pattern: "Prefix: Description" + inputPrefixPattern: /^([A-Z][A-Za-z\s]+):\s/, + + // Variable/secret patterns in examples + variablePattern: /\$\{\{\s*vars\.(\w+)\s*\}\}/, + secretPattern: /\$\{\{\s*secrets\.(\w+)\s*\}\}/ + }, + + // UI configuration + ui: { + boxWidth: 50, + pageSize: 15, + icons: { + main: '๐Ÿš€', + config: '๐Ÿ“', + components: '๐Ÿ“ฆ', + group: '๐Ÿ”ง', + complete: 'โœ“', + steps: '๐Ÿ“‹' + } + } +}; diff --git a/cli/lib/template-loader.js b/cli/lib/template-loader.js index c5c3996..baf6e1c 100644 --- a/cli/lib/template-loader.js +++ b/cli/lib/template-loader.js @@ -23,7 +23,8 @@ export class TemplateLoader { } const workflowFiles = readdirSync(workflowsDir) - .filter(file => file.endsWith(this.config.components.workflow.ext)); + .filter(file => file.endsWith(this.config.components.workflow.ext)) + .filter(file => !file.startsWith('_')); const templates = []; diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 0000000..792b1e9 --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,666 @@ +{ + "name": "@udx/reusable-workflows", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@udx/reusable-workflows", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "inquirer": "^9.2.12", + "js-yaml": "^4.1.0", + "marked": "^11.1.1" + }, + "bin": { + "reusable-workflows": "index.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "9.3.8", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.3.8.tgz", + "integrity": "sha512-pFGGdaHrmRKMh4WoDDSowddgjT1Vkl90atobmTeSmcPGdYiwikch/m/Ef5wRaiamHejtw0cUUMMerzDUXCci2w==", + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.2", + "@inquirer/figures": "^1.0.3", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "1.0.0", + "ora": "^5.4.1", + "run-async": "^3.0.0", + "rxjs": "^7.8.1", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/marked": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-11.2.0.tgz", + "integrity": "sha512-HR0m3bvu0jAPYiIvLUUQtdg1g6D247//lvcekpHO1WMvbwDlwSkZAX9Lw4F4YHE1T0HaaNve0tuAWuV1UJ6vtw==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/run-async": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", + "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} From 32b5070482d0ee3e17bbbec3c574bd16f4948982 Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 17:51:41 +0300 Subject: [PATCH 08/20] docs: add development and release guides --- README.md | 18 ++++++++++++++++++ cli/README.md | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/README.md b/README.md index ab3a5cb..5211072 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,24 @@ Each template is structured as follows: - **Documentation** (`docs/`) - Setup guides, configuration options, troubleshooting - **Examples** (`examples/`) - Real-world usage patterns with variable/secret patterns +## Development + +### Adding a Template +To add a new reusable workflow: +1. Create your workflow in `.github/workflows/`. +2. Prefix internal repository workflows with `_` to hide them from the CLI generator. +3. Add a setup guide in `docs/` and an usage example in `examples/`. +4. Ensure your workflow inputs follow the standard registry-prefix naming convention in descriptions (e.g., `Docker Hub: Image Name`). + +### Internal Infrastructure +Infrastructure workflows (CLI release, tests, etc.) are marked with a `_` prefix and are excluded from the public CLI generator. The primary entry point is [`_release-cli.yml`](file:///Users/jonyfq/git/udx/reusable-workflows/.github/workflows/_release-cli.yml). + +## Releasing + +1. **Versioning**: The repository uses Semantic Versioning. +2. **Automated Release**: Pushing to `master` triggers the [`_release-cli.yml`](file:///Users/jonyfq/git/udx/reusable-workflows/.github/workflows/_release-cli.yml) workflow. +3. **Artifacts**: This automatically bumps the CLI version, creates a GitHub Release, and tags the repository. + ## License MIT License - see [LICENSE](https://github.com/udx/reusable-workflows/blob/master/LICENSE) diff --git a/cli/README.md b/cli/README.md index 62e11e7..2faefd4 100644 --- a/cli/README.md +++ b/cli/README.md @@ -39,3 +39,30 @@ The CLI parses a three-layer structure to provide a dynamic UI: 1. **Workflow (.yml)**: Defines inputs with registry-prefixed metadata. 2. **Docs (.md)**: Provides the generator flow and setup instructions. 3. **Example (.yml)**: Acts as the base template for the generated manifest. + +## Development + +### Local Testing +To test the CLI during development: +```bash +# Install dependencies +npm install + +# Run tests +npm test + +# Run UX test suite +../scripts/test-cli-ux.sh + +# Run locally in a target repo +node index.js +``` + +## Releasing +Publishing is automated via GitHub Actions: +- **Trigger**: Merge to `master`. +- **Workflow**: Uses [`npm-release-ops`](file:///Users/jonyfq/git/udx/reusable-workflows/.github/workflows/npm-release-ops.yml) with `working_directory: cli`. +- **Manual**: To publish from local (if authorized): + ```bash + ../scripts/npm-publish.sh # Run from /cli folder + ``` From eabccc7d757d6b71642fe655206e6a509998bfe2 Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 18:04:56 +0300 Subject: [PATCH 09/20] feat: add OIDC (keyless) publishing and environment support --- .github/workflows/_release-cli.yml | 1 + .github/workflows/npm-release-ops.yml | 9 +++++++-- cli/package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/_release-cli.yml b/.github/workflows/_release-cli.yml index 8fc9f4c..2bfd60b 100644 --- a/.github/workflows/_release-cli.yml +++ b/.github/workflows/_release-cli.yml @@ -15,5 +15,6 @@ jobs: build_command: "" # CLI doesn't need build test_command: "npm test" working_directory: "cli" + environment: "Master" secrets: npm_token: ${{ secrets.npm_token }} diff --git a/.github/workflows/npm-release-ops.yml b/.github/workflows/npm-release-ops.yml index 8399a9c..279e14d 100644 --- a/.github/workflows/npm-release-ops.yml +++ b/.github/workflows/npm-release-ops.yml @@ -48,10 +48,14 @@ on: required: false type: string default: "." + environment: + description: "GitHub: Environment to use for the release job (Optional)" + required: false + type: string secrets: npm_token: - description: "NPM: Token for publishing to the registry" - required: true + description: "NPM: Token for publishing (Optional if OIDC/Keyless is configured)" + required: false slack_webhook_url: description: "Slack: Webhook URL for notifications (Optional)" required: false @@ -129,6 +133,7 @@ jobs: needs.config.outputs.is_release_branch == 'true' && (needs.test.result == 'success' || needs.test.result == 'skipped') runs-on: ubuntu-latest + environment: ${{ inputs.environment }} permissions: contents: write id-token: write # Required for provenance diff --git a/cli/package.json b/cli/package.json index 3372f68..da76708 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@udx/reusable-workflows", - "version": "1.0.0", + "version": "1.1.0", "description": "CLI tool to generate GitHub Actions workflows from reusable templates", "type": "module", "main": "index.js", From cd97c47f50d687fc617c9488cfb86d479b1a9cb8 Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 18:12:28 +0300 Subject: [PATCH 10/20] docs(cli): add changes.md for version tracking --- cli/changes.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 cli/changes.md diff --git a/cli/changes.md b/cli/changes.md new file mode 100644 index 0000000..646d9ab --- /dev/null +++ b/cli/changes.md @@ -0,0 +1,15 @@ +# Changelog + +### 1.1.0 +- **Feat**: Added OIDC (Keyless) publishing support. +- **Feat**: Added GitHub Environment support (configured for `Master` environment). +- **Feat**: Added `--ref` and `-r` flags for pinning workflow versions. +- **Feat**: Added `--output` and `-o` flags for custom manifest destination. +- **Refactor**: Flattened repository structure with `_` prefix for internal workflows. +- **Chore**: Standardized metadata across all templates. + +### 1.0.0 +- Initial release of the Reusable Workflows CLI. +- Interactive workflow generation. +- Existing configuration auto-detection. +- Preview manifest before writing. From cb27801b11930d48913a3f1e71d5c9286b13891b Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 18:19:42 +0300 Subject: [PATCH 11/20] test(cli): refactor UX tests to use file-based fixtures --- .../.github/workflows/docker-ops.yml | 5 +++ .../.github/workflows/npm-release-ops.yml | 5 +++ .../.github/workflows/docker-ops.yml | 11 +---- scripts/test-cli-ux.sh | 42 +++++++++++-------- 4 files changed, 35 insertions(+), 28 deletions(-) create mode 100644 cli/test/cases/repo-multi/.github/workflows/docker-ops.yml create mode 100644 cli/test/cases/repo-multi/.github/workflows/npm-release-ops.yml rename cli/test/{ => cases/repo-single}/.github/workflows/docker-ops.yml (85%) diff --git a/cli/test/cases/repo-multi/.github/workflows/docker-ops.yml b/cli/test/cases/repo-multi/.github/workflows/docker-ops.yml new file mode 100644 index 0000000..2cd5749 --- /dev/null +++ b/cli/test/cases/repo-multi/.github/workflows/docker-ops.yml @@ -0,0 +1,5 @@ +jobs: + build: + uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master + with: + image_name: "multi-docker-image" diff --git a/cli/test/cases/repo-multi/.github/workflows/npm-release-ops.yml b/cli/test/cases/repo-multi/.github/workflows/npm-release-ops.yml new file mode 100644 index 0000000..598a5c1 --- /dev/null +++ b/cli/test/cases/repo-multi/.github/workflows/npm-release-ops.yml @@ -0,0 +1,5 @@ +jobs: + release: + uses: udx/reusable-workflows/.github/workflows/npm-release-ops.yml@master + with: + working_directory: "multi-npm-dir" diff --git a/cli/test/.github/workflows/docker-ops.yml b/cli/test/cases/repo-single/.github/workflows/docker-ops.yml similarity index 85% rename from cli/test/.github/workflows/docker-ops.yml rename to cli/test/cases/repo-single/.github/workflows/docker-ops.yml index 4fafb61..1db4c05 100644 --- a/cli/test/.github/workflows/docker-ops.yml +++ b/cli/test/cases/repo-single/.github/workflows/docker-ops.yml @@ -1,14 +1,5 @@ -name: Docker Release Example -"on": - push: - branches: - - main - workflow_dispatch: null jobs: - multi-registry: - permissions: - id-token: write - contents: write + build: uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master with: image_name: test diff --git a/scripts/test-cli-ux.sh b/scripts/test-cli-ux.sh index dc20afa..5a86f83 100755 --- a/scripts/test-cli-ux.sh +++ b/scripts/test-cli-ux.sh @@ -41,27 +41,16 @@ fi # Test Case 2: Existing Configuration Detection echo -e "\n${BLUE}[Test 2] Existing Configuration Detection${NC}" TEST2_DIR="$TEST_TEMP_DIR/existing-config" -mkdir -p "$TEST2_DIR/.github/workflows" +mkdir -p "$TEST2_DIR" +cp -r "$CLI_DIR/test/cases/repo-single/.github" "$TEST2_DIR/" cd "$TEST2_DIR" -# Create a mock workflow with existing values -cat > .github/workflows/npm-release-ops.yml < Date: Fri, 16 Jan 2026 18:20:51 +0300 Subject: [PATCH 12/20] docs(cli): remove recommended tag from npx usage --- cli/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/README.md b/cli/README.md index 2faefd4..37bf0da 100644 --- a/cli/README.md +++ b/cli/README.md @@ -6,7 +6,7 @@ A minimal-friction CLI to integrate and update GitHub Actions workflows based on Run the CLI in the root of your repository to select, configure, and update workflows. -### On-the-fly (Recommended) +### On-the-fly Always runs the latest version without installation: ```bash npx @udx/reusable-workflows [template-id] [options] From 77d6f2681bdd4b97464742514b1567079cccba07 Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 18:25:08 +0300 Subject: [PATCH 13/20] fix(cli): resolve global installation path and bundle templates --- .gitignore | 5 +++++ cli/index.js | 7 ++++++- cli/package.json | 12 +++++++++++- scripts/npm-publish.sh | 16 ++++++++++++++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index f1f0e46..1688015 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ Thumbs.db # Project cli/test/ux-temp/ SETUP-*.md + +# CLI bundled assets (copied during publish) +cli/.github/ +cli/docs/ +cli/examples/ diff --git a/cli/index.js b/cli/index.js index c547482..09ff367 100644 --- a/cli/index.js +++ b/cli/index.js @@ -22,7 +22,12 @@ const __dirname = dirname(__filename); class WorkflowGenerator { constructor() { this.config = CLI_CONFIG; - this.repoRoot = join(__dirname, '..'); + + // Support both local development and bundled npm package + // Local: __dirname is /cli, templates are in .. + // Bundled: templates are copied into /cli + const bundledWorkflows = join(__dirname, this.config.components.workflow.dir); + this.repoRoot = existsSync(bundledWorkflows) ? __dirname : join(__dirname, '..'); // Initialize modules this.templateLoader = new TemplateLoader(this.repoRoot, this.config); diff --git a/cli/package.json b/cli/package.json index da76708..0c3cd1f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,9 +1,19 @@ { "name": "@udx/reusable-workflows", - "version": "1.1.0", + "version": "1.1.1", "description": "CLI tool to generate GitHub Actions workflows from reusable templates", "type": "module", "main": "index.js", + "files": [ + "index.js", + "config.js", + "lib/", + "README.md", + "changes.md", + ".github/", + "docs/", + "examples/" + ], "bin": { "reusable-workflows": "./index.js" }, diff --git a/scripts/npm-publish.sh b/scripts/npm-publish.sh index 4934e5f..ac55e44 100755 --- a/scripts/npm-publish.sh +++ b/scripts/npm-publish.sh @@ -52,6 +52,16 @@ if [ -n "$BUILD_COMMAND" ]; then eval "$BUILD_COMMAND" fi +# Bundle templates if we are in the cli directory +if [ -d "../.github/workflows" ] && [ "$(basename "$(pwd)")" == "cli" ]; then + echo -e "๐Ÿ“ฆ Bundling templates into CLI package..." + cp -r ../.github ./ + cp -r ../docs ./ + cp -r ../examples ./ + # We want to remove the _ prefixed files from the bundled .github/workflows + find .github/workflows -name "_*" -delete +fi + # Publish to NPM echo -e "๐Ÿšข Publishing to NPM..." PUBLISH_ARGS=() @@ -72,4 +82,10 @@ fi # The orchestration layer (GH Action) should handle .npmrc setup for NODE_AUTH_TOKEN npm publish "${PUBLISH_ARGS[@]}" +# Cleanup bundled assets +if [ -d "../.github/workflows" ] && [ "$(basename "$(pwd)")" == "cli" ]; then + echo -e "๐Ÿงน Cleaning up bundled templates..." + rm -rf .github docs examples +fi + echo -e "${GREEN}โœ… Successfully published ${PACKAGE_NAME}@${PACKAGE_VERSION}${NC}" From 0b5c88949215e87cb7e5f0927f42ad9905f409e9 Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 18:27:58 +0300 Subject: [PATCH 14/20] fix(cli): add missing existsSync import and bump to 1.1.2 --- cli/index.js | 1 + cli/package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/index.js b/cli/index.js index 09ff367..2bc95e9 100644 --- a/cli/index.js +++ b/cli/index.js @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import inquirer from 'inquirer'; diff --git a/cli/package.json b/cli/package.json index 0c3cd1f..e6b0515 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@udx/reusable-workflows", - "version": "1.1.1", + "version": "1.1.2", "description": "CLI tool to generate GitHub Actions workflows from reusable templates", "type": "module", "main": "index.js", From ccbdf500034511e0dda9095b31bb0be016332a8f Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 19:12:18 +0300 Subject: [PATCH 15/20] feat(cli): finalize marker-free auto-presets and global standardization --- cli/config.js | 3 + cli/index.js | 72 +++++++++++++++------- cli/lib/file-generator.js | 58 ++++++++++-------- cli/lib/template-loader.js | 106 ++++++++++++++++++++++++++++++++- cli/package.json | 2 +- docs/docker-ops.md | 48 --------------- examples/docker-ops.yml | 44 +------------- examples/npm-release-ops.yml | 2 +- examples/wp-gh-release-ops.yml | 2 +- scripts/test-cli-ux.sh | 17 ++++++ 10 files changed, 212 insertions(+), 142 deletions(-) diff --git a/cli/config.js b/cli/config.js index 79b97c2..0a6bb80 100644 --- a/cli/config.js +++ b/cli/config.js @@ -15,6 +15,9 @@ export const CLI_CONFIG = { // Extract short description from docs: docsShortDescription: //, + // Extract presets from examples: ## PRESET: Name + presetPattern: /##\s*PRESET:\s*(.+)/, + // Group inputs by prefix pattern: "Prefix: Description" inputPrefixPattern: /^([A-Z][A-Za-z\s]+):\s/, diff --git a/cli/index.js b/cli/index.js index 2bc95e9..cfb7e50 100644 --- a/cli/index.js +++ b/cli/index.js @@ -57,6 +57,10 @@ class WorkflowGenerator { const refIdx = args.findIndex(arg => arg === '--ref' || arg === '-r'); const cliRef = refIdx !== -1 ? args[refIdx + 1] : null; + // Support preset selection: --preset or -p + const presetIdxCmd = args.findIndex(arg => arg === '--preset' || arg === '-p'); + const cliPresetName = presetIdxCmd !== -1 ? args[presetIdxCmd + 1] : null; + if (isHelp) { this.ui.printHelp(); return; @@ -83,35 +87,61 @@ class WorkflowGenerator { } const template = this.templates.find(t => t.id === templateId); - const inputs = this.templateLoader.parseWorkflowInputs(template); + template.inputs = this.templateLoader.parseWorkflowInputs(template); + template.secrets = this.templateLoader.parseWorkflowSecrets(template); + const inputs = template.inputs; - // Auto-detection phase + // Auto-detection phase (existing files) const detectedValues = this.configDetector.detectExistingConfig(templateId); if (Object.keys(detectedValues).length > 0) { console.log(chalk.green(`\nโœจ Auto-detected existing configuration from .github/workflows\n`)); } + + // Preset selection + let presetValues = {}; + if (template.presets && template.presets.length > 0) { + if (cliPresetName) { + const preset = template.presets.find(p => p.name.toLowerCase().includes(cliPresetName.toLowerCase())); + if (preset) { + presetValues = { ...preset.values, ...preset.secrets }; + console.log(chalk.green(`\nโœ… Applied preset: ${preset.name}\n`)); + } else { + console.warn(chalk.yellow(`\nโš  Preset "${cliPresetName}" not found. Falling back to manual configuration.\n`)); + } + } else if (Object.keys(detectedValues).length === 0 && !isNonInteractive) { + this.ui.printSectionHeader(this.config.ui.icons.group, 'Configuration Preset'); + const { presetIdx } = await inquirer.prompt([ + { + type: 'list', + name: 'presetIdx', + message: 'Choose a starting configuration:', + choices: [ + ...template.presets.map((p, i) => ({ name: p.name, value: i })), + { name: 'Custom (Manual Setup)', value: -1 } + ] + } + ]); + + if (presetIdx !== -1) { + const preset = template.presets[presetIdx]; + presetValues = { ...preset.values, ...preset.secrets }; + console.log(chalk.green(`\nโœ… Applied preset: ${preset.name}\n`)); + } + } + } + + // Merge detected and preset values + const mergedDefaults = { ...presetValues, ...detectedValues }; + // Configuration const groupedInputs = this.inputGrouper.groupInputsByPrefix(inputs); - // Version / Ref selection - let versionRef = cliRef; - if (!versionRef && !isNonInteractive) { - const { selectedRef } = await inquirer.prompt([ - { - type: 'input', - name: 'selectedRef', - message: 'Workflow reference (branch/tag):', - default: 'master' - } - ]); - versionRef = selectedRef; - } else if (!versionRef) { - versionRef = 'master'; - } + // Version / Ref selection (fixed to master / cliRef) + const versionRef = cliRef || 'master'; // Common inputs this.ui.printSectionHeader(icons.config, 'Configuration'); - const commonAnswers = await this.promptInputs(groupedInputs.common, detectedValues, isNonInteractive); + const commonAnswers = await this.promptInputs(groupedInputs.common, mergedDefaults, isNonInteractive); // Optional component groups const prefixGroups = Object.keys(groupedInputs).filter(key => key !== 'common'); @@ -130,7 +160,7 @@ class WorkflowGenerator { choices: prefixGroups.map(key => ({ name: groupedInputs[key][0].prefix, value: key, - checked: selectedGroups.includes(key) || groupedInputs[key].some(input => detectedValues[input.name] !== undefined) + checked: selectedGroups.includes(key) || groupedInputs[key].some(input => mergedDefaults[input.name] !== undefined) })) } ]); @@ -139,7 +169,7 @@ class WorkflowGenerator { } else { // In non-interactive mode, select groups that have detected inputs selectedGroups = prefixGroups.filter(key => - groupedInputs[key].some(input => detectedValues[input.name] !== undefined) + groupedInputs[key].some(input => mergedDefaults[input.name] !== undefined) ); } @@ -150,7 +180,7 @@ class WorkflowGenerator { if (!isNonInteractive) { this.ui.printSectionHeader(icons.group, `${groupName} Configuration`); } - const answers = await this.promptInputs(groupInputs, detectedValues, isNonInteractive); + const answers = await this.promptInputs(groupInputs, mergedDefaults, isNonInteractive); groupAnswers = { ...groupAnswers, ...answers }; } } diff --git a/cli/lib/file-generator.js b/cli/lib/file-generator.js index d9c77f0..d51f30c 100644 --- a/cli/lib/file-generator.js +++ b/cli/lib/file-generator.js @@ -47,39 +47,45 @@ export class FileGenerator { } generateWorkflowManifest(template, answers, ref = 'master') { - // Read example file as base - const exampleContent = readFileSync(template.examplesPath, 'utf8'); - const example = yaml.load(exampleContent); - - // Find the job that uses the reusable workflow - let workflowJob = null; - for (const [jobName, jobConfig] of Object.entries(example.jobs || {})) { - if (jobConfig.uses) { - workflowJob = jobConfig; - break; - } - } + // Determine triggers (use template default or default to main push) + const triggers = template.defaultTriggers || { push: { branches: ['main'] }, workflow_dispatch: null }; - // Update the 'uses' reference - if (workflowJob.uses) { - const base = workflowJob.uses.split('@')[0]; - workflowJob.uses = `${base}@${ref}`; - } + // Distinguish between inputs and secrets + const workflowSecrets = template.secrets || {}; + const withValues = {}; + const secretValues = {}; - // Update the 'with' section with user answers and detected values - if (!workflowJob.with) { - workflowJob.with = {}; - } - - // Merge answers into the existing 'with' section for (const [key, value] of Object.entries(answers)) { if (value !== undefined) { - workflowJob.with[key] = value; + if (workflowSecrets[key]) { + secretValues[key] = value; + } else { + withValues[key] = value; + } } } - // Generate the manifest - return yaml.dump(example, { + const manifest = { + name: template.name, + on: triggers, + permissions: { + contents: 'write', + 'id-token': 'write' + }, + jobs: { + release: { + uses: `udx/reusable-workflows/.github/workflows/${template.id}.yml@${ref}`, + with: withValues, + secrets: Object.keys(secretValues).length > 0 ? secretValues : undefined + } + } + }; + + // Remove empty sections + if (Object.keys(manifest.jobs.release.with).length === 0) delete manifest.jobs.release.with; + if (!manifest.jobs.release.secrets) delete manifest.jobs.release.secrets; + + return yaml.dump(manifest, { lineWidth: -1, noRefs: true, quotingType: '"', diff --git a/cli/lib/template-loader.js b/cli/lib/template-loader.js index baf6e1c..b475bcb 100644 --- a/cli/lib/template-loader.js +++ b/cli/lib/template-loader.js @@ -58,7 +58,9 @@ export class TemplateLoader { description, workflowPath, docsPath, - examplesPath + examplesPath, + presets: this.extractPresets(examplesPath), + defaultTriggers: this.extractExampleTriggers(examplesPath) }); } } catch (error) { @@ -105,4 +107,106 @@ export class TemplateLoader { const workflow = yaml.load(content); return workflow.on.workflow_call.inputs || {}; } + + parseWorkflowSecrets(template) { + const content = readFileSync(template.workflowPath, 'utf8'); + const workflow = yaml.load(content); + return workflow.on.workflow_call.secrets || {}; + } + + extractPresets(examplesPath) { + const presets = []; + try { + const templateId = examplesPath.split('/').pop().replace('.yml', ''); + const content = readFileSync(examplesPath, 'utf8'); + + // 1. Create a "normalized" version of the file where comments are stripped but indentation is preserved + const lines = content.split('\n'); + const normalizedLines = lines.map(line => { + // Strip leading '#' but keep the indentation it was at + return line.replace(/^(\s*)#\s?/, '$1'); + }); + + // 2. Scan normalized lines for template usage + for (let i = 0; i < normalizedLines.length; i++) { + const line = normalizedLines[i]; + if (line.includes(`${templateId}.yml@`)) { + // Backtrack to find job ID + let jobId = null; + let jobStartIndex = -1; + let jobIndent = -1; + + for (let j = i - 1; j >= 0; j--) { + const prevLine = normalizedLines[j]; + const match = prevLine.match(/^(\s*)([\w-]+):\s*$/); + if (match) { + jobId = match[2]; + jobIndent = match[1].length; + jobStartIndex = j; + break; + } + // Stop if we hit unindented prose + if (prevLine.trim() !== '' && !prevLine.match(/^\s/)) break; + } + + if (jobId) { + // Collect block lines + const blockLines = [normalizedLines[jobStartIndex]]; + for (let k = jobStartIndex + 1; k < normalizedLines.length; k++) { + const nextLine = normalizedLines[k]; + if (nextLine.trim() === '') { + blockLines.push(''); + continue; + } + const nextIndent = nextLine.match(/^(\s*)/)[1].length; + if (nextIndent > jobIndent) { + blockLines.push(nextLine); + } else { + break; + } + } + + // Parse block + try { + const blockContent = blockLines.join('\n'); + const data = yaml.load(blockContent); + const job = data[jobId]; + if (job && job.uses && job.uses.includes(templateId)) { + presets.push({ + name: this.formatPresetName(jobId), + values: job.with || {}, + secrets: job.secrets || {} + }); + } + } catch (e) { /* skip invalid blocks */ } + } + } + } + } catch (e) { /* ignore file errors */ } + + // Deduplicate by name + return presets.filter((p, index, self) => + index === self.findIndex((t) => t.name === p.name) + ); + } + + formatPresetName(jobId) { + return jobId + .split(/[-_]/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + extractExampleTriggers(examplesPath) { + try { + const content = readFileSync(examplesPath, 'utf8'); + const data = yaml.load(content); + if (data.on) { + return data.on; + } + } catch (e) { + // Ignore + } + return { push: { branches: ['main'] }, workflow_dispatch: null }; + } } diff --git a/cli/package.json b/cli/package.json index e6b0515..212e110 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@udx/reusable-workflows", - "version": "1.1.2", + "version": "1.1.3", "description": "CLI tool to generate GitHub Actions workflows from reusable templates", "type": "module", "main": "index.js", diff --git a/docs/docker-ops.md b/docs/docker-ops.md index 6eac287..586b746 100644 --- a/docs/docker-ops.md +++ b/docs/docker-ops.md @@ -435,54 +435,6 @@ with: - Only works on release branches - Requires `security-events: write` permission (auto-granted) -## AI Implementation Prompt - -Use this template with AI coding assistants to generate a complete workflow configuration: - -``` -Act as a DevSecOps Engineer. Use the udx/reusable-workflows/.github/workflows/docker-ops.yml@master -template to create a Docker release workflow for my containerized application. - -Requirements: -1. Check the Dependency & Permission Matrix in docs/docker-ops.md -2. Include all mandatory inputs for [Docker Hub/GCP/ACR] based on my target registry -3. Use build_args with {{version}} placeholder for version injection -4. Ensure proper OIDC permissions for cloud registries (id-token: write) -5. Configure for release branch: [main/master/latest] - -Project context: -- Docker image name: [your-image-name] -- Target registries: [Docker Hub/GCP Artifact Registry/Azure Container Registry] -- Dockerfile location: [./Dockerfile or custom path] -- Build arguments: [any ARG values needed in Dockerfile] -- Multi-platform builds: [linux/amd64, linux/arm64, or custom] - -Verify that all mandatory inputs from the matrix are included and properly configured. -``` - -**Example usage:** - -``` -Act as a DevSecOps Engineer. Use the udx/reusable-workflows/.github/workflows/docker-ops.yml@master -template to create a Docker release workflow for my containerized application. - -Requirements: -1. Check the Dependency & Permission Matrix in docs/docker-ops.md -2. Include all mandatory inputs for GCP based on my target registry -3. Use build_args with {{version}} placeholder for version injection -4. Ensure proper OIDC permissions for cloud registries (id-token: write) -5. Configure for release branch: main - -Project context: -- Docker image name: my-api-service -- Target registries: GCP Artifact Registry -- Dockerfile location: ./Dockerfile -- Build arguments: APP_VERSION={{version}}, BUILD_DATE={{branch}} -- Multi-platform builds: linux/amd64,linux/arm64 - -Verify that all mandatory inputs from the matrix are included and properly configured. -``` - ## Best Practices 1. Use semantic versioning in `package.json` or GitVersion config diff --git a/examples/docker-ops.yml b/examples/docker-ops.yml index f0df54c..6951a53 100644 --- a/examples/docker-ops.yml +++ b/examples/docker-ops.yml @@ -8,11 +8,9 @@ on: jobs: # =========================================================================== # Example 1: Minimal - GitHub Release Only - # =========================================================================== # USE CASE: Build and scan Docker image, create GitHub release with SBOM # REQUIRED: Only image_name # PUBLISHES TO: GitHub Releases only (no external registries) - # AI NOTE: Use this when you only need CI/CD without registry publishing # minimal: # uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master @@ -21,11 +19,9 @@ jobs: # =========================================================================== # Example 2: Docker Hub Publishing - # =========================================================================== # USE CASE: Publish to Docker Hub with version and latest tags # REQUIRED: image_name, docker_login, docker_org, docker_repo, docker_token secret # PUBLISHES TO: Docker Hub + GitHub Releases - # AI NOTE: Check Dependency Matrix in docs/docker-ops.md for all mandatory inputs # docker-hub: # uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master @@ -39,12 +35,9 @@ jobs: # =========================================================================== # Example 3: Multi-Registry (Docker Hub + GCP + ACR + Slack) - # =========================================================================== # USE CASE: Enterprise deployment to multiple cloud registries with notifications # REQUIRED: All inputs for each target registry (see Dependency Matrix in docs/docker-ops.md) # PUBLISHES TO: Docker Hub + GCP Artifact Registry + Azure Container Registry + GitHub Releases - # SECURITY: Requires id-token: write permission for GCP and ACR OIDC authentication - # AI NOTE: All IDs below are fake examples - replace with your actual values multi-registry: permissions: @@ -89,41 +82,6 @@ jobs: secrets: docker_token: ${{ secrets.DOCKER_TOKEN }} slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} -# =========================================================================== -# Quick Reference -# =========================================================================== -# -# AI-Friendly Configuration: -# - See Dependency & Permission Matrix in docs/docker-ops.md -# - Matrix shows all mandatory inputs/secrets/roles per registry type -# - Use AI Implementation Prompt template in docs for workflow generation -# -# Versioning: -# - Auto-detected from package.json or GitVersion (ci/git-version.yml) -# - No manual version input needed -# -# Placeholders in build_args: -# - {{version}} = release version (e.g., 1.2.3) -# - {{branch}} = current branch name (e.g., main, feature/xyz) -# - Example: build_args: APP_VERSION={{version}},BRANCH={{branch}} -# -# Authentication Setup: -# - DOCKER_TOKEN: Personal access token from https://hub.docker.com/settings/security -# - GCP: Workload Identity Federation (keyless OIDC) -# * Requires: id-token: write permission -# * Service account needs: roles/artifactregistry.writer -# * Setup guide: docs/docker-ops.md "GCP Workload Identity Federation Setup" -# - Azure: OIDC with federated credentials (keyless) -# * Requires: id-token: write permission -# * Service principal needs: AcrPush role -# * Security options: Strict (single branch) or Flexible (wildcard) -# * Setup guide: docs/docker-ops.md "Azure OIDC Setup" -# - SLACK_WEBHOOK_URL: Optional, from https://api.slack.com/messaging/webhooks -# -# Changelog: -# - Create changes.md with version entries (### 1.2.0) -# - Or auto-generated from git commits since last tag -# + # Full Documentation: # - See docs/docker-ops.md for complete setup guides, troubleshooting, and best practices -# - AI Implementation Prompt template available for generating workflow configurations diff --git a/examples/npm-release-ops.yml b/examples/npm-release-ops.yml index cf843d0..00f031a 100644 --- a/examples/npm-release-ops.yml +++ b/examples/npm-release-ops.yml @@ -6,7 +6,7 @@ on: - latest jobs: - release: + standard-release: permissions: contents: write id-token: write diff --git a/examples/wp-gh-release-ops.yml b/examples/wp-gh-release-ops.yml index 26f0464..b9c1977 100644 --- a/examples/wp-gh-release-ops.yml +++ b/examples/wp-gh-release-ops.yml @@ -17,7 +17,7 @@ permissions: contents: write jobs: - release: + plugin-release: uses: udx/reusable-workflows/.github/workflows/wp-gh-release-ops.yml@master with: tag: ${{ github.event.inputs.tag }} diff --git a/scripts/test-cli-ux.sh b/scripts/test-cli-ux.sh index 5a86f83..e03f85b 100755 --- a/scripts/test-cli-ux.sh +++ b/scripts/test-cli-ux.sh @@ -108,6 +108,23 @@ else exit 1 fi +# Test Case 6: Configuration Presets +echo -e "\n${BLUE}[Test 6] Configuration Presets${NC}" +TEST6_DIR="$TEST_TEMP_DIR/presets" +mkdir -p "$TEST6_DIR" +cd "$TEST6_DIR" + +# Run CLI with --preset +node "$CLI_DIR/index.js" docker-ops --non-interactive --preset "Minimal" + +if grep -q "uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master" ".github/workflows/docker-ops.yml" && grep -q "image_name: \"\?my-app\"\?" ".github/workflows/docker-ops.yml"; then + echo -e "${GREEN}โœ… Successfully generated manifest from preset${NC}" +else + echo -e "โŒ Preset-based generation failed (Check .github/workflows/docker-ops.yml content)" + cat .github/workflows/docker-ops.yml + exit 1 +fi + echo -e "\n${GREEN}โœจ All CLI UX Tests Passed!${NC}" # Cleanup From f7e97dc3ea5ec8e2faea52c857f50965e28b4435 Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 19:38:59 +0300 Subject: [PATCH 16/20] test: update ux tests for new docker presets --- .github/workflows/docker-ops.yml | 4 +- cli/index.js | 10 ++- cli/lib/input-grouper.js | 4 +- examples/docker-ops.yml | 128 ++++++++++++++++--------------- scripts/test-cli-ux.sh | 4 +- 5 files changed, 80 insertions(+), 70 deletions(-) diff --git a/.github/workflows/docker-ops.yml b/.github/workflows/docker-ops.yml index 1da4c14..213cd9c 100644 --- a/.github/workflows/docker-ops.yml +++ b/.github/workflows/docker-ops.yml @@ -59,12 +59,12 @@ on: default: "./Dockerfile" type: string build_platforms: - description: "Platforms to build for (comma-separated, e.g., linux/amd64,linux/arm64)" + description: "Platforms to build for (e.g., linux/amd64,linux/arm64). Note: multi-platform is primarily used for Docker Hub." required: false default: "linux/amd64,linux/arm64" type: string version_config_path: - description: "Path to GitVersion config file (only used if package.json is absent)" + description: "Path to GitVersion config file (used if package.json NOT defined)" required: false default: "ci/git-version.yml" type: string diff --git a/cli/index.js b/cli/index.js index cfb7e50..bbf7649 100644 --- a/cli/index.js +++ b/cli/index.js @@ -177,6 +177,10 @@ class WorkflowGenerator { const groupInputs = groupedInputs[groupKey]; const groupName = groupInputs[0].prefix; + // Skip group if all inputs are already provided (e.g. by preset) + const missingInputs = groupInputs.filter(input => mergedDefaults[input.name] === undefined); + if (missingInputs.length === 0) continue; + if (!isNonInteractive) { this.ui.printSectionHeader(icons.group, `${groupName} Configuration`); } @@ -269,10 +273,14 @@ class WorkflowGenerator { const questions = inputs .filter(input => detectedValues[input.name] === undefined) .map(input => { + const message = input.name === 'image_name' + ? 'Enter Docker image name (e.g. my-app) - used for all registry tags:' + : this.inputGrouper.extractPrompt(input.description); + const question = { type: 'input', name: input.name, - message: this.inputGrouper.extractPrompt(input.description), + message: message, default: input.default || '' }; diff --git a/cli/lib/input-grouper.js b/cli/lib/input-grouper.js index cab6953..a28f171 100644 --- a/cli/lib/input-grouper.js +++ b/cli/lib/input-grouper.js @@ -53,7 +53,7 @@ export class InputGrouper { prompt = prompt.substring(match[0].length).trim(); } - // Remove parenthetical notes - return prompt.split('(')[0].trim(); + // Return the rest of the description as the prompt + return prompt.trim(); } } diff --git a/examples/docker-ops.yml b/examples/docker-ops.yml index 6951a53..d840ed6 100644 --- a/examples/docker-ops.yml +++ b/examples/docker-ops.yml @@ -7,78 +7,80 @@ on: jobs: # =========================================================================== - # Example 1: Minimal - GitHub Release Only - # USE CASE: Build and scan Docker image, create GitHub release with SBOM - # REQUIRED: Only image_name - # PUBLISHES TO: GitHub Releases only (no external registries) - - # minimal: - # uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master - # with: - # image_name: my-app - + # Docker Hub: docker-hub + # USE CASE: Build, scan, and publish to Docker Hub + GitHub Releases # =========================================================================== - # Example 2: Docker Hub Publishing - # USE CASE: Publish to Docker Hub with version and latest tags - # REQUIRED: image_name, docker_login, docker_org, docker_repo, docker_token secret - # PUBLISHES TO: Docker Hub + GitHub Releases - - # docker-hub: - # uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master - # with: - # image_name: my-app - # docker_login: myusername - # docker_org: myorg - # docker_repo: my-app - # secrets: - # docker_token: ${{ secrets.DOCKER_TOKEN }} + docker-hub: + uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master + with: + image_name: ${{ vars.IMAGE_NAME }} + docker_login: ${{ vars.DOCKER_USERNAME }} + docker_org: ${{ vars.DOCKER_ORG }} + docker_repo: ${{ vars.DOCKER_REPO }} + secrets: + docker_token: ${{ secrets.DOCKER_TOKEN }} # =========================================================================== - # Example 3: Multi-Registry (Docker Hub + GCP + ACR + Slack) - # USE CASE: Enterprise deployment to multiple cloud registries with notifications - # REQUIRED: All inputs for each target registry (see Dependency Matrix in docs/docker-ops.md) - # PUBLISHES TO: Docker Hub + GCP Artifact Registry + Azure Container Registry + GitHub Releases + # GCP: gcr + # USE CASE: Publish to GCP Artifact Registry using OIDC (Keyless) + # =========================================================================== + gcr: + permissions: + id-token: write + contents: write + uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master + with: + image_name: ${{ vars.IMAGE_NAME }} + gcp_region: ${{ vars.GCP_REGION }} + gcp_project_id: ${{ vars.GCP_PROJECT_ID }} + gcp_repo: ${{ vars.GCP_REPO }} + gcp_workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + gcp_service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} - multi-registry: + # =========================================================================== + # Azure: acr + # USE CASE: Publish to Azure Container Registry using OIDC (Keyless) + # =========================================================================== + acr: permissions: - id-token: write # Required for GCP and Azure OIDC authentication - contents: write # Required for GitHub releases + id-token: write + contents: write uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master with: - image_name: my-app - release_branch: main + image_name: ${{ vars.IMAGE_NAME }} + acr_registry: ${{ vars.ACR_REGISTRY }} + acr_repository: ${{ vars.ACR_REPOSITORY }} + azure_client_id: ${{ vars.AZURE_CLIENT_ID }} + azure_tenant_id: ${{ vars.AZURE_TENANT_ID }} + azure_subscription_id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - # Docker Hub Configuration - # Required: docker_login, docker_org, docker_repo, docker_token secret + # =========================================================================== + # All: all + # USE CASE: Publish to Docker Hub, GCP, and Azure simultaneously + # =========================================================================== + all: + permissions: + id-token: write + contents: write + uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master + with: + image_name: ${{ vars.IMAGE_NAME }} + # Docker Hub docker_login: ${{ vars.DOCKER_USERNAME }} - docker_org: myorg - docker_repo: my-app - - # GCP Artifact Registry (Workload Identity Federation - Keyless Auth) - # Required: All 5 inputs below - # Setup: See docs/docker-ops.md "GCP Workload Identity Federation Setup" - gcp_region: us-central1 - gcp_project_id: my-gcp-project - gcp_repo: docker-images - gcp_workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/github-provider - gcp_service_account: github-actions@my-gcp-project.iam.gserviceaccount.com - - # Azure Container Registry (OIDC - Keyless Auth) - # Required: All 5 inputs below - # Setup: See docs/docker-ops.md "Azure OIDC Setup" - # Note: IDs below are fake examples - replace with your actual Azure values - acr_registry: myregistry.azurecr.io - acr_repository: my-app - azure_client_id: 12345678-90ab-cdef-1234-567890abcdef - azure_tenant_id: 87654321-fedc-ba09-8765-4321fedcba09 - azure_subscription_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - - # Optional: Custom build settings - dockerfile_path: ./Dockerfile - build_platforms: linux/amd64,linux/arm64 - # Build args support placeholders: {{version}} = release version, {{branch}} = branch name - build_args: APP_VERSION={{version}},BUILD_DATE={{branch}},ENV=production - + docker_org: ${{ vars.DOCKER_ORG }} + docker_repo: ${{ vars.DOCKER_REPO }} + # GCP + gcp_region: ${{ vars.GCP_REGION }} + gcp_project_id: ${{ vars.GCP_PROJECT_ID }} + gcp_repo: ${{ vars.GCP_REPO }} + gcp_workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + gcp_service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} + # Azure + acr_registry: ${{ vars.ACR_REGISTRY }} + acr_repository: ${{ vars.ACR_REPOSITORY }} + azure_client_id: ${{ vars.AZURE_CLIENT_ID }} + azure_tenant_id: ${{ vars.AZURE_TENANT_ID }} + azure_subscription_id: ${{ vars.AZURE_SUBSCRIPTION_ID }} secrets: docker_token: ${{ secrets.DOCKER_TOKEN }} slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/scripts/test-cli-ux.sh b/scripts/test-cli-ux.sh index e03f85b..5047dc8 100755 --- a/scripts/test-cli-ux.sh +++ b/scripts/test-cli-ux.sh @@ -115,9 +115,9 @@ mkdir -p "$TEST6_DIR" cd "$TEST6_DIR" # Run CLI with --preset -node "$CLI_DIR/index.js" docker-ops --non-interactive --preset "Minimal" +node "$CLI_DIR/index.js" docker-ops --non-interactive --preset "Docker Hub" -if grep -q "uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master" ".github/workflows/docker-ops.yml" && grep -q "image_name: \"\?my-app\"\?" ".github/workflows/docker-ops.yml"; then +if grep -q "uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master" ".github/workflows/docker-ops.yml" && grep -q "image_name: \"\?\${{ vars.IMAGE_NAME }}\"\?" ".github/workflows/docker-ops.yml"; then echo -e "${GREEN}โœ… Successfully generated manifest from preset${NC}" else echo -e "โŒ Preset-based generation failed (Check .github/workflows/docker-ops.yml content)" From d6813da98a22502722214852d456abd840b350d8 Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 19:39:19 +0300 Subject: [PATCH 17/20] feat(cli): refined docker presets and prompt flow v1.1.4 --- cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/package.json b/cli/package.json index 212e110..a2ece10 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@udx/reusable-workflows", - "version": "1.1.3", + "version": "1.1.4", "description": "CLI tool to generate GitHub Actions workflows from reusable templates", "type": "module", "main": "index.js", From 1894624ed1a75b3ea47e7b9898b8264c3b2a639f Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 19:57:31 +0300 Subject: [PATCH 18/20] feat(cli): generalize ux tests and finalize v1.1.5 --- .github/workflows/wp-gh-release-ops.yml | 1 + cli/index.js | 13 +- cli/lib/file-generator.js | 10 +- cli/lib/template-loader.js | 37 +++-- cli/package.json | 2 +- cli/test-gcr.yml | 27 ++++ cli/test/run-ux.js | 149 ++++++++++++++++++ cli/test/ux-cases/docker-ops/acr.json | 6 + cli/test/ux-cases/docker-ops/docker-hub.json | 6 + .../ux-cases/docker-ops/existing-config.json | 9 ++ cli/test/ux-cases/docker-ops/gcr.json | 6 + .../ux-cases/docker-ops/multi-registry.json | 6 + cli/test/ux-cases/npm-release-ops/fresh.json | 6 + .../npm-release-ops/version-pinning.json | 8 + .../ux-cases/wp-gh-release-ops/fresh.json | 6 + .../acr/.github/workflows/docker-ops.yml | 27 ++++ .../.github/workflows/docker-ops.yml | 27 ++++ .../.github/workflows/docker-ops.yml | 35 ++++ .../gcr/.github/workflows/docker-ops.yml | 27 ++++ .../.github/workflows/docker-ops.yml | 38 +++++ .../.github/workflows/npm-release-ops.yml | 14 ++ .../.github/workflows/npm-release-ops.yml | 14 ++ .../.github/workflows/wp-gh-release-ops.yml | 22 +++ examples/docker-ops.yml | 2 +- scripts/test-cli-ux.sh | 130 +-------------- 25 files changed, 487 insertions(+), 141 deletions(-) create mode 100644 cli/test-gcr.yml create mode 100644 cli/test/run-ux.js create mode 100644 cli/test/ux-cases/docker-ops/acr.json create mode 100644 cli/test/ux-cases/docker-ops/docker-hub.json create mode 100644 cli/test/ux-cases/docker-ops/existing-config.json create mode 100644 cli/test/ux-cases/docker-ops/gcr.json create mode 100644 cli/test/ux-cases/docker-ops/multi-registry.json create mode 100644 cli/test/ux-cases/npm-release-ops/fresh.json create mode 100644 cli/test/ux-cases/npm-release-ops/version-pinning.json create mode 100644 cli/test/ux-cases/wp-gh-release-ops/fresh.json create mode 100644 cli/test/ux-temp-runner/docker-ops/acr/.github/workflows/docker-ops.yml create mode 100644 cli/test/ux-temp-runner/docker-ops/docker-hub/.github/workflows/docker-ops.yml create mode 100644 cli/test/ux-temp-runner/docker-ops/existing-config/.github/workflows/docker-ops.yml create mode 100644 cli/test/ux-temp-runner/docker-ops/gcr/.github/workflows/docker-ops.yml create mode 100644 cli/test/ux-temp-runner/docker-ops/multi-registry/.github/workflows/docker-ops.yml create mode 100644 cli/test/ux-temp-runner/npm-release-ops/fresh/.github/workflows/npm-release-ops.yml create mode 100644 cli/test/ux-temp-runner/npm-release-ops/version-pinning/.github/workflows/npm-release-ops.yml create mode 100644 cli/test/ux-temp-runner/wp-gh-release-ops/fresh/.github/workflows/wp-gh-release-ops.yml diff --git a/.github/workflows/wp-gh-release-ops.yml b/.github/workflows/wp-gh-release-ops.yml index ce42241..58fc9e3 100644 --- a/.github/workflows/wp-gh-release-ops.yml +++ b/.github/workflows/wp-gh-release-ops.yml @@ -7,6 +7,7 @@ on: tag: description: 'GitHub: Release tag (e.g. 1.2.3a)' required: true + default: '1.2.3' type: string version: description: 'WordPress: Release version (e.g. 1.2.3), default: latest' diff --git a/cli/index.js b/cli/index.js index bbf7649..06d1374 100644 --- a/cli/index.js +++ b/cli/index.js @@ -101,7 +101,9 @@ class WorkflowGenerator { let presetValues = {}; if (template.presets && template.presets.length > 0) { if (cliPresetName) { - const preset = template.presets.find(p => p.name.toLowerCase().includes(cliPresetName.toLowerCase())); + const normalize = (s) => s.toLowerCase().replace(/[-_]/g, ' ').trim(); + const search = normalize(cliPresetName); + const preset = template.presets.find(p => normalize(p.id).includes(search) || normalize(p.name).includes(search)); if (preset) { presetValues = { ...preset.values, ...preset.secrets }; console.log(chalk.green(`\nโœ… Applied preset: ${preset.name}\n`)); @@ -167,9 +169,13 @@ class WorkflowGenerator { selectedGroups = result.selectedGroups; } else { - // In non-interactive mode, select groups that have detected inputs + // In non-interactive mode, select groups that have: + // 1. Detected values + // 2. OR required inputs (to ensure they get defaults/fallbacks) selectedGroups = prefixGroups.filter(key => - groupedInputs[key].some(input => mergedDefaults[input.name] !== undefined) + groupedInputs[key].some(input => + mergedDefaults[input.name] !== undefined || input.required + ) ); } @@ -190,6 +196,7 @@ class WorkflowGenerator { } const allAnswers = { ...commonAnswers, ...groupAnswers }; + // console.log('DEBUG allAnswers:', JSON.stringify(allAnswers, null, 2)); // Preview and Confirmation if (!isNonInteractive) { diff --git a/cli/lib/file-generator.js b/cli/lib/file-generator.js index d51f30c..2fc279e 100644 --- a/cli/lib/file-generator.js +++ b/cli/lib/file-generator.js @@ -47,8 +47,14 @@ export class FileGenerator { } generateWorkflowManifest(template, answers, ref = 'master') { - // Determine triggers (use template default or default to main push) - const triggers = template.defaultTriggers || { push: { branches: ['main'] }, workflow_dispatch: null }; + // Determine triggers (exclude workflow_call) + let triggers = { ...template.defaultTriggers }; + delete triggers.workflow_call; + + // Fallback to defaults if no valid triggers left + if (Object.keys(triggers).length === 0) { + triggers = { push: { branches: ['main'] }, workflow_dispatch: null }; + } // Distinguish between inputs and secrets const workflowSecrets = template.secrets || {}; diff --git a/cli/lib/template-loader.js b/cli/lib/template-loader.js index b475bcb..7de00e9 100644 --- a/cli/lib/template-loader.js +++ b/cli/lib/template-loader.js @@ -120,33 +120,42 @@ export class TemplateLoader { const templateId = examplesPath.split('/').pop().replace('.yml', ''); const content = readFileSync(examplesPath, 'utf8'); - // 1. Create a "normalized" version of the file where comments are stripped but indentation is preserved + // 1. Create a "normalized" version of the file where structural comments are stripped + // but purely descriptive comments are kept to maintain YAML validity. const lines = content.split('\n'); const normalizedLines = lines.map(line => { - // Strip leading '#' but keep the indentation it was at - return line.replace(/^(\s*)#\s?/, '$1'); + // Only strip the leading '#' if it's followed by something that looks like YAML structure + // (key: value, - list item, or block start) + if (line.match(/^(\s*)#\s*([\w-]+):/) || line.match(/^(\s*)#\s*- /)) { + return line.replace(/^(\s*)#\s?/, '$1'); + } + return line; }); // 2. Scan normalized lines for template usage for (let i = 0; i < normalizedLines.length; i++) { const line = normalizedLines[i]; if (line.includes(`${templateId}.yml@`)) { - // Backtrack to find job ID + // Backtrack to find job ID (nearest line with LESS indentation) let jobId = null; let jobStartIndex = -1; let jobIndent = -1; + const currentIndent = line.match(/^(\s*)/)[0].length; for (let j = i - 1; j >= 0; j--) { const prevLine = normalizedLines[j]; const match = prevLine.match(/^(\s*)([\w-]+):\s*$/); if (match) { - jobId = match[2]; - jobIndent = match[1].length; - jobStartIndex = j; - break; + const prevIndent = match[1].length; + if (prevIndent < currentIndent) { + jobId = match[2]; + jobIndent = prevIndent; + jobStartIndex = j; + break; + } } - // Stop if we hit unindented prose - if (prevLine.trim() !== '' && !prevLine.match(/^\s/)) break; + // Stop if we hit unindented prose and it's not a comment-turned-line + if (prevLine.trim() !== '' && !prevLine.match(/^\s/) && !lines[j].startsWith('#')) break; } if (jobId) { @@ -173,6 +182,7 @@ export class TemplateLoader { const job = data[jobId]; if (job && job.uses && job.uses.includes(templateId)) { presets.push({ + id: jobId, name: this.formatPresetName(jobId), values: job.with || {}, secrets: job.secrets || {} @@ -191,9 +201,14 @@ export class TemplateLoader { } formatPresetName(jobId) { + const acronyms = ['GCR', 'ACR', 'GCP', 'SBOM']; return jobId .split(/[-_]/) - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .map(word => { + const upper = word.toUpperCase(); + if (acronyms.includes(upper)) return upper; + return word.charAt(0).toUpperCase() + word.slice(1); + }) .join(' '); } diff --git a/cli/package.json b/cli/package.json index a2ece10..6b473d3 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@udx/reusable-workflows", - "version": "1.1.4", + "version": "1.1.5", "description": "CLI tool to generate GitHub Actions workflows from reusable templates", "type": "module", "main": "index.js", diff --git a/cli/test-gcr.yml b/cli/test-gcr.yml new file mode 100644 index 0000000..df4b1bf --- /dev/null +++ b/cli/test-gcr.yml @@ -0,0 +1,27 @@ +name: Docker Release +"on": + push: + branches: + - main + workflow_dispatch: null +permissions: + contents: write + id-token: write +jobs: + release: + uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master + with: + image_name: ${{ vars.IMAGE_NAME }} + gcp_region: ${{ vars.GCP_REGION }} + gcp_project_id: ${{ vars.GCP_PROJECT_ID }} + gcp_repo: ${{ vars.GCP_REPO }} + gcp_workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + gcp_service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} + release_branch: latest + dockerfile_path: ./Dockerfile + build_platforms: linux/amd64,linux/arm64 + version_config_path: ci/git-version.yml + build_args: "" + enable_security_scan: "true" + enable_security_upload: "true" + enable_sbom: "true" diff --git a/cli/test/run-ux.js b/cli/test/run-ux.js new file mode 100644 index 0000000..bc18a02 --- /dev/null +++ b/cli/test/run-ux.js @@ -0,0 +1,149 @@ +import { execSync } from 'child_process'; +import { readFileSync, existsSync, mkdirSync, readdirSync, lstatSync, rmSync } from 'fs'; +import { join, dirname, relative } from 'path'; +import { fileURLToPath } from 'url'; +import yaml from 'js-yaml'; +import chalk from 'chalk'; + +import { CLI_CONFIG } from '../config.js'; +import { TemplateLoader } from '../lib/template-loader.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const REPO_ROOT = join(__dirname, '../..'); +const TEMP_DIR = join(__dirname, 'ux-temp-runner'); +const CASES_DIR = join(__dirname, 'ux-cases'); + +/** + * Asset-Driven UX Test Runner + * Verifies CLI output against source templates and example files. + */ +class UXTestRunner { + constructor() { + this.templateLoader = new TemplateLoader(REPO_ROOT, CLI_CONFIG); + } + + async run() { + console.log(chalk.blue('๐Ÿš€ Starting Asset-Driven UX Tests...')); + + const cases = this.findCases(CASES_DIR); + let passed = 0; + let failed = 0; + + for (const casePath of cases) { + const caseName = relative(CASES_DIR, casePath); + try { + await this.runTestCase(casePath); + console.log(chalk.green(` โœ… PASSED: ${caseName}`)); + passed++; + } catch (error) { + console.log(chalk.red(` โŒ FAILED: ${caseName}`)); + console.error(chalk.yellow(` Reason: ${error.message}`)); + failed++; + } + } + + console.log('\n' + chalk.blue('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•')); + console.log(chalk.white(` Total: ${passed + failed} | `) + chalk.green(`Passed: ${passed}`) + chalk.white(' | ') + chalk.red(`Failed: ${failed}`)); + console.log(chalk.blue('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•') + '\n'); + + if (failed > 0) { + process.exit(1); + } + } + + findCases(dir) { + let results = []; + const list = readdirSync(dir); + for (const file of list) { + const path = join(dir, file); + const stat = lstatSync(path); + if (stat && stat.isDirectory()) { + results = results.concat(this.findCases(path)); + } else if (file.endsWith('.json')) { + results.push(path); + } + } + return results; + } + + async runTestCase(casePath) { + const testCase = JSON.parse(readFileSync(casePath, 'utf8')); + const testWorkDir = join(TEMP_DIR, relative(CASES_DIR, casePath).replace('.json', '')); + + // Clean and prepare workdir + if (existsSync(testWorkDir)) rmSync(testWorkDir, { recursive: true, force: true }); + mkdirSync(testWorkDir, { recursive: true }); + + // 0. Setup fixture if specified + if (testCase.setup) { + const fixtureDir = join(REPO_ROOT, 'cli/test/cases', testCase.setup); + if (existsSync(fixtureDir)) { + execSync(`cp -r "${fixtureDir}"/. "${testWorkDir}/"`); + } + } + + // Run CLI + const args = testCase.args || []; + const cmd = `node "${join(REPO_ROOT, 'cli/index.js')}" ${testCase.template} ${args.join(' ')}`; + execSync(cmd, { cwd: testWorkDir, stdio: 'pipe' }); + + // Load generated manifest + const manifestPath = join(testWorkDir, `.github/workflows/${testCase.template}.yml`); + if (!existsSync(manifestPath)) { + throw new Error(`Manifest not generated at ${manifestPath}`); + } + const manifestRaw = readFileSync(manifestPath, 'utf8'); + const manifest = yaml.load(manifestRaw); + const generatedJob = manifest.jobs.release; + + // 1. Verify against Preset if specified + if (testCase.matchPreset) { + const examplesPath = join(REPO_ROOT, `examples/${testCase.template}.yml`); + const presets = this.templateLoader.extractPresets(examplesPath); + const targetPreset = presets.find(p => p.id === testCase.matchPreset || p.name === testCase.matchPreset); + + if (!targetPreset) { + throw new Error(`Preset "${testCase.matchPreset}" not found in ${examplesPath}`); + } + + this.compareValues(generatedJob.with || {}, targetPreset.values, 'inputs'); + this.compareValues(generatedJob.secrets || {}, targetPreset.secrets, 'secrets'); + } + + // 2. Verify Required inputs if specified + if (testCase.verifyRequired) { + const template = this.templateLoader.loadTemplates().find(t => t.id === testCase.template); + const inputs = this.templateLoader.parseWorkflowInputs(template); + + for (const [name, config] of Object.entries(inputs)) { + if (config.required && generatedJob.with?.[name] === undefined) { + throw new Error(`Required input "${name}" missing from generated manifest`); + } + } + } + + // 3. Verify manual contains if specified + if (testCase.expect?.contains) { + for (const str of testCase.expect.contains) { + if (!manifestRaw.includes(str)) { + throw new Error(`Manifest missing expected string: "${str}"`); + } + } + } + } + + compareValues(generated, expected, type) { + for (const [key, val] of Object.entries(expected)) { + if (generated[key] !== val) { + throw new Error(`Mismatch in ${type} "${key}": expected "${val}", got "${generated[key]}"`); + } + } + } +} + +const runner = new UXTestRunner(); +runner.run().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/cli/test/ux-cases/docker-ops/acr.json b/cli/test/ux-cases/docker-ops/acr.json new file mode 100644 index 0000000..72857d5 --- /dev/null +++ b/cli/test/ux-cases/docker-ops/acr.json @@ -0,0 +1,6 @@ +{ + "name": "ACR Preset", + "template": "docker-ops", + "args": ["--non-interactive", "--preset", "ACR"], + "matchPreset": "acr" +} diff --git a/cli/test/ux-cases/docker-ops/docker-hub.json b/cli/test/ux-cases/docker-ops/docker-hub.json new file mode 100644 index 0000000..3d57a0d --- /dev/null +++ b/cli/test/ux-cases/docker-ops/docker-hub.json @@ -0,0 +1,6 @@ +{ + "name": "Docker Hub Preset", + "template": "docker-ops", + "args": ["--non-interactive", "--preset", "Docker Hub"], + "matchPreset": "docker-hub" +} diff --git a/cli/test/ux-cases/docker-ops/existing-config.json b/cli/test/ux-cases/docker-ops/existing-config.json new file mode 100644 index 0000000..82dc5e9 --- /dev/null +++ b/cli/test/ux-cases/docker-ops/existing-config.json @@ -0,0 +1,9 @@ +{ + "name": "Existing Config Detection", + "template": "docker-ops", + "args": ["--non-interactive"], + "setup": "repo-single", + "expect": { + "contains": ["image_name: test"] + } +} diff --git a/cli/test/ux-cases/docker-ops/gcr.json b/cli/test/ux-cases/docker-ops/gcr.json new file mode 100644 index 0000000..0d51db9 --- /dev/null +++ b/cli/test/ux-cases/docker-ops/gcr.json @@ -0,0 +1,6 @@ +{ + "name": "GCR Preset", + "template": "docker-ops", + "args": ["--non-interactive", "--preset", "GCR"], + "matchPreset": "gcr" +} diff --git a/cli/test/ux-cases/docker-ops/multi-registry.json b/cli/test/ux-cases/docker-ops/multi-registry.json new file mode 100644 index 0000000..b224914 --- /dev/null +++ b/cli/test/ux-cases/docker-ops/multi-registry.json @@ -0,0 +1,6 @@ +{ + "name": "Multi-Registry Preset", + "template": "docker-ops", + "args": ["--non-interactive", "--preset", "multi-registry"], + "matchPreset": "multi-registry" +} diff --git a/cli/test/ux-cases/npm-release-ops/fresh.json b/cli/test/ux-cases/npm-release-ops/fresh.json new file mode 100644 index 0000000..533d2b5 --- /dev/null +++ b/cli/test/ux-cases/npm-release-ops/fresh.json @@ -0,0 +1,6 @@ +{ + "name": "NPM Release Fresh", + "template": "npm-release-ops", + "args": ["--non-interactive"], + "verifyRequired": true +} diff --git a/cli/test/ux-cases/npm-release-ops/version-pinning.json b/cli/test/ux-cases/npm-release-ops/version-pinning.json new file mode 100644 index 0000000..6ca90a1 --- /dev/null +++ b/cli/test/ux-cases/npm-release-ops/version-pinning.json @@ -0,0 +1,8 @@ +{ + "name": "Version Pinning", + "template": "npm-release-ops", + "args": ["--non-interactive", "--ref", "v2.0.0"], + "expect": { + "contains": ["uses: udx/reusable-workflows/.github/workflows/npm-release-ops.yml@v2.0.0"] + } +} diff --git a/cli/test/ux-cases/wp-gh-release-ops/fresh.json b/cli/test/ux-cases/wp-gh-release-ops/fresh.json new file mode 100644 index 0000000..bd80ebd --- /dev/null +++ b/cli/test/ux-cases/wp-gh-release-ops/fresh.json @@ -0,0 +1,6 @@ +{ + "name": "WordPress Release Fresh", + "template": "wp-gh-release-ops", + "args": ["--non-interactive"], + "verifyRequired": true +} diff --git a/cli/test/ux-temp-runner/docker-ops/acr/.github/workflows/docker-ops.yml b/cli/test/ux-temp-runner/docker-ops/acr/.github/workflows/docker-ops.yml new file mode 100644 index 0000000..72a73d3 --- /dev/null +++ b/cli/test/ux-temp-runner/docker-ops/acr/.github/workflows/docker-ops.yml @@ -0,0 +1,27 @@ +name: Docker Release +"on": + push: + branches: + - main + workflow_dispatch: null +permissions: + contents: write + id-token: write +jobs: + release: + uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master + with: + image_name: ${{ vars.IMAGE_NAME }} + acr_registry: ${{ vars.ACR_REGISTRY }} + acr_repository: ${{ vars.ACR_REPOSITORY }} + azure_client_id: ${{ vars.AZURE_CLIENT_ID }} + azure_tenant_id: ${{ vars.AZURE_TENANT_ID }} + azure_subscription_id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + release_branch: latest + dockerfile_path: ./Dockerfile + build_platforms: linux/amd64,linux/arm64 + version_config_path: ci/git-version.yml + build_args: "" + enable_security_scan: "true" + enable_security_upload: "true" + enable_sbom: "true" diff --git a/cli/test/ux-temp-runner/docker-ops/docker-hub/.github/workflows/docker-ops.yml b/cli/test/ux-temp-runner/docker-ops/docker-hub/.github/workflows/docker-ops.yml new file mode 100644 index 0000000..8acd969 --- /dev/null +++ b/cli/test/ux-temp-runner/docker-ops/docker-hub/.github/workflows/docker-ops.yml @@ -0,0 +1,27 @@ +name: Docker Release +"on": + push: + branches: + - main + workflow_dispatch: null +permissions: + contents: write + id-token: write +jobs: + release: + uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master + with: + image_name: ${{ vars.IMAGE_NAME }} + docker_login: ${{ vars.DOCKER_USERNAME }} + docker_org: ${{ vars.DOCKER_ORG }} + docker_repo: ${{ vars.DOCKER_REPO }} + release_branch: latest + dockerfile_path: ./Dockerfile + build_platforms: linux/amd64,linux/arm64 + version_config_path: ci/git-version.yml + build_args: "" + enable_security_scan: "true" + enable_security_upload: "true" + enable_sbom: "true" + secrets: + docker_token: ${{ secrets.DOCKER_TOKEN }} diff --git a/cli/test/ux-temp-runner/docker-ops/existing-config/.github/workflows/docker-ops.yml b/cli/test/ux-temp-runner/docker-ops/existing-config/.github/workflows/docker-ops.yml new file mode 100644 index 0000000..d18959f --- /dev/null +++ b/cli/test/ux-temp-runner/docker-ops/existing-config/.github/workflows/docker-ops.yml @@ -0,0 +1,35 @@ +name: Docker Release +"on": + push: + branches: + - main + workflow_dispatch: null +permissions: + contents: write + id-token: write +jobs: + release: + uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master + with: + image_name: test + release_branch: latest + docker_login: ${{ vars.DOCKER_USERNAME }} + docker_org: myorg + docker_repo: my-app + gcp_region: us-central1 + gcp_project_id: my-gcp-project + gcp_repo: docker-images + gcp_workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/github-provider + gcp_service_account: github-actions@my-gcp-project.iam.gserviceaccount.com + acr_registry: myregistry.azurecr.io + acr_repository: my-app + azure_client_id: 12345678-90ab-cdef-1234-567890abcdef + azure_tenant_id: 87654321-fedc-ba09-8765-4321fedcba09 + azure_subscription_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + dockerfile_path: ./Dockerfile + build_platforms: linux/amd64,linux/arm64 + build_args: "" + version_config_path: ci/git-version.yml + enable_security_scan: "true" + enable_security_upload: "true" + enable_sbom: "true" diff --git a/cli/test/ux-temp-runner/docker-ops/gcr/.github/workflows/docker-ops.yml b/cli/test/ux-temp-runner/docker-ops/gcr/.github/workflows/docker-ops.yml new file mode 100644 index 0000000..df4b1bf --- /dev/null +++ b/cli/test/ux-temp-runner/docker-ops/gcr/.github/workflows/docker-ops.yml @@ -0,0 +1,27 @@ +name: Docker Release +"on": + push: + branches: + - main + workflow_dispatch: null +permissions: + contents: write + id-token: write +jobs: + release: + uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master + with: + image_name: ${{ vars.IMAGE_NAME }} + gcp_region: ${{ vars.GCP_REGION }} + gcp_project_id: ${{ vars.GCP_PROJECT_ID }} + gcp_repo: ${{ vars.GCP_REPO }} + gcp_workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + gcp_service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} + release_branch: latest + dockerfile_path: ./Dockerfile + build_platforms: linux/amd64,linux/arm64 + version_config_path: ci/git-version.yml + build_args: "" + enable_security_scan: "true" + enable_security_upload: "true" + enable_sbom: "true" diff --git a/cli/test/ux-temp-runner/docker-ops/multi-registry/.github/workflows/docker-ops.yml b/cli/test/ux-temp-runner/docker-ops/multi-registry/.github/workflows/docker-ops.yml new file mode 100644 index 0000000..2f1acff --- /dev/null +++ b/cli/test/ux-temp-runner/docker-ops/multi-registry/.github/workflows/docker-ops.yml @@ -0,0 +1,38 @@ +name: Docker Release +"on": + push: + branches: + - main + workflow_dispatch: null +permissions: + contents: write + id-token: write +jobs: + release: + uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master + with: + image_name: ${{ vars.IMAGE_NAME }} + docker_login: ${{ vars.DOCKER_USERNAME }} + docker_org: ${{ vars.DOCKER_ORG }} + docker_repo: ${{ vars.DOCKER_REPO }} + gcp_region: ${{ vars.GCP_REGION }} + gcp_project_id: ${{ vars.GCP_PROJECT_ID }} + gcp_repo: ${{ vars.GCP_REPO }} + gcp_workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + gcp_service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} + acr_registry: ${{ vars.ACR_REGISTRY }} + acr_repository: ${{ vars.ACR_REPOSITORY }} + azure_client_id: ${{ vars.AZURE_CLIENT_ID }} + azure_tenant_id: ${{ vars.AZURE_TENANT_ID }} + azure_subscription_id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + release_branch: latest + dockerfile_path: ./Dockerfile + build_platforms: linux/amd64,linux/arm64 + version_config_path: ci/git-version.yml + build_args: "" + enable_security_scan: "true" + enable_security_upload: "true" + enable_sbom: "true" + secrets: + docker_token: ${{ secrets.DOCKER_TOKEN }} + slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/cli/test/ux-temp-runner/npm-release-ops/fresh/.github/workflows/npm-release-ops.yml b/cli/test/ux-temp-runner/npm-release-ops/fresh/.github/workflows/npm-release-ops.yml new file mode 100644 index 0000000..4ba5ed8 --- /dev/null +++ b/cli/test/ux-temp-runner/npm-release-ops/fresh/.github/workflows/npm-release-ops.yml @@ -0,0 +1,14 @@ +name: NPM Release +"on": + push: + branches: + - latest +permissions: + contents: write + id-token: write +jobs: + release: + uses: udx/reusable-workflows/.github/workflows/npm-release-ops.yml@master + with: + node_version: "20" + working_directory: . diff --git a/cli/test/ux-temp-runner/npm-release-ops/version-pinning/.github/workflows/npm-release-ops.yml b/cli/test/ux-temp-runner/npm-release-ops/version-pinning/.github/workflows/npm-release-ops.yml new file mode 100644 index 0000000..e028ad1 --- /dev/null +++ b/cli/test/ux-temp-runner/npm-release-ops/version-pinning/.github/workflows/npm-release-ops.yml @@ -0,0 +1,14 @@ +name: NPM Release +"on": + push: + branches: + - latest +permissions: + contents: write + id-token: write +jobs: + release: + uses: udx/reusable-workflows/.github/workflows/npm-release-ops.yml@v2.0.0 + with: + node_version: "20" + working_directory: . diff --git a/cli/test/ux-temp-runner/wp-gh-release-ops/fresh/.github/workflows/wp-gh-release-ops.yml b/cli/test/ux-temp-runner/wp-gh-release-ops/fresh/.github/workflows/wp-gh-release-ops.yml new file mode 100644 index 0000000..80dab26 --- /dev/null +++ b/cli/test/ux-temp-runner/wp-gh-release-ops/fresh/.github/workflows/wp-gh-release-ops.yml @@ -0,0 +1,22 @@ +name: Publish WP Plugin Release on GitHub +"on": + workflow_dispatch: + inputs: + tag: + description: Release tag (e.g. 1.2.3a) + required: true + version: + description: "Release version (e.g. 1.2.3), default: latest" + required: false + prerelease: + description: Pre-release version (e.g. RC1, beta, etc...) + required: false +permissions: + contents: write + id-token: write +jobs: + release: + uses: udx/reusable-workflows/.github/workflows/wp-gh-release-ops.yml@master + with: + tag: 1.2.3 + prerelease: "" diff --git a/examples/docker-ops.yml b/examples/docker-ops.yml index d840ed6..0a759e3 100644 --- a/examples/docker-ops.yml +++ b/examples/docker-ops.yml @@ -58,7 +58,7 @@ jobs: # All: all # USE CASE: Publish to Docker Hub, GCP, and Azure simultaneously # =========================================================================== - all: + multi-registry: permissions: id-token: write contents: write diff --git a/scripts/test-cli-ux.sh b/scripts/test-cli-ux.sh index 5047dc8..5c1b926 100755 --- a/scripts/test-cli-ux.sh +++ b/scripts/test-cli-ux.sh @@ -1,132 +1,16 @@ #!/bin/bash set -e -# Reusable Workflows CLI UX Test Script -# Verifies CLI behavior across different repository states +# Reusable Workflows CLI Asset-Driven UX Test Runner +# Verifies CLI behavior by comparing output against source templates and examples REPO_ROOT=$(pwd) CLI_DIR="$REPO_ROOT/cli" -TEST_TEMP_DIR="$REPO_ROOT/cli/test/ux-temp" -# Colors -GREEN='\033[0;32m' -BLUE='\033[0;34m' -NC='\033[0m' +echo -e "\033[0;34m๐Ÿš€ Running Asset-Driven UX Tests...\033[0m" -# Cleanup on exit -trap 'rm -rf "$TEST_TEMP_DIR"' EXIT +# Ensure dependencies are available (though they should be if CLI works) +cd "$CLI_DIR" +node test/run-ux.js -echo -e "${BLUE}๐Ÿš€ Starting CLI UX Tests...${NC}" - -# Cleanup previous tests -rm -rf "$TEST_TEMP_DIR" -mkdir -p "$TEST_TEMP_DIR" - -# Test Case 1: Fresh Repository (No existing workflows) -echo -e "\n${BLUE}[Test 1] Fresh Repository - Non-interactive mode${NC}" -TEST1_DIR="$TEST_TEMP_DIR/fresh-repo" -mkdir -p "$TEST1_DIR" -cd "$TEST1_DIR" - -# Run CLI for npm-release-ops -node "$CLI_DIR/index.js" npm-release-ops --non-interactive - -if [ -f ".github/workflows/npm-release-ops.yml" ]; then - echo -e "${GREEN}โœ… Successfully generated manifest in fresh repo${NC}" -else - echo -e "โŒ Failed to generate manifest in fresh repo" - exit 1 -fi - -# Test Case 2: Existing Configuration Detection -echo -e "\n${BLUE}[Test 2] Existing Configuration Detection${NC}" -TEST2_DIR="$TEST_TEMP_DIR/existing-config" -mkdir -p "$TEST2_DIR" -cp -r "$CLI_DIR/test/cases/repo-single/.github" "$TEST2_DIR/" -cd "$TEST2_DIR" - -# Run CLI for docker-ops - should auto-detect from the seeded file -node "$CLI_DIR/index.js" docker-ops --non-interactive - -# Verify common detected value -if grep -q "image_name: test" ".github/workflows/docker-ops.yml"; then - echo -e "${GREEN}โœ… Successfully detected existing configuration${NC}" -else - echo -e "โŒ Failed to detect existing configuration" - exit 1 -fi - -# Test Case 3: Positional Argument Selection -echo -e "\n${BLUE}[Test 3] Positional Argument Selection${NC}" -TEST3_DIR="$TEST_TEMP_DIR/positional-arg" -mkdir -p "$TEST3_DIR" -cd "$TEST3_DIR" - -# Run CLI with positional arg but interactive (should skip selection prompt) -# Note: We can't easily automate interactive prompts here without expects, -# but we can verify it doesn't crash and starts the right template if we piped input. -# For now, let's just use non-interactive to verify positional arg works. -node "$CLI_DIR/index.js" docker-ops --non-interactive - -if [ -f ".github/workflows/docker-ops.yml" ]; then - echo -e "${GREEN}โœ… Successfully used positional argument for template selection${NC}" -else - echo -e "โŒ Failed to use positional argument" - exit 1 -fi - -# Test Case 4: Version Pinning -echo -e "\n${BLUE}[Test 4] Version Pinning${NC}" -TEST4_DIR="$TEST_TEMP_DIR/version-pinning" -mkdir -p "$TEST4_DIR" -cd "$TEST4_DIR" - -# Run CLI with --ref pinning -node "$CLI_DIR/index.js" npm-release-ops --non-interactive --ref "v1.2.3" - -if grep -q "@v1.2.3" ".github/workflows/npm-release-ops.yml"; then - echo -e "${GREEN}โœ… Successfully pinned workflow to v1.2.3${NC}" -else - echo -e "โŒ Failed to pin workflow version" - exit 1 -fi - -# Test Case 5: Multi-Template Detection -echo -e "\n${BLUE}[Test 5] Multi-Template Detection${NC}" -TEST5_DIR="$TEST_TEMP_DIR/multi-template" -mkdir -p "$TEST5_DIR" -cp -r "$CLI_DIR/test/cases/repo-multi/.github" "$TEST5_DIR/" -cd "$TEST5_DIR" - -# Run CLI for npm-release-ops and verify it ONLY picked up npm values -node "$CLI_DIR/index.js" npm-release-ops --non-interactive - -if grep -q "working_directory: multi-npm-dir" ".github/workflows/npm-release-ops.yml" && ! grep -q "multi-docker-image" ".github/workflows/npm-release-ops.yml"; then - echo -e "${GREEN}โœ… Successfully isolated configuration in multi-template repo${NC}" -else - echo -e "โŒ Multi-template detection failed or leaked values" - exit 1 -fi - -# Test Case 6: Configuration Presets -echo -e "\n${BLUE}[Test 6] Configuration Presets${NC}" -TEST6_DIR="$TEST_TEMP_DIR/presets" -mkdir -p "$TEST6_DIR" -cd "$TEST6_DIR" - -# Run CLI with --preset -node "$CLI_DIR/index.js" docker-ops --non-interactive --preset "Docker Hub" - -if grep -q "uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master" ".github/workflows/docker-ops.yml" && grep -q "image_name: \"\?\${{ vars.IMAGE_NAME }}\"\?" ".github/workflows/docker-ops.yml"; then - echo -e "${GREEN}โœ… Successfully generated manifest from preset${NC}" -else - echo -e "โŒ Preset-based generation failed (Check .github/workflows/docker-ops.yml content)" - cat .github/workflows/docker-ops.yml - exit 1 -fi - -echo -e "\n${GREEN}โœจ All CLI UX Tests Passed!${NC}" - -# Cleanup -cd "$REPO_ROOT" -rm -rf "$TEST_TEMP_DIR" +echo -e "\n\033[0;32mโœจ UX Test Suite Execution Complete\033[0m" From d966b55572dc097e9d0f250eb200c41f37fd077e Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 20:02:50 +0300 Subject: [PATCH 19/20] docs: finalize READMEs for v1.1.5 --- cli/README.md | 8 +++++-- cli/changes.md | 22 +++++++++++++++++++ .../.github/workflows/docker-ops.yml | 0 .../.github/workflows/test-usage.yml | 0 .../{ux-cases => cases}/docker-ops/acr.json | 0 .../docker-ops/docker-hub.json | 0 .../docker-ops/existing-config.json | 0 .../{ux-cases => cases}/docker-ops/gcr.json | 0 .../docker-ops/multi-registry.json | 0 .../npm-release-ops/fresh.json | 0 .../npm-release-ops/version-pinning.json | 0 .../.github/workflows/docker-ops.yml | 5 ----- .../.github/workflows/npm-release-ops.yml | 5 ----- .../wp-gh-release-ops/fresh.json | 0 cli/test/config-detector.test.js | 2 +- cli/test/input-grouper.test.js | 2 +- cli/test/run-ux.js | 5 +++-- 17 files changed, 33 insertions(+), 16 deletions(-) rename cli/test/cases/{ => _fixtures}/repo-single/.github/workflows/docker-ops.yml (100%) rename cli/test/{mocks => cases/_fixtures/unit-mocks}/.github/workflows/test-usage.yml (100%) rename cli/test/{ux-cases => cases}/docker-ops/acr.json (100%) rename cli/test/{ux-cases => cases}/docker-ops/docker-hub.json (100%) rename cli/test/{ux-cases => cases}/docker-ops/existing-config.json (100%) rename cli/test/{ux-cases => cases}/docker-ops/gcr.json (100%) rename cli/test/{ux-cases => cases}/docker-ops/multi-registry.json (100%) rename cli/test/{ux-cases => cases}/npm-release-ops/fresh.json (100%) rename cli/test/{ux-cases => cases}/npm-release-ops/version-pinning.json (100%) delete mode 100644 cli/test/cases/repo-multi/.github/workflows/docker-ops.yml delete mode 100644 cli/test/cases/repo-multi/.github/workflows/npm-release-ops.yml rename cli/test/{ux-cases => cases}/wp-gh-release-ops/fresh.json (100%) diff --git a/cli/README.md b/cli/README.md index 37bf0da..d2055bf 100644 --- a/cli/README.md +++ b/cli/README.md @@ -22,15 +22,19 @@ reusable-workflows [template-id] [options] ## Features - **Smart Auto-Detection**: Scans `.github/workflows` to pre-fill existing configurations. +- **Dynamic Presets**: Automatically extracts configuration presets (e.g., GCR, ACR) from example files. - **Fast-Path Flow**: Skips redundant prompts and moves straight to a manifest preview. - **One-Glance Preview**: Review the generated YAML in your terminal before writing. -- **Opt-in Setup Guides**: Documentation (`SETUP-*.md`) is generated only when requested. -- **Non-Interactive Mode**: Support for headless environments and scripts. +- **Non-Interactive Mode**: Full support for headless environments and CI/CD scripts. +- **Asset-Driven Testing**: Built-in test suite verifies CLI output against source templates and examples. ## Options - `[template-id]`: Optional positional argument to skip selection (e.g., `docker-ops`). - `-n, --non-interactive`: Use detected/default values without prompting. +- `-p, --preset [name]`: Apply a specific configuration preset (e.g., `GCR`, `Docker Hub`). +- `-r, --ref [version]`: Pin the reusable workflow to a specific Git ref/version. +- `-o, --output [path]`: Specify custom destination for the manifest. - `-h, --help`: Show usage instructions. ## Metadata Architecture diff --git a/cli/changes.md b/cli/changes.md index 646d9ab..3b0fcbf 100644 --- a/cli/changes.md +++ b/cli/changes.md @@ -1,5 +1,27 @@ # Changelog +### 1.1.5 +- **Feat**: Asset-Driven UX Testing suite (verifies CLI against repository templates/examples). +- **Fix**: Normalized preset matching (matches `multi-registry` to `Multi Registry`). +- **Fix**: Trigger sanitization (removes `workflow_call` from generated manifests). +- **Fix**: Non-interactive mode now correctly selects groups with required inputs. +- **Refactor**: Unified test structure under `cli/test/cases/_fixtures`. + +### 1.1.4 +- **Feat**: Introduced specific Docker presets: `Docker Hub`, `GCR`, `ACR`, and `Multi Registry`. +- **Refactor**: Standardized all example configurations to use GitHub `vars` and `secrets`. +- **Chore**: Updated metadata descriptions for `build_platforms` and `version_config_path`. + +### 1.1.3 +- **Feat**: Implemented marker-free Auto-Preset detection via `TemplateLoader`. +- **Feat**: Smart prompt skipping (CLI skips questions if values are detected or provided by preset). +- **Refactor**: `FileGenerator` now constructs manifests from scratch for cleaner YAML output. +- **Chore**: Removed legacy "AI Prompt" comments from all assets. + +### 1.1.2 +- **Fix**: Bundled templates with NPM package for reliable global installation. +- **Fix**: Improved repo root detection for global CLI usage. + ### 1.1.0 - **Feat**: Added OIDC (Keyless) publishing support. - **Feat**: Added GitHub Environment support (configured for `Master` environment). diff --git a/cli/test/cases/repo-single/.github/workflows/docker-ops.yml b/cli/test/cases/_fixtures/repo-single/.github/workflows/docker-ops.yml similarity index 100% rename from cli/test/cases/repo-single/.github/workflows/docker-ops.yml rename to cli/test/cases/_fixtures/repo-single/.github/workflows/docker-ops.yml diff --git a/cli/test/mocks/.github/workflows/test-usage.yml b/cli/test/cases/_fixtures/unit-mocks/.github/workflows/test-usage.yml similarity index 100% rename from cli/test/mocks/.github/workflows/test-usage.yml rename to cli/test/cases/_fixtures/unit-mocks/.github/workflows/test-usage.yml diff --git a/cli/test/ux-cases/docker-ops/acr.json b/cli/test/cases/docker-ops/acr.json similarity index 100% rename from cli/test/ux-cases/docker-ops/acr.json rename to cli/test/cases/docker-ops/acr.json diff --git a/cli/test/ux-cases/docker-ops/docker-hub.json b/cli/test/cases/docker-ops/docker-hub.json similarity index 100% rename from cli/test/ux-cases/docker-ops/docker-hub.json rename to cli/test/cases/docker-ops/docker-hub.json diff --git a/cli/test/ux-cases/docker-ops/existing-config.json b/cli/test/cases/docker-ops/existing-config.json similarity index 100% rename from cli/test/ux-cases/docker-ops/existing-config.json rename to cli/test/cases/docker-ops/existing-config.json diff --git a/cli/test/ux-cases/docker-ops/gcr.json b/cli/test/cases/docker-ops/gcr.json similarity index 100% rename from cli/test/ux-cases/docker-ops/gcr.json rename to cli/test/cases/docker-ops/gcr.json diff --git a/cli/test/ux-cases/docker-ops/multi-registry.json b/cli/test/cases/docker-ops/multi-registry.json similarity index 100% rename from cli/test/ux-cases/docker-ops/multi-registry.json rename to cli/test/cases/docker-ops/multi-registry.json diff --git a/cli/test/ux-cases/npm-release-ops/fresh.json b/cli/test/cases/npm-release-ops/fresh.json similarity index 100% rename from cli/test/ux-cases/npm-release-ops/fresh.json rename to cli/test/cases/npm-release-ops/fresh.json diff --git a/cli/test/ux-cases/npm-release-ops/version-pinning.json b/cli/test/cases/npm-release-ops/version-pinning.json similarity index 100% rename from cli/test/ux-cases/npm-release-ops/version-pinning.json rename to cli/test/cases/npm-release-ops/version-pinning.json diff --git a/cli/test/cases/repo-multi/.github/workflows/docker-ops.yml b/cli/test/cases/repo-multi/.github/workflows/docker-ops.yml deleted file mode 100644 index 2cd5749..0000000 --- a/cli/test/cases/repo-multi/.github/workflows/docker-ops.yml +++ /dev/null @@ -1,5 +0,0 @@ -jobs: - build: - uses: udx/reusable-workflows/.github/workflows/docker-ops.yml@master - with: - image_name: "multi-docker-image" diff --git a/cli/test/cases/repo-multi/.github/workflows/npm-release-ops.yml b/cli/test/cases/repo-multi/.github/workflows/npm-release-ops.yml deleted file mode 100644 index 598a5c1..0000000 --- a/cli/test/cases/repo-multi/.github/workflows/npm-release-ops.yml +++ /dev/null @@ -1,5 +0,0 @@ -jobs: - release: - uses: udx/reusable-workflows/.github/workflows/npm-release-ops.yml@master - with: - working_directory: "multi-npm-dir" diff --git a/cli/test/ux-cases/wp-gh-release-ops/fresh.json b/cli/test/cases/wp-gh-release-ops/fresh.json similarity index 100% rename from cli/test/ux-cases/wp-gh-release-ops/fresh.json rename to cli/test/cases/wp-gh-release-ops/fresh.json diff --git a/cli/test/config-detector.test.js b/cli/test/config-detector.test.js index e81ee7d..48eb0b8 100644 --- a/cli/test/config-detector.test.js +++ b/cli/test/config-detector.test.js @@ -8,7 +8,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); test('ConfigDetector - detectExistingConfig', () => { // Point to our mock directory instead of real CWD - const mockRepoRoot = join(__dirname, 'mocks'); + const mockRepoRoot = join(__dirname, 'cases/_fixtures/unit-mocks'); const detector = new ConfigDetector(mockRepoRoot); // Override the internal workflowDir for testing diff --git a/cli/test/input-grouper.test.js b/cli/test/input-grouper.test.js index 0966481..1692f97 100644 --- a/cli/test/input-grouper.test.js +++ b/cli/test/input-grouper.test.js @@ -26,7 +26,7 @@ test('InputGrouper - extractPrompt', () => { const grouper = new InputGrouper(CLI_CONFIG); const prompt1 = grouper.extractPrompt('Docker Hub: Username (required)'); - assert.strictEqual(prompt1, 'Username'); + assert.strictEqual(prompt1, 'Username (required)'); const prompt2 = grouper.extractPrompt('Image name'); assert.strictEqual(prompt2, 'Image name'); diff --git a/cli/test/run-ux.js b/cli/test/run-ux.js index bc18a02..d345098 100644 --- a/cli/test/run-ux.js +++ b/cli/test/run-ux.js @@ -12,7 +12,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const REPO_ROOT = join(__dirname, '../..'); const TEMP_DIR = join(__dirname, 'ux-temp-runner'); -const CASES_DIR = join(__dirname, 'ux-cases'); +const CASES_DIR = join(__dirname, 'cases'); /** * Asset-Driven UX Test Runner @@ -56,6 +56,7 @@ class UXTestRunner { let results = []; const list = readdirSync(dir); for (const file of list) { + if (file.startsWith('_')) continue; // Ignore hidden/fixture dirs const path = join(dir, file); const stat = lstatSync(path); if (stat && stat.isDirectory()) { @@ -77,7 +78,7 @@ class UXTestRunner { // 0. Setup fixture if specified if (testCase.setup) { - const fixtureDir = join(REPO_ROOT, 'cli/test/cases', testCase.setup); + const fixtureDir = join(CASES_DIR, '_fixtures', testCase.setup); if (existsSync(fixtureDir)) { execSync(`cp -r "${fixtureDir}"/. "${testWorkDir}/"`); } From cd6765330df0e19a2f1abc7c8cab93c2ae9a5337 Mon Sep 17 00:00:00 2001 From: Dmitry Smirnov Date: Fri, 16 Jan 2026 20:10:36 +0300 Subject: [PATCH 20/20] cli: Smart Prompt Skipping and Improved Interactive Registry Selection --- cli/changes.md | 5 +++++ cli/index.js | 28 +++++++++++++++++++++------- cli/package.json | 2 +- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/cli/changes.md b/cli/changes.md index 3b0fcbf..9a36f37 100644 --- a/cli/changes.md +++ b/cli/changes.md @@ -1,5 +1,10 @@ # Changelog +### 1.1.6 +- **Fix**: Smart Prompt Skipping (skips inputs with defaults to reduce friction). +- **Fix**: Preset Group Pre-selection (automatically selects and preserves preset-related component groups). +- **Fix**: Improved Interactive Registry Selection (allows adding registries to a preset base). + ### 1.1.5 - **Feat**: Asset-Driven UX Testing suite (verifies CLI against repository templates/examples). - **Fix**: Normalized preset matching (matches `multi-registry` to `Multi Registry`). diff --git a/cli/index.js b/cli/index.js index 06d1374..01e32b8 100644 --- a/cli/index.js +++ b/cli/index.js @@ -143,12 +143,18 @@ class WorkflowGenerator { // Common inputs this.ui.printSectionHeader(icons.config, 'Configuration'); - const commonAnswers = await this.promptInputs(groupedInputs.common, mergedDefaults, isNonInteractive); - + // If we have a preset, we use a "smart skip" strategy: only ask for required fields that have NO default or detected/preset value + const commonAnswers = await this.promptInputs(groupedInputs.common, mergedDefaults, isNonInteractive, !!cliPresetName || (template.presets && template.presets.length > 0)); // Optional component groups const prefixGroups = Object.keys(groupedInputs).filter(key => key !== 'common'); let groupAnswers = {}; - let selectedGroups = []; + + // Identify pre-selected groups (from preset or detection) + const initialGroups = prefixGroups.filter(key => + groupedInputs[key].some(input => mergedDefaults[input.name] !== undefined) + ); + + let selectedGroups = initialGroups; if (prefixGroups.length > 0) { if (!isNonInteractive) { @@ -162,7 +168,7 @@ class WorkflowGenerator { choices: prefixGroups.map(key => ({ name: groupedInputs[key][0].prefix, value: key, - checked: selectedGroups.includes(key) || groupedInputs[key].some(input => mergedDefaults[input.name] !== undefined) + checked: selectedGroups.includes(key) })) } ]); @@ -190,7 +196,7 @@ class WorkflowGenerator { if (!isNonInteractive) { this.ui.printSectionHeader(icons.group, `${groupName} Configuration`); } - const answers = await this.promptInputs(groupInputs, mergedDefaults, isNonInteractive); + const answers = await this.promptInputs(groupInputs, mergedDefaults, isNonInteractive, true); groupAnswers = { ...groupAnswers, ...answers }; } } @@ -261,7 +267,7 @@ class WorkflowGenerator { return { templateId, answers: allAnswers, files: result }; } - async promptInputs(inputs, detectedValues = {}, isNonInteractive = false) { + async promptInputs(inputs, detectedValues = {}, isNonInteractive = false, skipDefaults = false) { if (!inputs || inputs.length === 0) { return {}; } @@ -278,7 +284,15 @@ class WorkflowGenerator { } const questions = inputs - .filter(input => detectedValues[input.name] === undefined) + .filter(input => { + // Skip if already detected/preset + if (detectedValues[input.name] !== undefined) return false; + + // Skip if it has a default and we are in "smart skip" mode + if (skipDefaults && input.default !== undefined) return false; + + return true; + }) .map(input => { const message = input.name === 'image_name' ? 'Enter Docker image name (e.g. my-app) - used for all registry tags:' diff --git a/cli/package.json b/cli/package.json index 6b473d3..8ac46f3 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@udx/reusable-workflows", - "version": "1.1.5", + "version": "1.1.6", "description": "CLI tool to generate GitHub Actions workflows from reusable templates", "type": "module", "main": "index.js",