diff --git a/README.md b/README.md index bcfcff3..5b66fce 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,11 @@ npm install -g @burgan-tech/vnext-workflow-cli npm install @burgan-tech/vnext-workflow-cli ``` -After installation, you can use the CLI with: +After installation, you can use the CLI with any of these aliases: ```bash -wf --version -wf check +wf --version # short alias +vnext --version # alternative alias (recommended for Windows) +workflow --version # full name ``` ### Install from Source @@ -118,6 +119,8 @@ wf check **Note:** The CLI automatically uses the current working directory as the project root. Just `cd` into your project folder before running commands. +> **Tip:** All examples use `wf` but you can also use `vnext` or `workflow` interchangeably. The `vnext` alias is recommended on Windows where `wf` may conflict with existing system commands. + ### Basic Usage ```bash @@ -420,18 +423,20 @@ wf csx ### 6. Multidomain Workflow ```bash -# Add domains +# Add domains (one-time setup) wf domain add domain-a --API_BASE_URL http://localhost:4201 --DB_NAME vNext_DomainA wf domain add domain-b --API_BASE_URL http://localhost:4221 --DB_NAME vNext_DomainB -# Work on Domain A -wf domain use domain-a -wf check -wf update +# Option A: Auto-switch via vnext.config.json (recommended) +# Just cd into the project - domain profile switches automatically +cd ~/projects/domain-a-app # vnext.config.json has "domain": "domain-a" +wf update # auto-switches to domain-a profile -# Switch to Domain B - config is applied automatically -wf domain use domain-b -wf check +cd ~/projects/domain-b-app # vnext.config.json has "domain": "domain-b" +wf update # auto-switches to domain-b profile + +# Option B: Manual switch (still works) +wf domain use domain-a wf update # See all domains @@ -457,6 +462,35 @@ wf domain list The CLI supports managing multiple domain configurations. Each domain has its own `API_BASE_URL`, `DB_NAME`, and other settings. Switch between domains with a single command. +### Auto Domain Resolution + +When you run any command inside a vNext workspace that contains a `vnext.config.json`, the CLI **automatically** switches to the matching domain profile based on the `domain` field in the config file. This eliminates the need to manually run `wf domain use ` every time you switch between projects. + +**How it works:** +1. Before each command (except `wf domain`), the CLI checks if `vnext.config.json` exists in the current directory. +2. If found, it reads the `domain` field and looks for a matching CLI domain profile (`DOMAINS[].DOMAIN_NAME`). +3. If a match is found and it differs from the current active domain, it silently switches and shows a dim log message: + ``` + [auto] Domain switched to "onboarding" (from vnext.config.json) + ``` +4. If no `vnext.config.json` is found or no matching profile exists, the current active domain is kept (no error). + +**Example:** You have two projects and two domain profiles: +```bash +# Add domain profiles once +wf domain add core --DB_NAME vNext_Core +wf domain add onboarding --DB_NAME vNext_Onboarding + +# Now just cd into the project and run commands - domain switches automatically +cd ~/projects/core-app # has vnext.config.json with "domain": "core" +wf update # auto-switches to "core" profile + +cd ~/projects/onboarding-app # has vnext.config.json with "domain": "onboarding" +wf update # auto-switches to "onboarding" profile +``` + +> **Note:** The `wf domain` command is excluded from auto-resolution so that manual domain management is never interfered with. + ### Backward Compatibility - Existing single-domain configurations are automatically migrated to the new format. @@ -583,8 +617,9 @@ wf config get DOCKER_POSTGRES_CONTAINER ### "npm link not working" ```bash -# Use alias +# Use alias (wf or vnext) echo 'alias wf="node $(pwd)/bin/workflow.js"' >> ~/.bashrc +echo 'alias vnext="node $(pwd)/bin/workflow.js"' >> ~/.bashrc source ~/.bashrc ``` diff --git a/bin/workflow.js b/bin/workflow.js index 72fca8f..742bacc 100755 --- a/bin/workflow.js +++ b/bin/workflow.js @@ -4,6 +4,10 @@ const { program, Argument } = require('commander'); const chalk = require('chalk'); const pkg = require('../package.json'); +// Config +const config = require('../src/lib/config'); +const { printActiveDomainBanner } = require('../src/lib/ui'); + // Commands const checkCommand = require('../src/commands/check'); const csxCommand = require('../src/commands/csx'); @@ -18,6 +22,18 @@ program .description('vNext Workflow Manager CLI') .version(pkg.version); +// Auto-resolve domain and show banner before each command +program.hook('preAction', (thisCommand, actionCommand) => { + if (actionCommand.name() === 'domain') return; + + const result = config.resolveWorkspaceDomain(process.cwd()); + if (result.resolved && result.switched) { + console.log(chalk.dim(` [auto] Domain switched to "${result.domain}" (from vnext.config.json)`)); + } + + printActiveDomainBanner(); +}); + // Check command program .command('check') diff --git a/package-lock.json b/package-lock.json index 06c4b5c..5cb9c8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "pg": "^8.11.3" }, "bin": { + "vnext": "bin/workflow.js", "wf": "bin/workflow.js", "workflow": "bin/workflow.js" }, diff --git a/package.json b/package.json index 4aa007e..0886669 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "dist/index.js", "bin": { "workflow": "./bin/workflow.js", - "wf": "./bin/workflow.js" + "wf": "./bin/workflow.js", + "vnext": "./bin/workflow.js" }, "scripts": { "dev": "node bin/workflow.js", diff --git a/src/commands/check.js b/src/commands/check.js index a90e92f..947f2a3 100644 --- a/src/commands/check.js +++ b/src/commands/check.js @@ -5,22 +5,7 @@ const { discoverComponents, listDiscovered } = require('../lib/discover'); const { getDomain, getComponentTypes, getComponentsRoot } = require('../lib/vnextConfig'); const { testApiConnection } = require('../lib/api'); const { testDbConnection } = require('../lib/db'); - -// Logging helpers -const LOG = { - separator: () => console.log(chalk.cyan('═'.repeat(60))), - subSeparator: () => console.log(chalk.cyan('─'.repeat(60))), - header: (text) => { - console.log(); - LOG.separator(); - console.log(chalk.cyan.bold(` ${text}`)); - LOG.separator(); - }, - success: (text) => console.log(chalk.green(` ✓ ${text}`)), - error: (text) => console.log(chalk.red(` ✗ ${text}`)), - warning: (text) => console.log(chalk.yellow(` ⚠ ${text}`)), - info: (text) => console.log(chalk.dim(` ○ ${text}`)) -}; +const { LOG } = require('../lib/ui'); async function checkCommand() { LOG.header('SYSTEM CHECK'); diff --git a/src/commands/csx.js b/src/commands/csx.js index 5825de6..2269429 100644 --- a/src/commands/csx.js +++ b/src/commands/csx.js @@ -4,34 +4,7 @@ const path = require('path'); const config = require('../lib/config'); const { getDomain } = require('../lib/vnextConfig'); const { processCsxFile, getGitChangedCsx, findAllCsx } = require('../lib/csx'); - -// Logging helpers -const LOG = { - separator: () => console.log(chalk.cyan('═'.repeat(60))), - subSeparator: () => console.log(chalk.cyan('─'.repeat(60))), - header: (text) => { - console.log(); - LOG.separator(); - console.log(chalk.cyan.bold(` ${text}`)); - LOG.separator(); - }, - success: (text) => console.log(chalk.green(` ✓ ${text}`)), - error: (text) => console.log(chalk.red(` ✗ ${text}`)), - warning: (text) => console.log(chalk.yellow(` ⚠ ${text}`)), - info: (text) => console.log(chalk.dim(` ○ ${text}`)), - component: (type, name, status, detail = '') => { - const typeLabel = chalk.cyan(`[${type}]`); - const nameLabel = chalk.white(name); - if (status === 'success') { - console.log(` ${typeLabel} ${chalk.green('✓')} ${nameLabel} ${chalk.dim(detail)}`); - } else if (status === 'error') { - console.log(` ${typeLabel} ${chalk.red('✗')} ${nameLabel}`); - if (detail) console.log(chalk.red(` └─ ${detail}`)); - } else if (status === 'skip') { - console.log(` ${typeLabel} ${chalk.dim('○')} ${nameLabel} ${chalk.dim(detail)}`); - } - } -}; +const { LOG } = require('../lib/ui'); async function csxCommand(options) { LOG.header('CSX UPDATE'); @@ -40,9 +13,7 @@ async function csxCommand(options) { // Check domain try { - const domain = getDomain(projectRoot); - console.log(chalk.dim(` Domain: ${domain}`)); - console.log(); + getDomain(projectRoot); } catch (error) { LOG.error(`Failed to read vnext.config.json: ${error.message}`); return; diff --git a/src/commands/reset.js b/src/commands/reset.js index 62a46c2..b56ae89 100644 --- a/src/commands/reset.js +++ b/src/commands/reset.js @@ -9,34 +9,7 @@ const { getDomain, getComponentTypes } = require('../lib/vnextConfig'); const { getJsonMetadata, findAllJson, detectComponentType } = require('../lib/workflow'); const { publishComponent, reinitializeSystem } = require('../lib/api'); const { getInstanceId, deleteWorkflow } = require('../lib/db'); - -// Logging helpers -const LOG = { - separator: () => console.log(chalk.cyan('═'.repeat(60))), - subSeparator: () => console.log(chalk.cyan('─'.repeat(60))), - header: (text) => { - console.log(); - LOG.separator(); - console.log(chalk.cyan.bold(` ${text}`)); - LOG.separator(); - }, - success: (text) => console.log(chalk.green(` ✓ ${text}`)), - error: (text) => console.log(chalk.red(` ✗ ${text}`)), - warning: (text) => console.log(chalk.yellow(` ⚠ ${text}`)), - info: (text) => console.log(chalk.dim(` ○ ${text}`)), - component: (type, name, status, detail = '') => { - const typeLabel = chalk.cyan(`[${type}]`); - const nameLabel = chalk.white(name); - if (status === 'success') { - console.log(` ${typeLabel} ${chalk.green('✓')} ${nameLabel} ${chalk.dim(detail)}`); - } else if (status === 'error') { - console.log(` ${typeLabel} ${chalk.red('✗')} ${nameLabel}`); - if (detail) console.log(chalk.red(` └─ ${detail}`)); - } else if (status === 'skip') { - console.log(` ${typeLabel} ${chalk.dim('○')} ${nameLabel} ${chalk.dim(detail)}`); - } - } -}; +const { LOG, printApiError, printErrorSummaryTable } = require('../lib/ui'); async function resetCommand(options) { LOG.header('COMPONENT RESET (Force Update)'); @@ -72,10 +45,6 @@ async function resetCommand(options) { domain: domain }; - console.log(chalk.dim(` Domain: ${domain}`)); - console.log(chalk.dim(` API: ${apiConfig.baseUrl}`)); - console.log(); - // Discover folders const spinner = ora(' Scanning folders...').start(); let discovered; @@ -210,9 +179,9 @@ async function resetCommand(options) { LOG.component(type, fileName, 'success', `→ ${action}`); componentStats[type].success++; } else { - LOG.component(type, fileName, 'error', result.error); + printApiError(result, type, fileName); componentStats[type].failed++; - errors.push({ type, file: fileName, error: result.error }); + errors.push({ type, file: fileName, error: result.error, statusCode: result.statusCode, apiError: result.apiError }); } } catch (error) { const errorMsg = error.message || 'Unknown error'; @@ -257,12 +226,7 @@ async function resetCommand(options) { if (errors.length > 0) { console.log(); LOG.subSeparator(); - console.log(chalk.red.bold('\n ERRORS:\n')); - - for (const err of errors) { - console.log(chalk.red(` [${err.type}] ${err.file}`)); - console.log(chalk.dim(` └─ ${err.error}`)); - } + printErrorSummaryTable(errors); } LOG.separator(); diff --git a/src/commands/sync.js b/src/commands/sync.js index fd858e1..fb8ca72 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -8,34 +8,7 @@ const { publishComponent, reinitializeSystem } = require('../lib/api'); const { getInstanceId, deleteWorkflow } = require('../lib/db'); const { getJsonMetadata, detectComponentType } = require('../lib/workflow'); const { processCsxFile, findAllCsx } = require('../lib/csx'); - -// Logging helpers -const LOG = { - separator: () => console.log(chalk.cyan('═'.repeat(60))), - subSeparator: () => console.log(chalk.cyan('─'.repeat(60))), - header: (text) => { - console.log(); - LOG.separator(); - console.log(chalk.cyan.bold(` ${text}`)); - LOG.separator(); - }, - success: (text) => console.log(chalk.green(` ✓ ${text}`)), - error: (text) => console.log(chalk.red(` ✗ ${text}`)), - warning: (text) => console.log(chalk.yellow(` ⚠ ${text}`)), - info: (text) => console.log(chalk.dim(` ○ ${text}`)), - component: (type, name, status, detail = '') => { - const typeLabel = chalk.cyan(`[${type}]`); - const nameLabel = chalk.white(name); - if (status === 'success') { - console.log(` ${typeLabel} ${chalk.green('✓')} ${nameLabel} ${chalk.dim(detail)}`); - } else if (status === 'error') { - console.log(` ${typeLabel} ${chalk.red('✗')} ${nameLabel}`); - if (detail) console.log(chalk.red(` └─ ${detail}`)); - } else if (status === 'skip') { - console.log(` ${typeLabel} ${chalk.dim('○')} ${nameLabel} ${chalk.dim(detail)}`); - } - } -}; +const { LOG, printApiError, printErrorSummaryTable } = require('../lib/ui'); async function syncCommand() { LOG.header('SYSTEM SYNC - Add Missing Components'); @@ -77,10 +50,6 @@ async function syncCommand() { domain: domain }; - console.log(chalk.dim(` Domain: ${domain}`)); - console.log(chalk.dim(` API: ${apiConfig.baseUrl}`)); - console.log(); - // Discover folders const discoverSpinner = ora('Scanning folders...').start(); let discovered; @@ -183,9 +152,9 @@ async function syncCommand() { LOG.component(type, fileName, 'success', '→ published'); componentStats[type].success++; } else { - LOG.component(type, fileName, 'error', result.error); + printApiError(result, type, fileName); componentStats[type].failed++; - errors.push({ type, file: fileName, error: result.error }); + errors.push({ type, file: fileName, error: result.error, statusCode: result.statusCode, apiError: result.apiError }); } } catch (error) { const errorMsg = error.message || 'Unknown error'; @@ -239,20 +208,14 @@ async function syncCommand() { } // Errors - if (errors.length > 0 || csxResults.errors.length > 0) { + const allErrors = [ + ...errors, + ...csxResults.errors.map(e => ({ type: 'CSX', file: e.file, error: e.error })) + ]; + if (allErrors.length > 0) { console.log(); LOG.subSeparator(); - console.log(chalk.red.bold('\n ERRORS:\n')); - - for (const err of errors) { - console.log(chalk.red(` [${err.type}] ${err.file}`)); - console.log(chalk.dim(` └─ ${err.error}`)); - } - - for (const err of csxResults.errors) { - console.log(chalk.red(` [CSX] ${err.file}`)); - console.log(chalk.dim(` └─ ${err.error}`)); - } + printErrorSummaryTable(allErrors); } LOG.separator(); diff --git a/src/commands/update.js b/src/commands/update.js index 6baf327..51b940c 100644 --- a/src/commands/update.js +++ b/src/commands/update.js @@ -9,34 +9,7 @@ const { publishComponent, reinitializeSystem } = require('../lib/api'); const { getInstanceId, deleteWorkflow } = require('../lib/db'); const { getJsonMetadata, getGitChangedJson, findAllJson, detectComponentType } = require('../lib/workflow'); const { processCsxFile, getGitChangedCsx, findAllCsx } = require('../lib/csx'); - -// Logging helpers -const LOG = { - separator: () => console.log(chalk.cyan('═'.repeat(60))), - subSeparator: () => console.log(chalk.cyan('─'.repeat(60))), - header: (text) => { - console.log(); - LOG.separator(); - console.log(chalk.cyan.bold(` ${text}`)); - LOG.separator(); - }, - success: (text) => console.log(chalk.green(` ✓ ${text}`)), - error: (text) => console.log(chalk.red(` ✗ ${text}`)), - warning: (text) => console.log(chalk.yellow(` ⚠ ${text}`)), - info: (text) => console.log(chalk.dim(` ○ ${text}`)), - component: (type, name, status, detail = '') => { - const typeLabel = chalk.cyan(`[${type}]`); - const nameLabel = chalk.white(name); - if (status === 'success') { - console.log(` ${typeLabel} ${chalk.green('✓')} ${nameLabel} ${chalk.dim(detail)}`); - } else if (status === 'error') { - console.log(` ${typeLabel} ${chalk.red('✗')} ${nameLabel}`); - if (detail) console.log(chalk.red(` └─ ${detail}`)); - } else if (status === 'skip') { - console.log(` ${typeLabel} ${chalk.dim('○')} ${nameLabel} ${chalk.dim(detail)}`); - } - } -}; +const { LOG, printApiError, printErrorSummaryTable } = require('../lib/ui'); async function updateCommand(options) { LOG.header('COMPONENT UPDATE'); @@ -72,10 +45,6 @@ async function updateCommand(options) { domain: domain }; - console.log(chalk.dim(` Domain: ${domain}`)); - console.log(chalk.dim(` API: ${apiConfig.baseUrl}`)); - console.log(); - // FIRST: Update changed CSX files let csxFiles = []; const csxResults = { success: 0, failed: 0, errors: [] }; @@ -239,9 +208,9 @@ async function updateCommand(options) { } componentStats[type].success++; } else { - LOG.component(type, fileName, 'error', result.error); + printApiError(result, type, fileName); componentStats[type].failed++; - errors.push({ type, file: fileName, error: result.error }); + errors.push({ type, file: fileName, error: result.error, statusCode: result.statusCode, apiError: result.apiError }); } } catch (error) { const errorMsg = error.message || 'Unknown error'; @@ -291,20 +260,14 @@ async function updateCommand(options) { } // Errors - if (errors.length > 0 || csxResults.errors.length > 0) { + const allErrors = [ + ...errors, + ...csxResults.errors.map(e => ({ type: 'CSX', file: e.file, error: e.error })) + ]; + if (allErrors.length > 0) { console.log(); LOG.subSeparator(); - console.log(chalk.red.bold('\n ERRORS:\n')); - - for (const err of errors) { - console.log(chalk.red(` [${err.type}] ${err.file}`)); - console.log(chalk.dim(` └─ ${err.error}`)); - } - - for (const err of csxResults.errors) { - console.log(chalk.red(` [CSX] ${err.file}`)); - console.log(chalk.dim(` └─ ${err.error}`)); - } + printErrorSummaryTable(allErrors); } LOG.separator(); diff --git a/src/lib/api.js b/src/lib/api.js index fdaf374..ac8e0be 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -39,30 +39,41 @@ async function publishComponent(baseUrl, componentData) { data: response.data }; } catch (error) { - // Extract API error details let errorMessage = error.message; - let errorDetails = null; - + let apiError = null; + if (error.response) { const responseData = error.response.data; - + if (typeof responseData === 'string') { errorMessage = responseData; - } else if (responseData?.error?.message) { - errorMessage = responseData.error.message; - errorDetails = responseData.error; - } else if (responseData?.message) { - errorMessage = responseData.message; - } else if (responseData) { - errorMessage = JSON.stringify(responseData); + } else if (responseData && typeof responseData === 'object') { + // RFC 7807 Problem Details (detail + status fields) + if (responseData.detail) { + errorMessage = responseData.detail; + apiError = { + title: responseData.title, + detail: responseData.detail, + errors: responseData.errors || null, + errorCode: responseData.errorCode || null, + traceId: responseData.traceId || null, + type: responseData.type || null + }; + } else if (responseData.error?.message) { + errorMessage = responseData.error.message; + } else if (responseData.message) { + errorMessage = responseData.message; + } else { + errorMessage = JSON.stringify(responseData); + } } } - + return { success: false, error: errorMessage, - errorDetails: errorDetails, - statusCode: error.response?.status + statusCode: error.response?.status, + apiError }; } } diff --git a/src/lib/config.js b/src/lib/config.js index b2291bd..f15edb4 100644 --- a/src/lib/config.js +++ b/src/lib/config.js @@ -1,4 +1,6 @@ const Conf = require('conf'); +const fs = require('fs'); +const path = require('path'); // Default config values for a domain const DEFAULT_DOMAIN_CONFIG = { @@ -224,6 +226,43 @@ function removeDomain(name) { } } +/** + * Resolves the active domain from vnext.config.json in the given project root. + * If a matching CLI domain profile exists, silently switches to it. + * @param {string} projectRoot - Project root folder (typically cwd) + * @returns {Object} Resolution result with { resolved, switched, domain, previous, reason } + */ +function resolveWorkspaceDomain(projectRoot) { + try { + const configPath = path.join(projectRoot, 'vnext.config.json'); + if (!fs.existsSync(configPath)) { + return { resolved: false, reason: 'no-config-file' }; + } + + const content = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const domain = content.domain; + if (!domain) { + return { resolved: false, reason: 'no-domain-field' }; + } + + const domains = config.get('DOMAINS') || []; + const match = domains.find(d => d.DOMAIN_NAME === domain); + if (!match) { + return { resolved: false, reason: 'no-matching-profile', domain }; + } + + const currentActive = config.get('ACTIVE_DOMAIN'); + if (currentActive === domain) { + return { resolved: true, switched: false, domain }; + } + + config.set('ACTIVE_DOMAIN', domain); + return { resolved: true, switched: true, domain, previous: currentActive }; + } catch { + return { resolved: false, reason: 'error' }; + } +} + module.exports = { get, set, @@ -235,5 +274,6 @@ module.exports = { listDomains, removeDomain, getActiveDomainConfig, + resolveWorkspaceDomain, DEFAULT_DOMAIN_CONFIG }; diff --git a/src/lib/ui.js b/src/lib/ui.js new file mode 100644 index 0000000..5e11576 --- /dev/null +++ b/src/lib/ui.js @@ -0,0 +1,181 @@ +const chalk = require('chalk'); +const config = require('./config'); + +const LOG = { + separator: () => console.log(chalk.cyan('═'.repeat(60))), + subSeparator: () => console.log(chalk.cyan('─'.repeat(60))), + header: (text) => { + console.log(); + LOG.separator(); + console.log(chalk.cyan.bold(` ${text}`)); + LOG.separator(); + }, + success: (text) => console.log(chalk.green(` ✓ ${text}`)), + error: (text) => console.log(chalk.red(` ✗ ${text}`)), + warning: (text) => console.log(chalk.yellow(` ⚠ ${text}`)), + info: (text) => console.log(chalk.dim(` ○ ${text}`)), + component: (type, name, status, detail = '') => { + const typeLabel = chalk.cyan(`[${type}]`); + const nameLabel = chalk.white(name); + if (status === 'success') { + console.log(` ${typeLabel} ${chalk.green('✓')} ${nameLabel} ${chalk.dim(detail)}`); + } else if (status === 'error') { + console.log(` ${typeLabel} ${chalk.red('✗')} ${nameLabel}`); + if (detail) console.log(chalk.red(` └─ ${detail}`)); + } else if (status === 'skip') { + console.log(` ${typeLabel} ${chalk.dim('○')} ${nameLabel} ${chalk.dim(detail)}`); + } + } +}; + +/** + * Prints a structured, colored error block for a failed publish result. + * Handles both RFC 7807 (apiError) and plain error strings. + */ +function printApiError(result, componentType, fileName) { + const typeLabel = chalk.cyan(`[${componentType}]`); + console.log(` ${typeLabel} ${chalk.red('✗')} ${chalk.white(fileName)}`); + + const api = result.apiError; + if (api) { + const statusLine = result.statusCode + ? `HTTP ${result.statusCode} ${api.title || ''}` + : api.title || 'Error'; + console.log(chalk.red(` ├─ ${chalk.red.bold(statusLine.trim())}`)); + + if (api.detail) { + console.log(chalk.red(` ├─ Detail: ${api.detail}`)); + } + if (api.errorCode) { + console.log(chalk.red(` ├─ Code: ${chalk.yellow(api.errorCode)}`)); + } + + if (api.errors && typeof api.errors === 'object' && Object.keys(api.errors).length > 0) { + console.log(chalk.red(' ├─ Errors:')); + const fields = Object.entries(api.errors); + for (const [fieldPath, messages] of fields) { + console.log(chalk.yellow(` │ ${fieldPath}`)); + const msgs = Array.isArray(messages) ? messages : [messages]; + for (const msg of msgs) { + console.log(chalk.white(` │ - ${msg}`)); + } + } + } + + if (api.traceId) { + console.log(chalk.dim(` └─ TraceId: ${api.traceId}`)); + } else { + // close the tree + console.log(chalk.red(' └─')); + } + } else { + if (result.statusCode) { + console.log(chalk.red(` ├─ ${chalk.red.bold(`HTTP ${result.statusCode}`)}`)); + } + if (result.error) { + console.log(chalk.red(` └─ ${result.error}`)); + } + } +} + +/** + * Prints a two-layer summary table for batch operation errors. + * Each row shows component/file/HTTP/errorCode/detail, and if validation + * errors exist they are expanded below the row. + */ +function printErrorSummaryTable(errors) { + if (!errors || errors.length === 0) return; + + const COL = { idx: 3, type: 16, file: 26, http: 5, code: 16, detail: 36 }; + const totalWidth = COL.idx + COL.type + COL.file + COL.http + COL.code + COL.detail + 15; + + const pad = (str, len) => String(str || '').padEnd(len); + const divider = () => console.log(chalk.dim(` ${'─'.repeat(totalWidth)}`)); + + console.log(chalk.red.bold(`\n ERRORS (${errors.length})\n`)); + + // Header + console.log( + chalk.dim(' ') + + chalk.white.bold(pad('#', COL.idx)) + chalk.dim(' │ ') + + chalk.white.bold(pad('Component', COL.type)) + chalk.dim(' │ ') + + chalk.white.bold(pad('File', COL.file)) + chalk.dim(' │ ') + + chalk.white.bold(pad('HTTP', COL.http)) + chalk.dim(' │ ') + + chalk.white.bold(pad('Error Code', COL.code)) + chalk.dim(' │ ') + + chalk.white.bold('Detail') + ); + divider(); + + for (let i = 0; i < errors.length; i++) { + const err = errors[i]; + const api = err.apiError; + const statusCode = err.statusCode || ''; + const errorCode = api?.errorCode || ''; + const detail = (api?.detail || err.error || '').substring(0, COL.detail); + + console.log( + chalk.dim(' ') + + chalk.dim(pad(i + 1, COL.idx)) + chalk.dim(' │ ') + + chalk.cyan(pad(err.type, COL.type)) + chalk.dim(' │ ') + + chalk.white(pad(err.file, COL.file)) + chalk.dim(' │ ') + + chalk.red.bold(pad(statusCode, COL.http)) + chalk.dim(' │ ') + + chalk.yellow(pad(errorCode, COL.code)) + chalk.dim(' │ ') + + chalk.red(detail) + ); + + // Expand validation errors below the row + if (api?.errors && typeof api.errors === 'object') { + const fields = Object.entries(api.errors); + if (fields.length > 0) { + console.log( + chalk.dim(' ') + + pad('', COL.idx) + chalk.dim(' │ ') + + chalk.white.bold(' Validation Errors:') + ); + for (const [fieldPath, messages] of fields) { + console.log( + chalk.dim(' ') + + pad('', COL.idx) + chalk.dim(' │ ') + + chalk.yellow(` ${fieldPath}`) + ); + const msgs = Array.isArray(messages) ? messages : [messages]; + for (const msg of msgs) { + console.log( + chalk.dim(' ') + + pad('', COL.idx) + chalk.dim(' │ ') + + chalk.white(` - ${msg}`) + ); + } + } + } + } + + divider(); + } +} + +/** + * Prints a boxed banner showing the active domain and API URL. + * Called from the preAction hook before every command. + */ +function printActiveDomainBanner() { + const domain = config.get('ACTIVE_DOMAIN') || 'default'; + const apiUrl = config.get('API_BASE_URL') || '-'; + + const domainLine = `Domain: ${domain}`; + const apiLine = `API: ${apiUrl}`; + const innerWidth = Math.max(domainLine.length, apiLine.length) + 4; + + console.log(); + console.log(chalk.cyan(` ┌${'─'.repeat(innerWidth)}┐`)); + console.log(chalk.cyan(' │') + ` ${chalk.white.bold(domainLine)}${' '.repeat(innerWidth - domainLine.length - 2)}` + chalk.cyan('│')); + console.log(chalk.cyan(' │') + ` ${chalk.dim(apiLine)}${' '.repeat(innerWidth - apiLine.length - 2)}` + chalk.cyan('│')); + console.log(chalk.cyan(` └${'─'.repeat(innerWidth)}┘`)); +} + +module.exports = { + LOG, + printApiError, + printErrorSummaryTable, + printActiveDomainBanner +};