diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4841cb7faa..c43cf6aabc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -214,10 +214,20 @@ jobs: # Try a basic curl to see if service responds curl -v --connect-timeout 5 "http://localhost:${{ steps.test-vars.outputs.port }}/" || true + - name: Patch Adobe CF serialfilter.txt for Oracle JDBC + if: ${{ (matrix.cfengine == 'adobe2023' || matrix.cfengine == 'adobe2025') && matrix.dbengine == 'oracle' }} + run: | + docker exec wheels-${{ matrix.cfengine }}-1 sh -c "echo ';oracle.sql.converter.**;oracle.sql.**;oracle.jdbc.**' >> /wheels-test-suite/.engine/${{ matrix.cfengine }}/WEB-INF/cfusion/lib/serialfilter.txt" + + - name: Restart CF Engine + if: ${{ (matrix.cfengine == 'adobe2023' || matrix.cfengine == 'adobe2025') && matrix.dbengine == 'oracle' }} + run: | + docker restart wheels-${{ matrix.cfengine }}-1 + - name: Wait for Oracle to be ready if: ${{ matrix.dbengine == 'oracle' }} run: sleep 120 - + - name: Running onServerInstall Script for Adobe2021, Adobe2023, and Adobe2025 if: ${{ matrix.cfengine == 'adobe2021' || matrix.cfengine == 'adobe2023' || matrix.cfengine == 'adobe2025' }} run: | diff --git a/cli/src/commands/wheels/config/diff.cfc b/cli/src/commands/wheels/config/diff.cfc index 21d93ebe22..9af6ce8488 100644 --- a/cli/src/commands/wheels/config/diff.cfc +++ b/cli/src/commands/wheels/config/diff.cfc @@ -252,7 +252,7 @@ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { // Display environment variable differences if (arguments.compareEnv && StructKeyExists(arguments.differences, "env")) { if (arguments.compareSettings && StructKeyExists(arguments.differences, "settings")) { - detailOutput.separator(); + detailOutput.line(); } detailOutput.subHeader("ENVIRONMENT VARIABLES", 50); displayDifferenceSection(arguments.differences.env, arguments.env1, arguments.env2, arguments.changesOnly, "env"); @@ -307,7 +307,7 @@ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { // Overall summary local.grandTotal = local.totalIdentical + local.totalDifferent + local.totalUnique; if (local.grandTotal > 0) { - detailOutput.separator(); + detailOutput.line(); detailOutput.output("Overall:"); detailOutput.metric("Total configurations", local.grandTotal); detailOutput.metric("Identical", local.totalIdentical); diff --git a/cli/src/commands/wheels/dbmigrate/latest.cfc b/cli/src/commands/wheels/dbmigrate/latest.cfc index 9df0df6371..23dbbb5fed 100644 --- a/cli/src/commands/wheels/dbmigrate/latest.cfc +++ b/cli/src/commands/wheels/dbmigrate/latest.cfc @@ -50,7 +50,7 @@ component aliases='wheels db latest,wheels db migrate' extends="../base" { } // Add a separator before the info command output - detailOutput.separator(); + detailOutput.line(); command('wheels dbmigrate info').run(); } diff --git a/cli/src/commands/wheels/dbmigrate/up.cfc b/cli/src/commands/wheels/dbmigrate/up.cfc index 82ab7aed83..89e0814799 100644 --- a/cli/src/commands/wheels/dbmigrate/up.cfc +++ b/cli/src/commands/wheels/dbmigrate/up.cfc @@ -41,7 +41,7 @@ component aliases='wheels db up' extends="../base" { } else { detailOutput.statusWarning("No more versions to go to?"); } - detailOutput.separator(); + detailOutput.line(); command('wheels dbmigrate info').run(); } diff --git a/cli/src/commands/wheels/deps.cfc b/cli/src/commands/wheels/deps.cfc index d58369ec99..ee19d1dbbf 100644 --- a/cli/src/commands/wheels/deps.cfc +++ b/cli/src/commands/wheels/deps.cfc @@ -10,6 +10,8 @@ * {code} */ component extends="base" { + + property name="detailOutput" inject="DetailOutputService@wheels-cli"; /** * @action Action to perform (list, install, update, remove, report) @@ -32,9 +34,7 @@ component extends="base" { ); // Welcome message - print.line(); - print.boldMagentaLine("Wheels Dependency Manager"); - print.line(); + detailOutput.header("Wheels Dependency Manager"); // Handle different actions switch (lCase(arguments.action)) { @@ -43,19 +43,22 @@ component extends="base" { break; case "install": if (len(trim(arguments.name)) == 0) { - error("Name parameter is required for install action"); + detailOutput.error("Name parameter is required for install action"); + return; } installDependency(arguments.name, arguments.version, arguments.dev); break; case "update": if (len(trim(arguments.name)) == 0) { - error("Name parameter is required for update action"); + detailOutput.error("Name parameter is required for update action"); + return; } updateDependency(arguments.name); break; case "remove": if (len(trim(arguments.name)) == 0) { - error("Name parameter is required for remove action"); + detailOutput.error("Name parameter is required for remove action"); + return; } removeDependency(arguments.name); break; @@ -64,21 +67,21 @@ component extends="base" { break; } - print.line(); + detailOutput.line(); } /** * List installed dependencies from box.json */ private void function listDependencies() { - print.line("Retrieving dependencies from box.json..."); + detailOutput.output("Retrieving dependencies from box.json..."); try { local.boxJsonPath = fileSystemUtil.resolvePath("box.json"); if (!fileExists(local.boxJsonPath)) { - print.boldRedLine("No box.json file found"); - print.line("Run 'box init' to create a box.json file"); + detailOutput.error("No box.json file found"); + detailOutput.output("Run 'box init' to create a box.json file"); return; } @@ -87,7 +90,7 @@ component extends="base" { // List regular dependencies if (structKeyExists(local.boxJson, "dependencies") && structCount(local.boxJson.dependencies) > 0) { - print.boldYellowLine("Dependencies:"); + detailOutput.subHeader("Dependencies"); local.depsTable = []; for (local.dep in local.boxJson.dependencies) { @@ -98,18 +101,16 @@ component extends="base" { arrayAppend(local.depsTable, [local.dep, local.version, "Production", local.status]); } - // print.table(local.depsTable, ["Package", "Version", "Type", "Status"]); for (local.row in local.depsTable) { - print.line(" " & local.row[1] & " @ " & local.row[2] & " (" & local.row[3] & ") - " & local.row[4]); + detailOutput.output(" " & local.row[1] & " @ " & local.row[2] & " (" & local.row[3] & ") - " & local.row[4], true); } local.hasDeps = true; } // List dev dependencies if (structKeyExists(local.boxJson, "devDependencies") && structCount(local.boxJson.devDependencies) > 0) { - if (local.hasDeps) print.line(); - - print.boldYellowLine("Dev Dependencies:"); + detailOutput.line(); + detailOutput.subHeader("Dev Dependencies"); local.devDepsTable = []; for (local.dep in local.boxJson.devDependencies) { @@ -120,20 +121,19 @@ component extends="base" { arrayAppend(local.devDepsTable, [local.dep, local.version, "Development", local.status]); } - // print.table(local.devDepsTable, ["Package", "Version", "Type", "Status"]); for (local.row in local.devDepsTable) { - print.line(" " & local.row[1] & " @ " & local.row[2] & " (" & local.row[3] & ") - " & local.row[4]); + detailOutput.output(" " & local.row[1] & " @ " & local.row[2] & " (" & local.row[3] & ") - " & local.row[4], true); } local.hasDeps = true; } if (!local.hasDeps) { - print.yellowLine("No dependencies found in box.json"); - print.line("Use 'wheels deps install ' to add dependencies"); + detailOutput.statusWarning("No dependencies found in box.json"); + detailOutput.output("Use 'wheels deps install ' to add dependencies"); } } catch (any e) { - print.boldRedLine("Error reading dependencies: #e.message#"); + detailOutput.statusFailed("Error reading dependencies: #e.message#"); } } @@ -145,7 +145,7 @@ component extends="base" { string version="", boolean dev=false ) { - print.line("Installing #arguments.name#..."); + detailOutput.output("Installing #arguments.name#..."); try { // Prepare install command @@ -160,10 +160,10 @@ component extends="base" { } // Run CommandBox install - print.line("Running: box #local.installCmd#"); + detailOutput.output("Running: box #local.installCmd#"); command(local.installCmd).run(); - print.boldGreenLine("#arguments.name# installed successfully"); + detailOutput.statusSuccess("#arguments.name# installed successfully"); // Show post-install information local.boxJsonPath = fileSystemUtil.resolvePath("box.json"); @@ -173,13 +173,14 @@ component extends="base" { if (structKeyExists(local.boxJson, local.depType) && structKeyExists(local.boxJson[local.depType], arguments.name)) { - print.yellowLine("Added to #local.depType#: #arguments.name# @ #local.boxJson[local.depType][arguments.name]#"); + detailOutput.statusInfo("Added to #local.depType#: #arguments.name# @ #local.boxJson[local.depType][arguments.name]#"); } } } catch (any e) { - print.boldRedLine("Failed to install #arguments.name#"); - print.redLine("Error: #e.message#"); + detailOutput.statusFailed("Failed to install #arguments.name#"); + detailOutput.error("Error: #e.message#"); + return; } } @@ -187,13 +188,13 @@ component extends="base" { * Update a dependency */ private void function updateDependency(required string name) { - print.line("Updating #arguments.name#..."); + detailOutput.output("Updating #arguments.name#..."); try { // Check if dependency exists in box.json local.boxJsonPath = fileSystemUtil.resolvePath("box.json"); if (!fileExists(local.boxJsonPath)) { - print.boldRedLine("No box.json file found"); + detailOutput.error("No box.json file found"); return; } @@ -217,23 +218,23 @@ component extends="base" { } if (!local.found) { - print.boldRedLine("Dependency '#arguments.name#' not found in box.json"); - print.line("Use 'wheels deps list' to see available dependencies"); + detailOutput.statusFailed("Dependency '#arguments.name#' not found in box.json"); + detailOutput.statusInfo("Use 'wheels deps list' to see available dependencies"); return; } // Update the dependency - print.yellowLine("Current version: #local.currentVersion#"); + detailOutput.statusInfo("Current version: #local.currentVersion#"); local.updateCmd = "update #arguments.name#"; if (local.isDev) { local.updateCmd &= " --dev"; } - print.line("Running: box #local.updateCmd#"); + detailOutput.output("Running: box #local.updateCmd#"); command(local.updateCmd).run(); - print.boldGreenLine("#arguments.name# updated successfully"); + detailOutput.statusSuccess("#arguments.name# updated successfully"); // Show new version local.boxJson = deserializeJSON(fileRead(local.boxJsonPath)); @@ -243,13 +244,14 @@ component extends="base" { structKeyExists(local.boxJson[local.depType], arguments.name)) { local.newVersion = local.boxJson[local.depType][arguments.name]; if (local.currentVersion != local.newVersion) { - print.yellowLine("Updated from #local.currentVersion# to #local.newVersion#"); + detailOutput.statusInfo("Updated from #local.currentVersion# to #local.newVersion#"); } } } catch (any e) { - print.boldRedLine("Failed to update #arguments.name#"); - print.redLine("Error: #e.message#"); + detailOutput.statusFailed("Failed to update #arguments.name#"); + detailOutput.error("Error: #e.message#"); + return; } } @@ -258,17 +260,17 @@ component extends="base" { */ private void function removeDependency(required string name) { if (!confirm("Are you sure you want to remove #arguments.name#? [y/n]")) { - print.line("Aborted"); + detailOutput.output("Aborted"); return; } - print.line("Removing #arguments.name#..."); + detailOutput.output("Removing #arguments.name#..."); try { // Check if dependency exists in box.json local.boxJsonPath = fileSystemUtil.resolvePath("box.json"); if (!fileExists(local.boxJsonPath)) { - print.boldRedLine("No box.json file found"); + detailOutput.error("No box.json file found"); return; } @@ -290,28 +292,29 @@ component extends="base" { } if (!local.found) { - print.boldRedLine("Dependency '#arguments.name#' not found in box.json"); - print.line("Use 'wheels deps list' to see available dependencies"); + detailOutput.statusFailed("Dependency '#arguments.name#' not found in box.json"); + detailOutput.output("Use 'wheels deps list' to see available dependencies"); return; } // Remove the dependency local.uninstallCmd = "uninstall #arguments.name#"; - print.line("Running: box #local.uninstallCmd#"); + detailOutput.output("Running: box #local.uninstallCmd#"); command(local.uninstallCmd).run(); - print.boldGreenLine("#arguments.name# removed successfully"); + detailOutput.statusSuccess("#arguments.name# removed successfully"); if (local.isDev) { - print.yellowLine("Removed from devDependencies"); + detailOutput.statusInfo("Removed from devDependencies"); } else { - print.yellowLine("Removed from dependencies"); + detailOutput.statusInfo("Removed from dependencies"); } } catch (any e) { - print.boldRedLine("Failed to remove #arguments.name#"); - print.redLine("Error: #e.message#"); + detailOutput.statusFailed("Failed to remove #arguments.name#"); + detailOutput.error("Error: #e.message#"); + return; } } @@ -319,7 +322,7 @@ component extends="base" { * Generate dependency report */ private void function generateDependencyReport() { - print.line("Generating dependency report..."); + detailOutput.output("Generating dependency report..."); try { local.report = { @@ -410,22 +413,21 @@ component extends="base" { } // Display report - print.boldYellowLine("Dependency Report:"); - print.line(); - print.yellowLine("Generated: #dateTimeFormat(local.report.timestamp, 'yyyy-mm-dd HH:nn:ss')#"); + detailOutput.header("Dependency Report"); + detailOutput.metric("Generated", dateTimeFormat(local.report.timestamp, 'yyyy-mm-dd HH:nn:ss')); if (len(local.report.project)) { - print.yellowLine("Project: #local.report.project#"); + detailOutput.metric("Project", local.report.project); } if (structKeyExists(local.report, "projectVersion")) { - print.yellowLine("Project Version: #local.report.projectVersion#"); + detailOutput.metric("Project Version", local.report.projectVersion); } - print.yellowLine("Wheels Version: #local.report.wheelsVersion#"); - print.yellowLine("CFML Engine: #local.report.cfmlEngine#"); - print.line(); + detailOutput.metric("Wheels Version", local.report.wheelsVersion); + detailOutput.metric("CFML Engine", local.report.cfmlEngine); + detailOutput.line(); // Display dependencies if (structCount(local.report.dependencies) > 0) { - print.yellowLine("Dependencies:"); + detailOutput.subHeader("Dependencies"); local.depsTable = []; for (local.dep in local.report.dependencies) { local.installed = checkIfInstalled(local.dep); @@ -435,16 +437,15 @@ component extends="base" { local.installed ? "Yes" : "No" ]); } - // print.table(local.depsTable, ["Package", "Version", "Installed"]); for (local.row in local.depsTable) { - print.line(" " & local.row[1] & " @ " & local.row[2] & " - Installed: " & local.row[3]); + detailOutput.output(" " & local.row[1] & " @ " & local.row[2] & " - Installed: " & local.row[3], true); } - print.line(); + detailOutput.line(); } // Display dev dependencies if (structCount(local.report.devDependencies) > 0) { - print.yellowLine("Dev Dependencies:"); + detailOutput.subHeader("Dev Dependencies"); local.devDepsTable = []; for (local.dep in local.report.devDependencies) { local.installed = checkIfInstalled(local.dep); @@ -454,49 +455,47 @@ component extends="base" { local.installed ? "Yes" : "No" ]); } - // print.table(local.devDepsTable, ["Package", "Version", "Installed"]); for (local.row in local.devDepsTable) { - print.line(" " & local.row[1] & " @ " & local.row[2] & " - Installed: " & local.row[3]); + detailOutput.output(" " & local.row[1] & " @ " & local.row[2] & " - Installed: " & local.row[3], true); } - print.line(); + detailOutput.line(); } // Display installed modules if (arrayLen(local.report.installedModules) > 0) { - print.yellowLine("Installed Modules:"); + detailOutput.subHeader("Installed Modules"); local.modulesTable = []; for (local.module in local.report.installedModules) { local.version = structKeyExists(local.module, "version") ? local.module.version : "Unknown"; arrayAppend(local.modulesTable, [local.module.name, local.version]); } - // print.table(local.modulesTable, ["Module", "Version"]); for (local.row in local.modulesTable) { - print.line(" " & local.row[1] & " (" & local.row[2] & ")"); + detailOutput.output(" " & local.row[1] & " (" & local.row[2] & ")", true); } - print.line(); + detailOutput.line(); } // Check for outdated packages - print.yellowLine("Checking for outdated packages..."); + detailOutput.output("Checking for outdated packages..."); try { local.outdatedResult = command("outdated").run(returnOutput=true); if (len(trim(local.outdatedResult))) { - print.line(local.outdatedResult); + detailOutput.code(local.outdatedResult); } else { - print.greenLine("All packages are up to date!"); + detailOutput.statusSuccess("All packages are up to date!"); } } catch (any e) { - print.line("Unable to check for outdated packages"); + detailOutput.statusWarning("Unable to check for outdated packages"); } // Save report to file local.reportPath = fileSystemUtil.resolvePath("dependency-report-#dateTimeFormat(now(), 'yyyymmdd-HHnnss')#.json"); fileWrite(local.reportPath, serializeJSON(local.report, true)); - print.line(); - print.greenLine("Full report exported to: #local.reportPath#"); + detailOutput.line(); + detailOutput.statusSuccess("Full report exported to: #local.reportPath#"); } catch (any e) { - print.boldRedLine("Error generating report: #e.message#"); + detailOutput.statusFailed("Error generating report: #e.message#"); } } diff --git a/cli/src/commands/wheels/destroy.cfc b/cli/src/commands/wheels/destroy.cfc index 17a4d07dca..e6b868b556 100644 --- a/cli/src/commands/wheels/destroy.cfc +++ b/cli/src/commands/wheels/destroy.cfc @@ -11,6 +11,8 @@ **/ component aliases='wheels d' extends="base" { + property name="detailOutput" inject="DetailOutputService@wheels-cli"; + /** * @type.hint Type of component to destroy (resource, controller, model, view). Default is resource * @name.hint Name of object to destroy @@ -22,14 +24,14 @@ component aliases='wheels d' extends="base" { // Validate that name is not empty arguments.name = trim(arguments.name); if (len(arguments.name) == 0) { - print.redBoldLine("Error: Name argument cannot be empty.") - .line("Please provide a name for the component to destroy.") + detailOutput.error("Name argument cannot be empty.") + .output("Please provide a name for the component to destroy.") .line() - .line("Examples:") - .line(" wheels destroy User") - .line(" wheels destroy controller Products") - .line(" wheels destroy model Product") - .line(" wheels destroy view products/index") + .output("Examples:") + .output("wheels destroy User", true) + .output("wheels destroy controller Products", true) + .output("wheels destroy model Product", true) + .output("wheels destroy view products/index", true) .line(); return; } @@ -39,8 +41,8 @@ component aliases='wheels d' extends="base" { // Validate that type is not empty (though it has a default) if (len(arguments.type) == 0) { - print.redBoldLine("Error: Type argument cannot be empty.") - .line("Valid types: resource, controller, model, view") + detailOutput.error("Type argument cannot be empty.") + .output("Valid types: resource, controller, model, view", true) .line(); return; } @@ -77,22 +79,19 @@ component aliases='wheels d' extends="base" { var routeFile = fileSystemUtil.resolvePath("config/routes.cfm"); var resourceName = '.resources("' & obj.objectNamePlural & '")'; - print.redBoldLine("================================================") - .redBoldLine("= Watch Out! =") - .redBoldLine("================================================") - .line("This will delete the associated database table '#obj.objectNamePlural#', and") - .line("the following files and directories:") - .line() - .line("#modelFile#") - .line("#controllerFile#") - .line("#viewFolder#") - .line("#testmodelFile#") - .line("#testcontrollerFile#") - .line("#testviewFolder#") - .line("#routeFile#") - .line("#resourceName#") + detailOutput.header("Watch Out!") + .output("This will delete the associated database table '#obj.objectNamePlural#', and") + .output("the following files and directories:") .line() - .toConsole(); + .output(modelFile, true) + .output(controllerFile, true) + .output(viewFolder, true) + .output(testmodelFile, true) + .output(testcontrollerFile, true) + .output(testviewFolder, true) + .output(routeFile, true) + .output(resourceName, true) + .line(); if(confirm("Are you sure? [y/n]")){ command('delete').params(path=modelFile, force=true).run(); @@ -109,10 +108,13 @@ component aliases='wheels d' extends="base" { file action='write' file='#routeFile#' mode ='777' output='#trim(routeContent)#'; //drop the table - print.greenline( "Migrating DB" ).toConsole(); + detailOutput.statusInfo("Migrating DB"); command('wheels dbmigrate remove table').params(name=obj.objectNamePlural).run(); command('wheels dbmigrate latest').run(); - print.line(); + detailOutput.line(); + }else{ + detailOutput.getPrint().redLine("Resource destruction cancelled.").toConsole(); + return; } } @@ -124,28 +126,29 @@ component aliases='wheels d' extends="base" { var controllerFile = fileSystemUtil.resolvePath("app/controllers/#obj.objectNamePluralC#.cfc"); var testcontrollerFile = fileSystemUtil.resolvePath("tests/specs/controllers/#obj.objectNamePluralC#ControllerSpec.cfc"); - print.redBoldLine("================================================") - .redBoldLine("= Watch Out! =") - .redBoldLine("================================================") - .line("This will delete the following files:") + detailOutput.header("Watch Out!") + .output("This will delete the following files:") .line() - .line("#controllerFile#") - .line("#testcontrollerFile#") + .output(controllerFile, true) + .output(testcontrollerFile, true) .line(); if(confirm("Are you sure? [y/n]")){ if(fileExists(controllerFile)) { command('delete').params(path=controllerFile, force=true).run(); - print.greenLine("Deleted: #controllerFile#"); + detailOutput.statusSuccess("Deleted: #controllerFile#"); } else { - print.yellowLine("File not found: #controllerFile#"); + detailOutput.statusWarning("File not found: #controllerFile#"); } if(fileExists(testcontrollerFile)) { command('delete').params(path=testcontrollerFile, force=true).run(); - print.greenLine("Deleted: #testcontrollerFile#"); + detailOutput.statusSuccess("Deleted: #testcontrollerFile#"); } - print.line(); + detailOutput.line(); + }else{ + detailOutput.getPrint().redLine("Resource destruction cancelled.").toConsole(); + return; } } @@ -157,33 +160,34 @@ component aliases='wheels d' extends="base" { var modelFile = fileSystemUtil.resolvePath("app/models/#obj.objectNameSingularC#.cfc"); var testmodelFile = fileSystemUtil.resolvePath("tests/specs/models/#obj.objectNameSingularC#Spec.cfc"); - print.redBoldLine("================================================") - .redBoldLine("= Watch Out! =") - .redBoldLine("================================================") - .line("This will delete the model file and drop the associated database table '#obj.objectNamePlural#'") + detailOutput.header("Watch Out!") + .output("This will delete the model file and drop the associated database table '#obj.objectNamePlural#'") .line() - .line("#modelFile#") - .line("#testmodelFile#") + .output(modelFile, true) + .output(testmodelFile, true) .line(); if(confirm("Are you sure? [y/n]")){ if(fileExists(modelFile)) { command('delete').params(path=modelFile, force=true).run(); - print.greenLine("Deleted: #modelFile#"); + detailOutput.statusSuccess("Deleted: #modelFile#"); } else { - print.yellowLine("File not found: #modelFile#"); + detailOutput.statusWarning("File not found: #modelFile#"); } if(fileExists(testmodelFile)) { command('delete').params(path=testmodelFile, force=true).run(); - print.greenLine("Deleted: #testmodelFile#"); + detailOutput.statusSuccess("Deleted: #testmodelFile#"); } // Drop the table - print.greenline( "Migrating DB to drop table" ).toConsole(); + detailOutput.statusInfo("Migrating DB to drop table"); command('wheels dbmigrate remove table').params(name=obj.objectNamePlural).run(); command('wheels dbmigrate latest').run(); - print.line(); + detailOutput.line(); + }else{ + detailOutput.getPrint().redLine("Resource destruction cancelled.").toConsole(); + return; } } @@ -197,9 +201,9 @@ component aliases='wheels d' extends="base" { // Validate that we have both controller and view parts if(arrayLen(parts) != 2 || len(trim(parts[1])) == 0 || len(trim(parts[2])) == 0) { - print.redBoldLine("Error: Invalid view path format.") - .line("When destroying a specific view, use format: controller/view") - .line("Example: wheels destroy view products/index") + detailOutput.error("Invalid view path format.") + .output("When destroying a specific view, use format: controller/view", true) + .output("Example: wheels destroy view products/index", true) .line(); return; } @@ -208,22 +212,23 @@ component aliases='wheels d' extends="base" { var viewName = trim(parts[2]); var viewFile = fileSystemUtil.resolvePath("app/views/#controllerName#/#viewName#.cfm"); - print.redBoldLine("================================================") - .redBoldLine("= Watch Out! =") - .redBoldLine("================================================") - .line("This will delete the following file:") + detailOutput.header("Watch Out!") + .output("This will delete the following file:") .line() - .line("#viewFile#") + .output(viewFile, true) .line(); if(confirm("Are you sure? [y/n]")){ if(fileExists(viewFile)) { command('delete').params(path=viewFile, force=true).run(); - print.greenLine("Deleted: #viewFile#"); + detailOutput.statusSuccess("Deleted: #viewFile#"); } else { - print.yellowLine("File not found: #viewFile#"); + detailOutput.statusWarning("File not found: #viewFile#"); } - print.line(); + detailOutput.line(); + }else{ + detailOutput.getPrint().redLine("Resource destruction cancelled.").toConsole(); + return; } } else { // Destroy all views for a controller @@ -231,28 +236,29 @@ component aliases='wheels d' extends="base" { var viewFolder = fileSystemUtil.resolvePath("app/views/#obj.objectNamePlural#/"); var testviewFolder = fileSystemUtil.resolvePath("tests/specs/views/#obj.objectNamePlural#/"); - print.redBoldLine("================================================") - .redBoldLine("= Watch Out! =") - .redBoldLine("================================================") - .line("This will delete the following directories:") + detailOutput.header("Watch Out!") + .output("This will delete the following directories:") .line() - .line("#viewFolder#") - .line("#testviewFolder#") + .output(viewFolder, true) + .output(testviewFolder, true) .line(); if(confirm("Are you sure? [y/n]")){ if(directoryExists(viewFolder)) { command('delete').params(path=viewFolder, force=true, recurse=true).run(); - print.greenLine("Deleted: #viewFolder#"); + detailOutput.statusSuccess("Deleted: #viewFolder#"); } else { - print.yellowLine("Directory not found: #viewFolder#"); + detailOutput.statusWarning("Directory not found: #viewFolder#"); } if(directoryExists(testviewFolder)) { command('delete').params(path=testviewFolder, force=true, recurse=true).run(); - print.greenLine("Deleted: #testviewFolder#"); + detailOutput.statusSuccess("Deleted: #testviewFolder#"); } - print.line(); + detailOutput.line(); + }else{ + detailOutput.getPrint().redLine("Resource destruction cancelled.").toConsole(); + return; } } } diff --git a/cli/src/commands/wheels/docker/DockerCommand.cfc b/cli/src/commands/wheels/docker/DockerCommand.cfc new file mode 100644 index 0000000000..0364c239b4 --- /dev/null +++ b/cli/src/commands/wheels/docker/DockerCommand.cfc @@ -0,0 +1,607 @@ +/** + * Base component for Docker commands + */ +component extends="../base" { + + /** + * Login to container registry + * + * @registry Registry type: dockerhub, ecr, gcr, acr, ghcr, private + * @image Image name (used for extracting region/registry info for some providers) + * @username Registry username + * @password Registry password + * @isLocal Whether to execute login locally or return command string + */ + public function loginToRegistry( + required string registry, + string image="", + string username="", + string password="", + boolean isLocal=true + ) { + var local = {}; + + switch(lCase(arguments.registry)) { + case "dockerhub": + if (!len(trim(arguments.username))) { + error("Docker Hub username is required. Use --username="); + } + + print.yellowLine("Logging in to Docker Hub...").toConsole(); + + if (!len(trim(arguments.password))) { + print.line("Enter Docker Hub password or access token:"); + arguments.password = ask(""); + } + + if (arguments.isLocal) { + local.loginCmd = ["docker", "login", "-u", arguments.username, "--password-stdin"]; + local.result = runLocalCommandWithInput(local.loginCmd, arguments.password); + } else { + return "echo '" & arguments.password & "' | docker login -u " & arguments.username & " --password-stdin"; + } + break; + + case "ecr": + print.yellowLine("Logging in to AWS ECR...").toConsole(); + print.cyanLine("Note: AWS CLI must be configured with valid credentials").toConsole(); + + // Extract region from image name + if (!len(trim(arguments.image))) { + error("AWS ECR requires image path to determine region. Use --image=123456789.dkr.ecr.region.amazonaws.com/repo:tag"); + } + local.region = extractAWSRegion(arguments.image); + + if (arguments.isLocal) { + // Get ECR login token + local.ecrCmd = ["aws", "ecr", "get-login-password", "--region", local.region]; + local.tokenResult = runLocalCommand(local.ecrCmd, false); + + // Extract registry URL from image + local.registryUrl = listFirst(arguments.image, "/"); + + // Login to ECR + local.loginCmd = ["docker", "login", "--username", "AWS", "--password-stdin", local.registryUrl]; + local.result = runLocalCommandWithInput(local.loginCmd, local.tokenResult.output); + } else { + return "aws ecr get-login-password --region " & local.region & " | docker login --username AWS --password-stdin " & listFirst(arguments.image, "/"); + } + break; + + case "gcr": + print.yellowLine("Logging in to Google Container Registry...").toConsole(); + print.cyanLine("Note: gcloud CLI must be configured").toConsole(); + + if (arguments.isLocal) { + runLocalCommand(["gcloud", "auth", "configure-docker"]); + } else { + return "gcloud auth configure-docker"; + } + break; + + case "acr": + print.yellowLine("Logging in to Azure Container Registry...").toConsole(); + print.cyanLine("Note: Azure CLI must be configured").toConsole(); + + // Extract registry name from image + if (!len(trim(arguments.image))) { + error("Azure ACR requires image path to determine registry. Use --image=registry.azurecr.io/image:tag"); + } + local.registryName = listFirst(arguments.image, "."); + + if (arguments.isLocal) { + runLocalCommand(["az", "acr", "login", "--name", local.registryName]); + } else { + return "az acr login --name " & local.registryName; + } + break; + + case "ghcr": + if (!len(trim(arguments.username))) { + error("GitHub username is required. Use --username="); + } + + print.yellowLine("Logging in to GitHub Container Registry...").toConsole(); + + if (!len(trim(arguments.password))) { + print.line("Enter GitHub Personal Access Token:"); + arguments.password = ask(""); + } + + if (arguments.isLocal) { + local.loginCmd = ["docker", "login", "ghcr.io", "-u", arguments.username, "--password-stdin"]; + local.result = runLocalCommandWithInput(local.loginCmd, arguments.password); + } else { + return "echo '" & arguments.password & "' | docker login ghcr.io -u " & arguments.username & " --password-stdin"; + } + break; + + case "private": + if (!len(trim(arguments.username))) { + error("Registry username is required. Use --username="); + } + + print.yellowLine("Logging in to private registry...").toConsole(); + + if (!len(trim(arguments.password))) { + print.line("Enter registry password:"); + arguments.password = ask(""); + } + + local.registryUrl = ""; + if (len(trim(arguments.image))) { + local.registryUrl = listFirst(arguments.image, "/"); + } else { + error("Private registry requires image path to determine registry URL. Use --image=registry.example.com:port/image:tag"); + } + + if (arguments.isLocal) { + local.loginCmd = ["docker", "login", local.registryUrl, "-u", arguments.username, "--password-stdin"]; + local.result = runLocalCommandWithInput(local.loginCmd, arguments.password); + } else { + return "echo '" & arguments.password & "' | docker login " & local.registryUrl & " -u " & arguments.username & " --password-stdin"; + } + break; + } + + print.greenLine("Login successful").toConsole(); + return ""; + } + + /** + * Extract AWS region from ECR image path + */ + public function extractAWSRegion(string imagePath) { + // Format: 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest + var parts = listToArray(arguments.imagePath, "."); + if (arrayLen(parts) >= 4) { + return parts[4]; // us-east-1 + } + return "us-east-1"; // default + } + + /** + * Check if Docker is installed locally + */ + public function isDockerInstalled() { + try { + var result = runLocalCommand(["docker", "--version"], false); + return (result.exitCode eq 0); + } catch (any e) { + return false; + } + } + + /** + * Check if a local Docker image exists + */ + public function checkLocalImageExists(required string imageName) { + try { + local.result = runLocalCommand(["docker", "image", "inspect", arguments.imageName], false); + return (local.result.exitCode eq 0); + } catch (any e) { + return false; + } + } + + /** + * Get project name from current directory + */ + public function getProjectName() { + var cwd = getCWD(); + var dirName = listLast(cwd, "\/"); + dirName = lCase(dirName); + dirName = reReplace(dirName, "[^a-z0-9\-]", "-", "all"); + dirName = reReplace(dirName, "\-+", "-", "all"); + dirName = reReplace(dirName, "^\-|\-$", "", "all"); + return len(dirName) ? dirName : "wheels-app"; + } + + /** + * Determine the final image name based on registry and parameters + */ + public function determineImageName( + required string registry, + required string customImage, + required string projectName, + required string tag, + required string username, + required string namespace + ) { + // If custom image is specified, use it + if (len(trim(arguments.customImage))) { + return arguments.customImage; + } + + // Use namespace if provided, otherwise use username + local.prefix = len(trim(arguments.namespace)) ? arguments.namespace : arguments.username; + + // Build image name based on registry type + switch(lCase(arguments.registry)) { + case "dockerhub": + if (!len(trim(local.prefix))) { + error("Docker Hub requires --username or --namespace parameter"); + } + return local.prefix & "/" & arguments.projectName & ":" & arguments.tag; + + case "ecr": + error("AWS ECR requires full image path. Use --image=123456789.dkr.ecr.region.amazonaws.com/repo:tag"); + + case "gcr": + error("GCR requires full image path. Use --image=gcr.io/project-id/image:tag"); + + case "acr": + error("Azure ACR requires full image path. Use --image=registry.azurecr.io/image:tag"); + + case "ghcr": + if (!len(trim(local.prefix))) { + error("GitHub Container Registry requires --username or --namespace parameter"); + } + return "ghcr.io/" & lCase(local.prefix) & "/" & arguments.projectName & ":" & arguments.tag; + + case "private": + error("Private registry requires full image path. Use --image=registry.example.com:port/image:tag"); + + default: + error("Unsupported registry type"); + } + } + + /** + * Run a local system command + */ + public function runLocalCommand(array cmd, boolean showOutput=true) { + var local = {}; + local.javaCmd = createObject("java","java.util.ArrayList").init(); + for (var c in arguments.cmd) { + local.javaCmd.add(c & ""); + } + + local.pb = createObject("java","java.lang.ProcessBuilder").init(local.javaCmd); + + // Set working directory to current directory + local.currentDir = createObject("java", "java.io.File").init(getCWD()); + local.pb.directory(local.currentDir); + + local.pb.redirectErrorStream(true); + local.proc = local.pb.start(); + + local.isr = createObject("java","java.io.InputStreamReader").init(local.proc.getInputStream(), "UTF-8"); + local.br = createObject("java","java.io.BufferedReader").init(local.isr); + local.outputParts = []; + + while (true) { + local.line = local.br.readLine(); + if (isNull(local.line)) break; + arrayAppend(local.outputParts, local.line); + if (arguments.showOutput) { + print.line(local.line).toConsole(); + } + } + + local.exitCode = local.proc.waitFor(); + local.output = arrayToList(local.outputParts, chr(10)); + + if (local.exitCode neq 0 && arguments.showOutput) { + error("Command failed with exit code: " & local.exitCode); + } + + return { exitCode: local.exitCode, output: local.output }; + } + + /** + * Run a local command with stdin input (for passwords) + */ + public function runLocalCommandWithInput(array cmd, string input) { + var local = {}; + local.javaCmd = createObject("java","java.util.ArrayList").init(); + for (var c in arguments.cmd) { + local.javaCmd.add(c & ""); + } + + local.pb = createObject("java","java.lang.ProcessBuilder").init(local.javaCmd); + local.currentDir = createObject("java", "java.io.File").init(getCWD()); + local.pb.directory(local.currentDir); + local.pb.redirectErrorStream(true); + local.proc = local.pb.start(); + + // Write input to stdin + local.os = local.proc.getOutputStream(); + local.osw = createObject("java","java.io.OutputStreamWriter").init(local.os, "UTF-8"); + local.osw.write(arguments.input); + local.osw.flush(); + local.osw.close(); + + // Read output + local.isr = createObject("java","java.io.InputStreamReader").init(local.proc.getInputStream(), "UTF-8"); + local.br = createObject("java","java.io.BufferedReader").init(local.isr); + local.outputParts = []; + + while (true) { + local.line = local.br.readLine(); + if (isNull(local.line)) break; + arrayAppend(local.outputParts, local.line); + print.line(local.line).toConsole(); + } + + local.exitCode = local.proc.waitFor(); + + if (local.exitCode neq 0) { + error("Login failed with exit code: " & local.exitCode); + } + + return { exitCode: local.exitCode }; + } + + /** + * Run a local system command interactively (inherits IO) + * Useful for long-running commands or those needing TTY (like logs -f) + * This prevents hanging by connecting subprocess IO directly to the console + */ + public function runInteractiveCommand(array cmd, boolean inheritInput=false) { + var local = {}; + local.javaCmd = createObject("java","java.util.ArrayList").init(); + for (var c in arguments.cmd) { + local.javaCmd.add(c & ""); + } + + local.pb = createObject("java","java.lang.ProcessBuilder").init(local.javaCmd); + + // Set working directory to current directory + local.currentDir = createObject("java", "java.io.File").init(getCWD()); + local.pb.directory(local.currentDir); + + // Inherit Output and Error streams + var Redirect = createObject("java", "java.lang.ProcessBuilder$Redirect"); + local.pb.redirectOutput(Redirect.INHERIT); + local.pb.redirectError(Redirect.INHERIT); + + // Conditionally inherit Input + // Only inherit input if explicitly requested (e.g. for interactive shells) + // Otherwise leave as PIPE to avoid CommandBox shell corruption + if (arguments.inheritInput) { + local.pb.redirectInput(Redirect.INHERIT); + } + + try { + local.proc = local.pb.start(); + local.exitCode = local.proc.waitFor(); + } catch (java.lang.InterruptedException e) { + // User interrupted (Ctrl+C) + local.exitCode = 130; // Standard exit code for SIGINT + + // Ensure process is destroyed + if (structKeyExists(local, "proc")) { + local.proc.destroy(); + } + + // Clear the interrupted status of the current thread to prevent side effects in CommandBox + createObject("java", "java.lang.Thread").currentThread().interrupt(); + // Actually, we want to clear it, so Thread.interrupted() does that. + // But waitFor() throws exception and clears status. + // Re-interrupting might cause CommandBox to think it's still interrupted. + // Let's just print a message. + print.line().toConsole(); + print.yellowLine("Command interrupted by user.").toConsole(); + } catch (any e) { + // Check for UserInterruptException (CommandBox specific) + if (findNoCase("UserInterruptException", e.message) || (structKeyExists(e, "type") && findNoCase("UserInterruptException", e.type))) { + local.exitCode = 130; + if (structKeyExists(local, "proc")) { + local.proc.destroy(); + } + print.line().toConsole(); + print.yellowLine("Command interrupted by user.").toConsole(); + } else { + local.exitCode = 1; + print.redLine("Error executing command: #e.message#").toConsole(); + } + } + + return { exitCode: local.exitCode }; + } + + /** + * Check if docker-compose file exists + */ + public function hasDockerComposeFile() { + var composeFiles = ["docker-compose.yml", "docker-compose.yaml"]; + for (var composeFile in composeFiles) { + if (fileExists(getCWD() & "/" & composeFile)) { + return true; + } + } + return false; + } + + /** + * Get exposed port from Dockerfile + */ + public function getDockerExposedPort() { + var dockerfilePath = getCWD() & "/Dockerfile"; + if (!fileExists(dockerfilePath)) { + return ""; + } + + var content = fileRead(dockerfilePath); + var lines = listToArray(content, chr(10)); + + for (var line in lines) { + if (reFindNoCase("^EXPOSE\s+[0-9]+", trim(line))) { + return listLast(trim(line), " "); + } + } + + return ""; + } + + /** + * Reconstruct arguments to handle key=value pairs passed as keys + */ + public function reconstructArgs(required struct args) { + var newArgs = duplicate(arguments.args); + + // Check for args in format "key=value" which CommandBox sometimes passes as keys with empty values + for (var key in newArgs) { + if (find("=", key)) { + var parts = listToArray(key, "="); + if (arrayLen(parts) >= 2) { + var paramName = parts[1]; + var paramValue = right(key, len(key) - len(paramName) - 1); + newArgs[paramName] = paramValue; + } + } + } + + return newArgs; + } + + // ============================================================================= + // SHARED HELPER FUNCTIONS (Moved from deploy.cfc) + // ============================================================================= + + public function getSSHOptions() { + return [ + "-o", "BatchMode=yes", + "-o", "PreferredAuthentications=publickey", + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=10", + "-o", "ServerAliveInterval=15", + "-o", "ServerAliveCountMax=3" + ]; + } + + public function testSSHConnection(string host, string user, numeric port) { + var local = {}; + print.yellowLine("Testing SSH connection to " & arguments.host & "...").toConsole(); + var sshCmd = ["ssh", "-p", arguments.port]; + sshCmd.addAll(getSSHOptions()); + sshCmd.addAll([arguments.user & "@" & arguments.host, "echo connected"]); + + local.result = runLocalCommand(sshCmd); + return (local.result.exitCode eq 0 and findNoCase("connected", local.result.output)); + } + + public function executeRemoteCommand(string host, string user, numeric port, string cmd) { + var local = {}; + var sshCmd = ["ssh", "-p", arguments.port]; + sshCmd.addAll(getSSHOptions()); + sshCmd.addAll([arguments.user & "@" & arguments.host, arguments.cmd]); + + local.result = runLocalCommand(sshCmd); + + if (local.result.exitCode neq 0) { + error("Remote command failed: " & arguments.cmd & " (Exit code: " & local.result.exitCode & ")"); + } + + return local.result; + } + + /** + * Load servers from simple text file + * Format: host username [port] + */ + public function loadServersFromTextFile(required string textFile) { + var filePath = fileSystemUtil.resolvePath(arguments.textFile); + + if (!fileExists(filePath)) { + error("Text file not found: #filePath#"); + } + + try { + var fileContent = fileRead(filePath); + var lines = listToArray(fileContent, chr(10)); + var servers = []; + + for (var lineNum = 1; lineNum <= arrayLen(lines); lineNum++) { + var line = trim(lines[lineNum]); + + // Skip empty lines and comments + if (len(line) == 0 || left(line, 1) == "##") { + continue; + } + + var parts = listToArray(line, " " & chr(9), true); + + if (arrayLen(parts) < 2) { + print.yellowLine(" Skipping invalid line #lineNum#: #line#").toConsole(); + continue; + } + + var serverConfig = { + "host": trim(parts[1]), + "user": trim(parts[2]), + "port": arrayLen(parts) >= 3 ? val(trim(parts[3])) : 22 + }; + + var projectName = getProjectName(); + serverConfig.remoteDir = "/home/#serverConfig.user#/#projectName#"; + serverConfig.imageName = projectName; + + arrayAppend(servers, serverConfig); + } + + if (arrayLen(servers) == 0) { + error("No valid servers found in text file"); + } + + print.greenLine("Loaded #arrayLen(servers)# server(s) from text file").toConsole(); + return servers; + + } catch (any e) { + error("Error reading text file: #e.message#"); + } + } + + /** + * Load servers configuration from JSON file + */ + public function loadServersFromConfig(required string configFile) { + var configPath = fileSystemUtil.resolvePath(arguments.configFile); + + if (!fileExists(configPath)) { + error("Config file not found: #configPath#"); + } + + try { + var configContent = fileRead(configPath); + var config = deserializeJSON(configContent); + + if (!structKeyExists(config, "servers") || !isArray(config.servers)) { + error("Invalid config file format. Expected { ""servers"": [ ... ] }"); + } + + if (arrayLen(config.servers) == 0) { + error("No servers defined in config file"); + } + + for (var i = 1; i <= arrayLen(config.servers); i++) { + var serverConfig = config.servers[i]; + if (!structKeyExists(serverConfig, "host") || !len(trim(serverConfig.host))) { + error("Server #i# is missing required 'host' field"); + } + if (!structKeyExists(serverConfig, "user") || !len(trim(serverConfig.user))) { + error("Server #i# is missing required 'user' field"); + } + + var projectName = getProjectName(); + if (!structKeyExists(serverConfig, "port")) { + serverConfig.port = 22; + } + if (!structKeyExists(serverConfig, "remoteDir")) { + serverConfig.remoteDir = "/home/#serverConfig.user#/#projectName#"; + } + if (!structKeyExists(serverConfig, "imageName")) { + serverConfig.imageName = projectName; + } + } + + print.greenLine("Loaded #arrayLen(config.servers)# server(s) from config file").toConsole(); + return config.servers; + + } catch (any e) { + error("Error parsing config file: #e.message#"); + } + } + +} diff --git a/cli/src/commands/wheels/docker/build.cfc b/cli/src/commands/wheels/docker/build.cfc new file mode 100644 index 0000000000..52b7af1e8b --- /dev/null +++ b/cli/src/commands/wheels/docker/build.cfc @@ -0,0 +1,630 @@ +/** + * Unified Docker build command for Wheels apps + * + * {code:bash} + * wheels docker build --local + * wheels docker build --local --tag=myapp:v1.0 + * wheels docker build --local --nocache + * wheels docker build --remote + * wheels docker build --remote --servers=1,3 + * {code} + */ +component extends="../base" { + + /** + * @local Build Docker image on local machine + * @remote Build Docker image on remote server(s) + * @servers Comma-separated list of server numbers to build on (e.g., "1,3,5") - for remote only + * @tag Custom tag for the Docker image (default: project-name:latest) + * @nocache Build without using cache + * @pull Always attempt to pull a newer version of the base image + */ + function run( + boolean local=false, + boolean remote=false, + string servers="", + string tag="", + boolean nocache=false, + boolean pull=false + ) { + + // Validate that exactly one build type is specified + if (!arguments.local && !arguments.remote) { + error("Please specify build type: --local or --remote"); + } + + if (arguments.local && arguments.remote) { + error("Cannot specify both --local and --remote. Please choose one."); + } + + // Route to appropriate build method + if (arguments.local) { + buildLocal(arguments.tag, arguments.nocache, arguments.pull); + } else { + buildRemote(arguments.servers, arguments.tag, arguments.nocache, arguments.pull); + } + } + + // ============================================================================= + // LOCAL BUILD + // ============================================================================= + + private function buildLocal(string customTag, boolean nocache, boolean pull) { + print.line(); + print.boldMagentaLine("Wheels Docker Local Build"); + print.line(); + + // Check if Docker is installed locally + if (!isDockerInstalled()) { + error("Docker is not installed or not accessible. Please ensure Docker Desktop or Docker Engine is running."); + } + + // Check for docker-compose file + local.useCompose = hasDockerComposeFile(); + + if (local.useCompose) { + print.greenLine("Found docker-compose file, will build using docker-compose").toConsole(); + + // Build command array + local.buildCmd = ["docker", "compose", "build"]; + + if (arguments.nocache) { + arrayAppend(local.buildCmd, "--no-cache"); + } + + if (arguments.pull) { + arrayAppend(local.buildCmd, "--pull"); + } + + print.yellowLine("Building services with docker-compose...").toConsole(); + runLocalCommand(local.buildCmd); + + print.line(); + print.boldGreenLine("Docker Compose services built successfully!").toConsole(); + print.line(); + print.yellowLine("View images with: docker images").toConsole(); + print.yellowLine("Start services with: wheels docker deploy --local").toConsole(); + print.line(); + + } else { + // Check for Dockerfile + local.dockerfilePath = getCWD() & "/Dockerfile"; + if (!fileExists(local.dockerfilePath)) { + error("No Dockerfile or docker-compose.yml found in current directory"); + } + + print.greenLine("Found Dockerfile, will build using standard docker build").toConsole(); + + // Get project name and determine tag + local.projectName = getProjectName(); + local.imageTag = len(trim(arguments.customTag)) ? arguments.customTag : local.projectName & ":latest"; + + print.cyanLine("Building image: " & local.imageTag).toConsole(); + + // Build command array + local.buildCmd = ["docker", "build", "-t", local.imageTag]; + + if (arguments.nocache) { + arrayAppend(local.buildCmd, "--no-cache"); + } + + if (arguments.pull) { + arrayAppend(local.buildCmd, "--pull"); + } + + arrayAppend(local.buildCmd, "."); + + print.yellowLine("Building Docker image...").toConsole(); + runLocalCommand(local.buildCmd); + + print.line(); + print.boldGreenLine("Docker image built successfully!").toConsole(); + print.line(); + print.yellowLine("Image tag: " & local.imageTag).toConsole(); + print.yellowLine("View image with: docker images " & local.projectName).toConsole(); + print.yellowLine("Run container with: wheels docker deploy --local").toConsole(); + print.line(); + } + } + + /** + * Check if Docker is installed locally + */ + private function isDockerInstalled() { + try { + var result = runLocalCommand(["docker", "--version"], false); + return (result.exitCode eq 0); + } catch (any e) { + return false; + } + } + + /** + * Run a local system command + */ + private function runLocalCommand(array cmd, boolean showOutput=true) { + var local = {}; + local.javaCmd = createObject("java","java.util.ArrayList").init(); + for (var c in arguments.cmd) { + local.javaCmd.add(c & ""); + } + + local.pb = createObject("java","java.lang.ProcessBuilder").init(local.javaCmd); + + // Set working directory to current directory + local.currentDir = createObject("java", "java.io.File").init(getCWD()); + local.pb.directory(local.currentDir); + + local.pb.redirectErrorStream(true); + local.proc = local.pb.start(); + + local.isr = createObject("java","java.io.InputStreamReader").init(local.proc.getInputStream(), "UTF-8"); + local.br = createObject("java","java.io.BufferedReader").init(local.isr); + local.outputParts = []; + + while (true) { + local.line = local.br.readLine(); + if (isNull(local.line)) break; + arrayAppend(local.outputParts, local.line); + if (arguments.showOutput) { + print.line(local.line).toConsole(); + } + } + + local.exitCode = local.proc.waitFor(); + local.output = arrayToList(local.outputParts, chr(10)); + + if (local.exitCode neq 0 && arguments.showOutput) { + error("Command failed with exit code: " & local.exitCode); + } + + return { exitCode: local.exitCode, output: local.output }; + } + + // ============================================================================= + // REMOTE BUILD + // ============================================================================= + + private function buildRemote(string serverNumbers, string customTag, boolean nocache, boolean pull) { + // Check for deploy-servers file (text or json) in current directory + var textConfigPath = fileSystemUtil.resolvePath("deploy-servers.txt"); + var jsonConfigPath = fileSystemUtil.resolvePath("deploy-servers.json"); + var allServers = []; + var serversToBuild = []; + + if (fileExists(textConfigPath)) { + print.cyanLine("Found deploy-servers.txt, loading server configuration").toConsole(); + allServers = loadServersFromTextFile("deploy-servers.txt"); + serversToBuild = filterServers(allServers, arguments.serverNumbers); + } else if (fileExists(jsonConfigPath)) { + print.cyanLine("Found deploy-servers.json, loading server configuration").toConsole(); + allServers = loadServersFromConfig("deploy-servers.json"); + serversToBuild = filterServers(allServers, arguments.serverNumbers); + } else { + error("No server configuration found. Create deploy-servers.txt or deploy-servers.json in your project root." & chr(10) & chr(10) & + "Example deploy-servers.txt:" & chr(10) & + "192.168.1.100 ubuntu 22" & chr(10) & + "production.example.com deploy" & chr(10) & chr(10) & + "Or see examples/deploy-servers.example.txt for more details."); + } + + if (arrayLen(serversToBuild) == 0) { + error("No servers configured for building"); + } + + print.line().boldCyanLine("Building Docker images on #arrayLen(serversToBuild)# server(s)...").toConsole(); + + // Build on all selected servers + buildOnServers(serversToBuild, arguments.customTag, arguments.nocache, arguments.pull); + + print.line().boldGreenLine("Build operations completed on all servers!").toConsole(); + } + + /** + * Filter servers based on comma-separated list of server numbers + */ + private function filterServers(required array allServers, string serverNumbers="") { + // If no specific servers requested, return all + if (!len(trim(arguments.serverNumbers))) { + return arguments.allServers; + } + + var selectedServers = []; + var numbers = listToArray(arguments.serverNumbers); + + for (var numStr in numbers) { + var num = val(trim(numStr)); + if (num > 0 && num <= arrayLen(arguments.allServers)) { + arrayAppend(selectedServers, arguments.allServers[num]); + } else { + print.yellowLine("Skipping invalid server number: " & numStr).toConsole(); + } + } + + if (arrayLen(selectedServers) == 0) { + print.yellowLine("No valid servers selected, using all servers").toConsole(); + return arguments.allServers; + } + + print.greenLine("Selected #arrayLen(selectedServers)# of #arrayLen(arguments.allServers)# server(s)").toConsole(); + return selectedServers; + } + + /** + * Build on multiple servers sequentially + */ + private function buildOnServers(required array servers, string customTag, boolean nocache, boolean pull) { + var successCount = 0; + var failureCount = 0; + var serverConfig = {}; + + for (var i = 1; i <= arrayLen(servers); i++) { + serverConfig = servers[i]; + print.line().boldCyanLine("---------------------------------------").toConsole(); + print.boldCyanLine("Building on server #i# of #arrayLen(servers)#: #serverConfig.host#").toConsole(); + print.line().boldCyanLine("---------------------------------------").toConsole(); + + try { + buildOnServer(serverConfig, arguments.customTag, arguments.nocache, arguments.pull); + successCount++; + print.greenLine("Build on #serverConfig.host# completed successfully").toConsole(); + } catch (any e) { + failureCount++; + print.redLine("Failed to build on #serverConfig.host#: #e.message#").toConsole(); + } + } + + print.line().toConsole(); + print.boldCyanLine("Build Operations Summary:").toConsole(); + print.greenLine(" Successful: #successCount#").toConsole(); + if (failureCount > 0) { + print.redLine(" Failed: #failureCount#").toConsole(); + } + } + + /** + * Build on a single server + */ + private function buildOnServer(required struct serverConfig, string customTag, boolean nocache, boolean pull) { + var local = {}; + local.host = arguments.serverConfig.host; + local.user = arguments.serverConfig.user; + local.port = structKeyExists(arguments.serverConfig, "port") ? arguments.serverConfig.port : 22; + local.imageName = structKeyExists(arguments.serverConfig, "imageName") ? arguments.serverConfig.imageName : "#local.user#-app"; + local.remoteDir = structKeyExists(arguments.serverConfig, "remoteDir") ? arguments.serverConfig.remoteDir : "/home/#local.user#/#local.user#-app"; + + // Check SSH connection + if (!testSSHConnection(local.host, local.user, local.port)) { + error("SSH connection failed to #local.host#. Check credentials and access."); + } + print.greenLine("SSH connection successful").toConsole(); + + // Check if remote directory exists + print.yellowLine("Checking remote directory...").toConsole(); + local.checkDirCmd = "test -d " & local.remoteDir; + local.dirExists = false; + + try { + executeRemoteCommand(local.host, local.user, local.port, local.checkDirCmd); + local.dirExists = true; + print.greenLine("Remote directory exists").toConsole(); + } catch (any e) { + print.yellowLine("Remote directory does not exist, uploading source code...").toConsole(); + uploadSourceCode(local.host, local.user, local.port, local.remoteDir); + } + + // Check if docker-compose is being used on the remote server + local.checkComposeCmd = "test -f " & local.remoteDir & "/docker-compose.yml || test -f " & local.remoteDir & "/docker-compose.yaml"; + local.useCompose = false; + + try { + executeRemoteCommand(local.host, local.user, local.port, local.checkComposeCmd); + local.useCompose = true; + print.greenLine("Found docker-compose file on remote server").toConsole(); + } catch (any e) { + print.yellowLine("No docker-compose file found, checking for Dockerfile...").toConsole(); + + // Check if Dockerfile exists + local.checkDockerfileCmd = "test -f " & local.remoteDir & "/Dockerfile"; + try { + executeRemoteCommand(local.host, local.user, local.port, local.checkDockerfileCmd); + print.greenLine("Found Dockerfile on remote server").toConsole(); + } catch (any e2) { + error("No Dockerfile or docker-compose.yml found on remote server in: " & local.remoteDir); + } + } + + if (local.useCompose) { + // Build using docker-compose + print.yellowLine("Building with docker-compose...").toConsole(); + + local.buildCmd = "cd " & local.remoteDir & " && "; + + // Check if user can run docker without sudo + local.buildCmd &= "if groups | grep -q docker && [ -w /var/run/docker.sock ]; then "; + local.buildCmd &= "docker compose build"; + + if (arguments.nocache) { + local.buildCmd &= " --no-cache"; + } + + if (arguments.pull) { + local.buildCmd &= " --pull"; + } + + local.buildCmd &= "; else sudo docker compose build"; + + if (arguments.nocache) { + local.buildCmd &= " --no-cache"; + } + + if (arguments.pull) { + local.buildCmd &= " --pull"; + } + + local.buildCmd &= "; fi"; + + executeRemoteCommand(local.host, local.user, local.port, local.buildCmd); + print.greenLine("Docker Compose build completed").toConsole(); + + } else { + // Build using standard docker build + print.yellowLine("Building Docker image...").toConsole(); + + // Determine tag + local.imageTag = len(trim(arguments.customTag)) ? arguments.customTag : local.imageName & ":latest"; + print.cyanLine("Building image: " & local.imageTag).toConsole(); + + local.buildCmd = "cd " & local.remoteDir & " && "; + + // Check if user can run docker without sudo + local.buildCmd &= "if groups | grep -q docker && [ -w /var/run/docker.sock ]; then "; + local.buildCmd &= "docker build -t " & local.imageTag; + + if (arguments.nocache) { + local.buildCmd &= " --no-cache"; + } + + if (arguments.pull) { + local.buildCmd &= " --pull"; + } + + local.buildCmd &= " ."; + local.buildCmd &= "; else sudo docker build -t " & local.imageTag; + + if (arguments.nocache) { + local.buildCmd &= " --no-cache"; + } + + if (arguments.pull) { + local.buildCmd &= " --pull"; + } + + local.buildCmd &= " ."; + local.buildCmd &= "; fi"; + + executeRemoteCommand(local.host, local.user, local.port, local.buildCmd); + print.greenLine("Docker image built: " & local.imageTag).toConsole(); + } + + print.boldGreenLine("Build operations on #local.host# completed!").toConsole(); + } + + /** + * Upload source code to remote server + */ + private function uploadSourceCode(string host, string user, numeric port, string remoteDir) { + var local = {}; + + print.yellowLine("Creating deployment directory on remote server...").toConsole(); + + // Create remote directory + local.createDirCmd = "sudo mkdir -p " & arguments.remoteDir & " && sudo chown -R $USER:$USER " & arguments.remoteDir; + + try { + executeRemoteCommand(arguments.host, arguments.user, arguments.port, local.createDirCmd); + } catch (any e) { + print.yellowLine("Note: Creating directory without sudo...").toConsole(); + executeRemoteCommand(arguments.host, arguments.user, arguments.port, "mkdir -p " & arguments.remoteDir); + } + + // Create tarball and upload + local.timestamp = dateFormat(now(), "yyyymmdd") & timeFormat(now(), "HHmmss"); + local.tarFile = getTempFile(getTempDirectory(), "buildsrc_") & ".tar.gz"; + local.remoteTar = "/tmp/buildsrc_" & local.timestamp & ".tar.gz"; + + print.yellowLine("Creating source tarball...").toConsole(); + runProcess(["tar", "-czf", local.tarFile, "-C", getCWD(), "."]); + + print.yellowLine("Uploading source code to remote server...").toConsole(); + runProcess(["scp", "-P", arguments.port, local.tarFile, arguments.user & "@" & arguments.host & ":" & local.remoteTar]); + fileDelete(local.tarFile); + + print.yellowLine("Extracting source code...").toConsole(); + local.extractCmd = "tar -xzf " & local.remoteTar & " -C " & arguments.remoteDir & " && rm " & local.remoteTar; + executeRemoteCommand(arguments.host, arguments.user, arguments.port, local.extractCmd); + + print.greenLine("Source code uploaded successfully").toConsole(); + } + + /** + * Load servers from simple text file + */ + private function loadServersFromTextFile(required string textFile) { + var filePath = fileSystemUtil.resolvePath(arguments.textFile); + + if (!fileExists(filePath)) { + error("Text file not found: #filePath#"); + } + + try { + var fileContent = fileRead(filePath); + var lines = listToArray(fileContent, chr(10)); + var servers = []; + + for (var lineNum = 1; lineNum <= arrayLen(lines); lineNum++) { + var line = trim(lines[lineNum]); + + // Skip empty lines and comments + if (len(line) == 0 || left(line, 1) == "##") { + continue; + } + + var parts = listToArray(line, " " & chr(9), true); + + if (arrayLen(parts) < 2) { + continue; + } + + var serverConfig = { + "host": trim(parts[1]), + "user": trim(parts[2]), + "port": arrayLen(parts) >= 3 ? val(trim(parts[3])) : 22 + }; + + var projectName = getProjectName(); + serverConfig.remoteDir = "/home/#serverConfig.user#/#projectName#"; + serverConfig.imageName = projectName; + arrayAppend(servers, serverConfig); + } + + return servers; + + } catch (any e) { + error("Error reading text file: #e.message#"); + } + } + + /** + * Load servers configuration from JSON file + */ + private function loadServersFromConfig(required string configFile) { + var configPath = fileSystemUtil.resolvePath(arguments.configFile); + + if (!fileExists(configPath)) { + error("Config file not found: #configPath#"); + } + + try { + var configContent = fileRead(configPath); + var config = deserializeJSON(configContent); + + if (!structKeyExists(config, "servers") || !isArray(config.servers)) { + error("Invalid config file format. Expected { ""servers"": [ ... ] }"); + } + + var projectName = getProjectName(); + for (var i = 1; i <= arrayLen(config.servers); i++) { + var serverConfig = config.servers[i]; + if (!structKeyExists(serverConfig, "port")) { + serverConfig.port = 22; + } + if (!structKeyExists(serverConfig, "remoteDir")) { + serverConfig.remoteDir = "/home/#serverConfig.user#/#projectName#"; + } + if (!structKeyExists(serverConfig, "imageName")) { + serverConfig.imageName = projectName; + } + } + + return config.servers; + + } catch (any e) { + error("Error parsing config file: #e.message#"); + } + } + + // ============================================================================= + // HELPER FUNCTIONS + // ============================================================================= + + private function testSSHConnection(string host, string user, numeric port) { + var local = {}; + print.yellowLine("Testing SSH connection to " & arguments.host & "...").toConsole(); + local.result = runProcess([ + "ssh", + "-o", "BatchMode=yes", + "-o", "PreferredAuthentications=publickey", + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=10", + "-p", arguments.port, + arguments.user & "@" & arguments.host, + "echo connected" + ]); + return (local.result.exitCode eq 0 and findNoCase("connected", local.result.output)); + } + + private function executeRemoteCommand(string host, string user, numeric port, string cmd) { + var local = {}; + print.yellowLine("Running: ssh -p " & arguments.port & " " & arguments.user & "@" & arguments.host & " " & arguments.cmd).toConsole(); + + local.result = runProcess([ + "ssh", + "-o", "BatchMode=yes", + "-o", "PreferredAuthentications=publickey", + "-o", "StrictHostKeyChecking=no", + "-o", "ServerAliveInterval=30", + "-o", "ServerAliveCountMax=3", + "-p", arguments.port, + arguments.user & "@" & arguments.host, + arguments.cmd + ]); + + if (local.result.exitCode neq 0) { + error("Remote command failed: " & arguments.cmd); + } + + return local.result; + } + + private function runProcess(array cmd) { + var local = {}; + local.javaCmd = createObject("java","java.util.ArrayList").init(); + for (var c in arguments.cmd) { + local.javaCmd.add(c & ""); + } + + local.pb = createObject("java","java.lang.ProcessBuilder").init(local.javaCmd); + local.pb.redirectErrorStream(true); + local.proc = local.pb.start(); + + local.isr = createObject("java","java.io.InputStreamReader").init(local.proc.getInputStream(), "UTF-8"); + local.br = createObject("java","java.io.BufferedReader").init(local.isr); + local.outputParts = []; + + while (true) { + local.line = local.br.readLine(); + if (isNull(local.line)) break; + arrayAppend(local.outputParts, local.line); + print.line(local.line).toConsole(); + } + + local.exitCode = local.proc.waitFor(); + local.output = arrayToList(local.outputParts, chr(10)); + + return { exitCode: local.exitCode, output: local.output }; + } + + private function getProjectName() { + var cwd = getCWD(); + var dirName = listLast(cwd, "\/"); + dirName = lCase(dirName); + dirName = reReplace(dirName, "[^a-z0-9\-]", "-", "all"); + dirName = reReplace(dirName, "\-+", "-", "all"); + dirName = reReplace(dirName, "^\-|\-$", "", "all"); + return len(dirName) ? dirName : "wheels-app"; + } + + private function hasDockerComposeFile() { + var composeFiles = ["docker-compose.yml", "docker-compose.yaml"]; + + for (var composeFile in composeFiles) { + var composePath = getCWD() & "/" & composeFile; + if (fileExists(composePath)) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/cli/src/commands/wheels/docker/deploy.cfc b/cli/src/commands/wheels/docker/deploy.cfc index 2889a5f381..74b8988c65 100644 --- a/cli/src/commands/wheels/docker/deploy.cfc +++ b/cli/src/commands/wheels/docker/deploy.cfc @@ -1,365 +1,829 @@ /** - * Create production-ready Docker configurations + * Unified Docker deployment command for Wheels apps * * {code:bash} - * wheels docker:deploy - * wheels docker:deploy --environment=staging - * wheels docker:deploy --db=mysql --cfengine=lucee + * wheels docker deploy --local + * wheels docker deploy --local --environment=staging + * wheels docker deploy --remote + * wheels docker deploy --remote --servers=deploy-servers.txt + * wheels docker deploy --remote --blue-green * {code} */ -component extends="../base" { - +component extends="DockerCommand" { + /** - * @environment Deployment environment (production, staging) - * @db Database to use (h2, mysql, postgres, mssql) - * @cfengine ColdFusion engine to use (lucee, adobe) - * @optimize Enable production optimizations - * @force Overwrite existing Docker files without confirmation + * @local Deploy to local Docker environment + * @remote Deploy to remote server(s) + * @environment Deployment environment (production, staging) - for local deployment + * @db Database to use (h2, mysql, postgres, mssql) - for local deployment + * @cfengine ColdFusion engine to use (lucee, adobe) - for local deployment + * @optimize Enable production optimizations - for local deployment + * @servers Server configuration file (deploy-servers.txt or deploy-servers.json) - for remote deployment + * @skipDockerCheck Skip Docker installation check on remote servers + * @blueGreen Enable Blue/Green deployment strategy (zero downtime) - for remote deployment */ function run( + boolean local=false, + boolean remote=false, string environment="production", string db="mysql", string cfengine="lucee", boolean optimize=true, - boolean force=false + string servers="", + boolean skipDockerCheck=false, + boolean blueGreen=false + ) { + arguments = reconstructArgs(arguments); + + // Validate that exactly one deployment type is specified + if (!arguments.local && !arguments.remote) { + error("Please specify deployment type: --local or --remote"); + } + + if (arguments.local && arguments.remote) { + error("Cannot specify both --local and --remote. Please choose one."); + } + + // Route to appropriate deployment method + if (arguments.local) { + deployLocal(arguments.environment, arguments.db, arguments.cfengine, arguments.optimize); + } else { + deployRemote(arguments.servers, arguments.skipDockerCheck, arguments.blueGreen); + } + } + + // ============================================================================= + // LOCAL DEPLOYMENT + // ============================================================================= + + private function deployLocal( + string environment, + string db, + string cfengine, + boolean optimize ) { // Welcome message print.line(); - print.boldMagentaLine("Wheels Docker Production Deployment"); + print.boldMagentaLine("Wheels Docker Local Deployment"); print.line(); - // Validate environment - local.supportedEnvironments = ["production", "staging"]; - if (!arrayContains(local.supportedEnvironments, lCase(arguments.environment))) { - error("Unsupported environment: #arguments.environment#. Please choose from: #arrayToList(local.supportedEnvironments)#"); - } - - // Check for existing files if force is not set - if (!arguments.force) { - local.existingFiles = []; - if (fileExists(fileSystemUtil.resolvePath("Dockerfile.production"))) { - arrayAppend(local.existingFiles, "Dockerfile.production"); + // Check for docker-compose file + local.useCompose = hasDockerComposeFile(); + + if (local.useCompose) { + print.greenLine("Found docker-compose file, will use docker-compose").toConsole(); + + // Check if Docker is installed locally + if (!isDockerInstalled()) { + error("Docker is not installed or not accessible. Please ensure Docker Desktop or Docker Engine is running."); } - if (fileExists(fileSystemUtil.resolvePath("docker-compose.production.yml"))) { - arrayAppend(local.existingFiles, "docker-compose.production.yml"); + + print.yellowLine("Starting services with docker-compose...").toConsole(); + + try { + // Stop existing containers + runLocalCommand(["docker", "compose", "down"]); + } catch (any e) { + print.yellowLine("No existing containers to stop").toConsole(); } - if (fileExists(fileSystemUtil.resolvePath("nginx.conf"))) { - arrayAppend(local.existingFiles, "nginx.conf"); + + // Start containers with build + print.yellowLine("Building and starting containers...").toConsole(); + runLocalCommand(["docker", "compose", "up", "-d", "--build"]); + + print.line(); + print.boldGreenLine("Docker Compose services started successfully!").toConsole(); + print.line(); + print.yellowLine("Check container status with: docker compose ps").toConsole(); + print.yellowLine("View logs with: docker compose logs -f").toConsole(); + print.line(); + + } else { + // Check for Dockerfile + local.dockerfilePath = getCWD() & "/Dockerfile"; + if (!fileExists(local.dockerfilePath)) { + error("No Dockerfile or docker-compose.yml found in current directory"); } - if (fileExists(fileSystemUtil.resolvePath("deploy.sh"))) { - arrayAppend(local.existingFiles, "deploy.sh"); + + print.greenLine("Found Dockerfile, will use standard docker commands").toConsole(); + + // Check if Docker is installed locally + if (!isDockerInstalled()) { + error("Docker is not installed or not accessible. Please ensure Docker Desktop or Docker Engine is running."); } - if (fileExists(fileSystemUtil.resolvePath(".env.#arguments.environment#.example"))) { - arrayAppend(local.existingFiles, ".env.#arguments.environment#.example"); + + // Extract port from Dockerfile + local.exposedPort = getDockerExposedPort(); + if (!len(local.exposedPort)) { + print.yellowLine("No EXPOSE directive found in Dockerfile, using default port 8080").toConsole(); + local.exposedPort = "8080"; + } else { + print.greenLine("Found EXPOSE port: " & local.exposedPort).toConsole(); } - - if (arrayLen(local.existingFiles)) { - print.line(); - print.yellowLine("The following production Docker files already exist:"); - for (local.file in local.existingFiles) { - print.line(" - #local.file#"); - } - print.line(); - - if (!confirm("Do you want to overwrite these files? [y/n]")) { - print.redLine("Operation cancelled."); - return; - } + + // Get project name for image/container naming + local.imageName = getProjectName(); + + print.yellowLine("Building Docker image...").toConsole(); + runLocalCommand(["docker", "build", "-t", local.imageName, "."]); + + print.yellowLine("Starting container...").toConsole(); + + try { + // Stop and remove existing container + runLocalCommand(["docker", "stop", local.imageName]); + runLocalCommand(["docker", "rm", local.imageName]); + } catch (any e) { + print.yellowLine("No existing container to remove").toConsole(); } + + // Run new container + runLocalCommand(["docker", "run", "-d", "--name", local.imageName, "-p", local.exposedPort & ":" & local.exposedPort, local.imageName]); + + print.line(); + print.boldGreenLine("Container started successfully!").toConsole(); + print.line(); + print.yellowLine("Container name: " & local.imageName).toConsole(); + print.yellowLine("Access your application at: http://localhost:" & local.exposedPort).toConsole(); + print.line(); + print.yellowLine("Check container status with: docker ps").toConsole(); + print.yellowLine("View logs with: docker logs -f " & local.imageName).toConsole(); + print.line(); } - - // Create production Docker files - createProductionDockerfile(arguments.cfengine, arguments.optimize); - createProductionDockerCompose(arguments.environment, arguments.db, arguments.cfengine); - createDeploymentScript(arguments.environment); - - print.line(); - print.greenLine("Production Docker configuration created successfully!"); - print.line(); - print.yellowLine("Next steps:"); - print.line("1. Review and customize the generated files"); - print.line("2. Build the production image: docker build -t wheels-app:latest -f Dockerfile.production ."); - print.line("3. Test locally: docker-compose -f docker-compose.production.yml up"); - print.line("4. Deploy using: ./deploy.sh"); - print.line(); } + + /** + * Check if Docker is installed locally + */ + private function isDockerInstalled() { + try { + var result = runLocalCommand(["docker", "--version"], false); + return (result.exitCode eq 0); + } catch (any e) { + return false; + } + } + + // ============================================================================= + // REMOTE DEPLOYMENT + // ============================================================================= + + private function deployRemote(string serversFile, boolean skipDockerCheck, boolean blueGreen) { + // Check for deploy-servers file (text or json) in current directory + var textConfigPath = fileSystemUtil.resolvePath("deploy-servers.txt"); + var jsonConfigPath = fileSystemUtil.resolvePath("deploy-servers.json"); + var servers = []; + + // If specific servers file is provided, use that + if (len(trim(arguments.serversFile))) { + var customPath = fileSystemUtil.resolvePath(arguments.serversFile); + if (!fileExists(customPath)) { + error("Server configuration file not found: #arguments.serversFile#"); + } + + if (right(arguments.serversFile, 5) == ".json") { + servers = loadServersFromConfig(arguments.serversFile); + } else { + servers = loadServersFromTextFile(arguments.serversFile); + } + } + // Otherwise, look for default files + else if (fileExists(textConfigPath)) { + print.cyanLine("Found deploy-servers.txt, loading server configuration").toConsole(); + servers = loadServersFromTextFile("deploy-servers.txt"); + } else if (fileExists(jsonConfigPath)) { + print.cyanLine("Found deploy-servers.json, loading server configuration").toConsole(); + servers = loadServersFromConfig("deploy-servers.json"); + } else { + error("No server configuration found. Create deploy-servers.txt or deploy-servers.json in your project root." & chr(10) & chr(10) & + "Example deploy-servers.txt:" & chr(10) & + "192.168.1.100 ubuntu 22" & chr(10) & + "production.example.com deploy" & chr(10) & chr(10) & + "Or see examples/deploy-servers.example.txt for more details."); + } - private function createProductionDockerfile(string cfengine, boolean optimize) { - local.dockerContent = ''; + if (arrayLen(servers) == 0) { + error("No servers configured for deployment"); + } - if (arguments.cfengine == "lucee") { - local.dockerContent = '## Multi-stage build for production -FROM lucee/lucee:5-nginx AS builder + print.line().boldCyanLine("Starting remote deployment to #arrayLen(servers)# server(s)...").toConsole(); + if (arguments.blueGreen) { + print.boldMagentaLine("Strategy: Blue/Green Deployment (Zero Downtime)").toConsole(); + } -## Install build dependencies -RUN apt-get update && apt-get install -y nodejs npm + // Deploy to all servers sequentially + deployToMultipleServersSequential(servers, arguments.skipDockerCheck, arguments.blueGreen); -## Copy application files -COPY . /build -WORKDIR /build + print.line().boldGreenLine("Deployment to all servers completed!").toConsole(); + } -## Install dependencies and build assets -RUN box install --production -RUN npm ci --only=production -RUN npm run build + /** + * Deploy to multiple servers sequentially + */ + private function deployToMultipleServersSequential(required array servers, boolean skipDockerCheck, boolean blueGreen) { + var successCount = 0; + var failureCount = 0; + var serverConfig = {}; + + for (var i = 1; i <= arrayLen(servers); i++) { + serverConfig = servers[i]; + print.line().boldCyanLine("---------------------------------------").toConsole(); + print.boldCyanLine("Deploying to server #i# of #arrayLen(servers)#: #serverConfig.host#").toConsole(); + print.line().boldCyanLine("---------------------------------------").toConsole(); + + try { + if (arguments.blueGreen) { + deployToSingleServerBlueGreen(serverConfig, arguments.skipDockerCheck); + } else { + deployToSingleServer(serverConfig, arguments.skipDockerCheck); + } + successCount++; + print.greenLine("Server #serverConfig.host# deployed successfully").toConsole(); + } catch (any e) { + failureCount++; + print.redLine("Failed to deploy to #serverConfig.host#: #e.message#").toConsole(); + } + } -## Production stage -FROM lucee/lucee:5-nginx + print.line().toConsole(); + print.boldCyanLine("Deployment Summary:").toConsole(); + print.greenLine(" Successful: #successCount#").toConsole(); + if (failureCount > 0) { + print.redLine(" Failed: #failureCount#").toConsole(); + } + } -## Copy built application -COPY --from=builder /build /var/www -WORKDIR /var/www + /** + * Deploy to a single server (Standard Strategy) + */ + private function deployToSingleServer(required struct serverConfig, boolean skipDockerCheck) { + var local = {}; + local.host = arguments.serverConfig.host; + local.user = arguments.serverConfig.user; + local.port = structKeyExists(arguments.serverConfig, "port") ? arguments.serverConfig.port : 22; + local.remoteDir = structKeyExists(arguments.serverConfig, "remoteDir") ? arguments.serverConfig.remoteDir : "/home/#local.user#/#local.user#-app"; + local.imageName = structKeyExists(arguments.serverConfig, "imageName") ? arguments.serverConfig.imageName : "#local.user#-app"; + + // Step 1: Check SSH connection + if (!testSSHConnection(local.host, local.user, local.port)) { + error("SSH connection failed to #local.host#. Check credentials and access."); + } + print.greenLine("SSH connection successful").toConsole(); -## Configure Lucee for production -RUN echo "this.mappings["/vendor"] = expandPath("./vendor");" >> /opt/lucee/web/Application.cfc'; + // Step 1.5: Check and install Docker if needed (unless skipped) + if (!arguments.skipDockerCheck) { + ensureDockerInstalled(local.host, local.user, local.port); } else { - local.dockerContent = '## Multi-stage build for production -FROM ortussolutions/commandbox:adobe2023 AS builder - -## Install build dependencies -RUN apt-get update && apt-get install -y nodejs npm - -## Copy application files -COPY . /build -WORKDIR /build - -## Install dependencies and build assets -RUN box install --production -RUN npm ci --only=production -RUN npm run build + print.yellowLine("Skipping Docker installation check (--skipDockerCheck flag is set)").toConsole(); + } -## Production stage -FROM ortussolutions/commandbox:adobe2023-alpine + // Step 2: Create remote directory + print.yellowLine("Creating remote directory...").toConsole(); + executeRemoteCommand(local.host, local.user, local.port, "mkdir -p " & local.remoteDir); -## Copy built application -COPY --from=builder /build /app -WORKDIR /app'; + // Step 3: Check for docker-compose file + local.useCompose = hasDockerComposeFile(); + if (local.useCompose) { + print.greenLine("Found docker-compose file, will use docker-compose").toConsole(); + } else { + // Extract port from Dockerfile for standard docker run + local.exposedPort = getDockerExposedPort(); + if (!len(local.exposedPort)) { + print.yellowLine(" No EXPOSE directive found in Dockerfile, using default port 8080").toConsole(); + local.exposedPort = "8080"; + } else { + print.greenLine("Found EXPOSE port: " & local.exposedPort).toConsole(); + } } - if (arguments.optimize) { - local.dockerContent &= ' - -## Production optimizations -ENV COMMANDBOX_CFENGINE_SAVECLASS=false -ENV COMMANDBOX_CFENGINE_BUFFEROUTPUT=false -ENV COMMANDBOX_CFENGINE_TEMPLATECACHE=true -ENV COMMANDBOX_CFENGINE_QUERYCACHE=true'; + // Step 4: Tar and upload project + local.timestamp = dateFormat(now(), "yyyymmdd") & timeFormat(now(), "HHmmss"); + local.tarFile = getTempFile(getTempDirectory(), "deploysrc_") & ".tar.gz"; + local.remoteTar = "/tmp/deploysrc_" & local.timestamp & ".tar.gz"; + + print.yellowLine("Creating source tarball...").toConsole(); + runLocalCommand(["tar", "-czf", local.tarFile, "-C", getCWD(), "."]); + + print.yellowLine(" Uploading to remote server...").toConsole(); + var scpCmd = ["scp", "-P", local.port]; + scpCmd.addAll(getSSHOptions()); + scpCmd.addAll([local.tarFile, local.user & "@" & local.host & ":" & local.remoteTar]); + runLocalCommand(scpCmd); + fileDelete(local.tarFile); + + // Step 5: Build and run on remote + local.deployScript = ""; + local.deployScript &= chr(35) & "!/bin/bash" & chr(10); + local.deployScript &= "set -e" & chr(10); + local.deployScript &= "echo 'Extracting source to " & local.remoteDir & " ...'" & chr(10); + local.deployScript &= "mkdir -p " & local.remoteDir & chr(10); + local.deployScript &= "tar --overwrite -xzf " & local.remoteTar & " -C " & local.remoteDir & chr(10); + local.deployScript &= "cd " & local.remoteDir & chr(10); + + if (local.useCompose) { + // Use docker-compose with proper permissions + local.deployScript &= "echo 'Starting services with docker-compose...'" & chr(10); + + // Check if user is in docker group and can run docker without sudo + local.deployScript &= "if groups | grep -q docker && [ -w /var/run/docker.sock ]; then" & chr(10); + local.deployScript &= " ## User has docker access, run without sudo" & chr(10); + local.deployScript &= " docker compose down || true" & chr(10); + local.deployScript &= " docker compose up -d --build" & chr(10); + local.deployScript &= "else" & chr(10); + local.deployScript &= " ## User needs sudo for docker" & chr(10); + local.deployScript &= " sudo docker compose down || true" & chr(10); + local.deployScript &= " sudo docker compose up -d --build" & chr(10); + local.deployScript &= "fi" & chr(10); + local.deployScript &= "echo 'Docker Compose services started!'" & chr(10); + } else { + // Use standard docker commands with proper permissions + local.deployScript &= "echo 'Building Docker image...'" & chr(10); + + // Check if user is in docker group and can run docker without sudo + local.deployScript &= "if groups | grep -q docker && [ -w /var/run/docker.sock ]; then" & chr(10); + local.deployScript &= " ## User has docker access, run without sudo" & chr(10); + local.deployScript &= " docker build -t " & local.imageName & " ." & chr(10); + local.deployScript &= " echo 'Starting container...'" & chr(10); + local.deployScript &= " docker stop " & local.imageName & " || true" & chr(10); + local.deployScript &= " docker rm " & local.imageName & " || true" & chr(10); + local.deployScript &= " docker run -d --name " & local.imageName & " -p " & local.exposedPort & ":" & local.exposedPort & " " & local.imageName & chr(10); + local.deployScript &= "else" & chr(10); + local.deployScript &= " ## User needs sudo for docker" & chr(10); + local.deployScript &= " sudo docker build -t " & local.imageName & " ." & chr(10); + local.deployScript &= " echo 'Starting container...'" & chr(10); + local.deployScript &= " sudo docker stop " & local.imageName & " || true" & chr(10); + local.deployScript &= " sudo docker rm " & local.imageName & " || true" & chr(10); + local.deployScript &= " sudo docker run -d --name " & local.imageName & " -p " & local.exposedPort & ":" & local.exposedPort & " " & local.imageName & chr(10); + local.deployScript &= "fi" & chr(10); } - local.dockerContent &= ' + local.deployScript &= "echo 'Deployment complete!'" & chr(10); -## Security hardening -RUN rm -rf /app/tests /app/.git /app/docker -RUN chmod -R 755 /app + // Normalize line endings + local.deployScript = replace(local.deployScript, chr(13) & chr(10), chr(10), "all"); + local.deployScript = replace(local.deployScript, chr(13), chr(10), "all"); -## Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ - CMD curl -f http://localhost:8080/ || exit 1 + local.tempFile = getTempFile(getTempDirectory(), "deploy_"); + fileWrite(local.tempFile, local.deployScript); -## Expose port -EXPOSE 8080 + print.yellowLine("Uploading deployment script...").toConsole(); + var scpScriptCmd = ["scp", "-P", local.port]; + scpScriptCmd.addAll(getSSHOptions()); + scpScriptCmd.addAll([local.tempFile, local.user & "@" & local.host & ":/tmp/deploy-simple.sh"]); + runLocalCommand(scpScriptCmd); + fileDelete(local.tempFile); -## Start the application -CMD ["box", "server", "start", "--console", "--force", "--production"]'; + print.yellowLine("Executing deployment script remotely...").toConsole(); + // Use interactive command to prevent hanging and allow Ctrl+C + var execCmd = ["ssh", "-p", local.port]; + execCmd.addAll(getSSHOptions()); + execCmd.addAll([local.user & "@" & local.host, "chmod +x /tmp/deploy-simple.sh && bash /tmp/deploy-simple.sh"]); + + runInteractiveCommand(execCmd); - file action='write' file='#fileSystemUtil.resolvePath("Dockerfile.production")#' mode='777' output='#trim(local.dockerContent)#'; - print.greenLine("Created Dockerfile.production"); + print.boldGreenLine("Deployment to #local.host# completed successfully!").toConsole(); } - private function createProductionDockerCompose(string environment, string db, string cfengine) { - local.composeContent = 'version: "3.8" - -services: - app: - image: wheels-app:latest - restart: always - ports: - - "80:8080" - environment: - ENVIRONMENT: #arguments.environment# - ## Add your production environment variables here - ## DB_HOST: ${DB_HOST} - ## DB_USER: ${DB_USER} - ## DB_PASSWORD: ${DB_PASSWORD} - volumes: - - app_logs:/app/logs - - app_uploads:/app/uploads - deploy: - replicas: 2 - update_config: - parallelism: 1 - delay: 10s - restart_policy: - condition: on-failure - delay: 5s - max_attempts: 3'; - - if (arguments.db != "h2") { - local.composeContent &= ' - depends_on: - - db - - db: - image: '; - - switch(arguments.db) { - case "mysql": - local.composeContent &= 'mysql:8.0'; - break; - case "postgres": - local.composeContent &= 'postgres:15-alpine'; - break; - case "mssql": - local.composeContent &= 'mcr.microsoft.com/mssql/server:2019-latest'; - break; - } + /** + * Deploy to a single server (Blue/Green Strategy) + */ + private function deployToSingleServerBlueGreen(required struct serverConfig, boolean skipDockerCheck) { + var local = {}; + local.host = arguments.serverConfig.host; + local.user = arguments.serverConfig.user; + local.port = structKeyExists(arguments.serverConfig, "port") ? arguments.serverConfig.port : 22; + local.remoteDir = structKeyExists(arguments.serverConfig, "remoteDir") ? arguments.serverConfig.remoteDir : "/home/#local.user#/#local.user#-app"; + local.imageName = structKeyExists(arguments.serverConfig, "imageName") ? arguments.serverConfig.imageName : "#local.user#-app"; + + // Step 1: Check SSH connection + if (!testSSHConnection(local.host, local.user, local.port)) { + error("SSH connection failed to #local.host#. Check credentials and access."); + } + print.greenLine("SSH connection successful").toConsole(); - local.composeContent &= ' - restart: always - environment: - ## Configure your production database credentials - ## These should come from environment variables or secrets - DB_PASSWORD: ${DB_PASSWORD} - volumes: - - db_data:/var/lib/mysql ## Adjust path based on database type - deploy: - placement: - constraints: - - node.role == manager'; + // Step 1.5: Check and install Docker if needed + if (!arguments.skipDockerCheck) { + ensureDockerInstalled(local.host, local.user, local.port); } - local.composeContent &= ' - - nginx: - image: nginx:alpine - restart: always - ports: - - "443:443" - volumes: - - ./nginx.conf:/etc/nginx/nginx.conf:ro - - ssl_certs:/etc/nginx/ssl - depends_on: - - app - -volumes: - app_logs: - app_uploads: - db_data: - ssl_certs:'; - - file action='write' file='#fileSystemUtil.resolvePath("docker-compose.production.yml")#' mode='777' output='#trim(local.composeContent)#'; - print.greenLine("Created docker-compose.production.yml"); - - // Create basic nginx config - local.nginxContent = 'events { - worker_connections 1024; -} - -http { - upstream wheels_app { - server app:8080; - } + // Step 2: Create remote directory + print.yellowLine("Creating remote directory...").toConsole(); + executeRemoteCommand(local.host, local.user, local.port, "mkdir -p " & local.remoteDir); - server { - listen 443 ssl http2; - server_name your-domain.com; + // Step 3: Determine Port + local.exposedPort = getDockerExposedPort(); + if (!len(local.exposedPort)) { + print.yellowLine(" No EXPOSE directive found in Dockerfile, using default port 8080").toConsole(); + local.exposedPort = "8080"; + } else { + print.greenLine("Found EXPOSE port: " & local.exposedPort).toConsole(); + } + + // Step 4: Tar and upload project + local.timestamp = dateFormat(now(), "yyyymmdd") & timeFormat(now(), "HHmmss"); + local.tarFile = getTempFile(getTempDirectory(), "deploysrc_") & ".tar.gz"; + local.remoteTar = "/tmp/deploysrc_" & local.timestamp & ".tar.gz"; + + print.yellowLine("Creating source tarball...").toConsole(); + runLocalCommand(["tar", "-czf", local.tarFile, "-C", getCWD(), "."]); + + print.yellowLine(" Uploading to remote server...").toConsole(); + var scpCmd = ["scp", "-P", local.port]; + scpCmd.addAll(getSSHOptions()); + scpCmd.addAll([local.tarFile, local.user & "@" & local.host & ":" & local.remoteTar]); + runLocalCommand(scpCmd); + fileDelete(local.tarFile); + + // Step 5: Generate Blue/Green Deployment Script + local.deployScript = ""; + local.deployScript &= chr(35) & "!/bin/bash" & chr(10); + local.deployScript &= "set -e" & chr(10); + + // Setup variables + local.deployScript &= "APP_NAME='" & local.imageName & "'" & chr(10); + local.deployScript &= "APP_PORT='" & local.exposedPort & "'" & chr(10); + local.deployScript &= "REMOTE_DIR='" & local.remoteDir & "'" & chr(10); + local.deployScript &= "REMOTE_TAR='" & local.remoteTar & "'" & chr(10); + local.deployScript &= "NETWORK_NAME='web'" & chr(10); + local.deployScript &= "PROXY_NAME='nginx-proxy'" & chr(10); + + // Extract source + local.deployScript &= "echo 'Extracting source to ' $REMOTE_DIR ' ...'" & chr(10); + local.deployScript &= "mkdir -p $REMOTE_DIR" & chr(10); + local.deployScript &= "tar --overwrite -xzf $REMOTE_TAR -C $REMOTE_DIR" & chr(10); + local.deployScript &= "cd $REMOTE_DIR" & chr(10); + + // Build Image + local.deployScript &= "echo 'Building Docker image...'" & chr(10); + local.deployScript &= "docker build -t $APP_NAME:latest ." & chr(10); + + // Ensure Network Exists + local.deployScript &= "echo 'Ensuring Docker network exists...'" & chr(10); + local.deployScript &= "docker network create $NETWORK_NAME 2>/dev/null || true" & chr(10); + + // Ensure Nginx Proxy Exists + local.deployScript &= "if [ -z ""$(docker ps -q -f name=$PROXY_NAME)"" ]; then" & chr(10); + local.deployScript &= " if [ -n ""$(docker ps -aq -f name=$PROXY_NAME)"" ]; then" & chr(10); + local.deployScript &= " echo 'Starting existing nginx-proxy...'" & chr(10); + local.deployScript &= " docker start $PROXY_NAME" & chr(10); + local.deployScript &= " else" & chr(10); + local.deployScript &= " echo 'Creating and starting nginx-proxy...'" & chr(10); + // Create a simple nginx config for the proxy + local.deployScript &= " mkdir -p /etc/nginx/conf.d" & chr(10); + local.deployScript &= " docker run -d --name $PROXY_NAME --network $NETWORK_NAME -p 80:80 nginx:alpine" & chr(10); + local.deployScript &= " fi" & chr(10); + local.deployScript &= "fi" & chr(10); + + // Determine Active Color + local.deployScript &= "IS_BLUE_RUNNING=$(docker ps -q -f name=${APP_NAME}-blue)" & chr(10); + local.deployScript &= "if [ -n ""$IS_BLUE_RUNNING"" ]; then" & chr(10); + local.deployScript &= " TARGET_COLOR='green'" & chr(10); + local.deployScript &= " CURRENT_COLOR='blue'" & chr(10); + local.deployScript &= "else" & chr(10); + local.deployScript &= " TARGET_COLOR='blue'" & chr(10); + local.deployScript &= " CURRENT_COLOR='green'" & chr(10); + local.deployScript &= "fi" & chr(10); + + local.deployScript &= "TARGET_CONTAINER=${APP_NAME}-${TARGET_COLOR}" & chr(10); + local.deployScript &= "echo 'Current active color: ' $CURRENT_COLOR" & chr(10); + local.deployScript &= "echo 'Deploying to: ' $TARGET_COLOR" & chr(10); + + // Stop Target if exists (cleanup from failed deploy or old state) + local.deployScript &= "docker stop $TARGET_CONTAINER 2>/dev/null || true" & chr(10); + local.deployScript &= "docker rm $TARGET_CONTAINER 2>/dev/null || true" & chr(10); + + // Start New Container + local.deployScript &= "echo 'Starting ' $TARGET_CONTAINER ' ...'" & chr(10); + local.deployScript &= "docker run -d --name $TARGET_CONTAINER --network $NETWORK_NAME --restart unless-stopped $APP_NAME:latest" & chr(10); + + // Wait for container to be ready (simple sleep for now, could be curl loop) + local.deployScript &= "echo 'Waiting for container to initialize...'" & chr(10); + local.deployScript &= "sleep 5" & chr(10); + + // Update Nginx Configuration + local.deployScript &= "echo 'Updating Nginx configuration...'" & chr(10); + local.deployScript &= "cat > nginx.conf <&1"]); + + local.sudoCheckResult = runLocalCommand(sudoCheckCmd); + + if (local.sudoCheckResult.exitCode neq 0) { + print.line().toConsole(); + print.boldRedLine("ERROR: User '#arguments.user#' does not have passwordless sudo access on #arguments.host#!").toConsole(); + print.line().toConsole(); + print.yellowLine("To enable passwordless sudo for Docker installation, follow these steps:").toConsole(); + print.line().toConsole(); + print.cyanLine(" 1. SSH into the server:").toConsole(); + print.boldWhiteLine(" ssh " & arguments.user & "@" & arguments.host & (arguments.port neq 22 ? " -p " & arguments.port : "")).toConsole(); + print.line().toConsole(); + print.cyanLine(" 2. Edit the sudoers file:").toConsole(); + print.boldWhiteLine(" sudo visudo").toConsole(); + print.line().toConsole(); + print.cyanLine(" 3. Add this line at the end of the file:").toConsole(); + print.boldWhiteLine(" " & arguments.user & " ALL=(ALL) NOPASSWD:ALL").toConsole(); + print.line().toConsole(); + print.cyanLine(" 4. Save and exit:").toConsole(); + print.line(" - Press Ctrl+X").toConsole(); + print.line(" - Press Y to confirm").toConsole(); + print.line(" - Press Enter to save").toConsole(); + print.line().toConsole(); + print.yellowLine("OR, manually install Docker on the remote server:").toConsole(); + print.line().toConsole(); + print.cyanLine(" For Ubuntu/Debian:").toConsole(); + print.line(" curl -fsSL https://get.docker.com -o get-docker.sh").toConsole(); + print.line(" sudo sh get-docker.sh").toConsole(); + print.line(" sudo usermod -aG docker " & arguments.user).toConsole(); + print.line(" newgrp docker").toConsole(); + print.line().toConsole(); + print.cyanLine(" For CentOS/RHEL:").toConsole(); + print.line(" curl -fsSL https://get.docker.com -o get-docker.sh").toConsole(); + print.line(" sudo sh get-docker.sh").toConsole(); + print.line(" sudo usermod -aG docker " & arguments.user).toConsole(); + print.line(" newgrp docker").toConsole(); + print.line().toConsole(); + print.boldYellowLine("After configuring passwordless sudo or installing Docker, run the deployment again.").toConsole(); + print.line().toConsole(); + + error("Cannot install Docker: User '" & arguments.user & "' requires passwordless sudo access on " & arguments.host); + } + + print.greenLine("User has sudo access").toConsole(); + + // Detect OS type + var osCmd = ["ssh", "-p", arguments.port]; + osCmd.addAll(getSSHOptions()); + osCmd.addAll([arguments.user & "@" & arguments.host, "cat /etc/os-release"]); + + local.osResult = runLocalCommand(osCmd); + + if (local.osResult.exitCode neq 0) { + error("Failed to detect OS type on remote server"); + } + + // Determine installation script based on OS + local.installScript = ""; + + if (findNoCase("ubuntu", local.osResult.output) || findNoCase("debian", local.osResult.output)) { + local.installScript = getDockerInstallScriptDebian(); + print.cyanLine("Detected Debian/Ubuntu system").toConsole(); + } else if (findNoCase("centos", local.osResult.output) || findNoCase("rhel", local.osResult.output) || findNoCase("fedora", local.osResult.output)) { + local.installScript = getDockerInstallScriptRHEL(); + print.cyanLine("Detected RHEL/CentOS/Fedora system").toConsole(); + } else { + error("Unsupported OS. Docker installation is only automated for Ubuntu/Debian and RHEL/CentOS/Fedora systems."); } + + // Create temp file with install script + local.tempFile = getTempFile(getTempDirectory(), "docker_install_"); + + // Normalize line endings to Unix format (LF only) + local.installScript = replace(local.installScript, chr(13) & chr(10), chr(10), "all"); + local.installScript = replace(local.installScript, chr(13), chr(10), "all"); + + fileWrite(local.tempFile, local.installScript); + + // Upload install script + print.yellowLine("Uploading Docker installation script...").toConsole(); + var scpInstallCmd = ["scp", "-P", arguments.port]; + scpInstallCmd.addAll(getSSHOptions()); + scpInstallCmd.addAll([local.tempFile, arguments.user & "@" & arguments.host & ":/tmp/install-docker.sh"]); + runLocalCommand(scpInstallCmd); + fileDelete(local.tempFile); + + // Execute install script + print.yellowLine("Installing Docker (this may take a few minutes)...").toConsole(); + var installCmd = ["ssh", "-p", arguments.port]; + installCmd.addAll(getSSHOptions()); + // Increase timeout for installation + installCmd.addAll(["-o", "ServerAliveInterval=30", "-o", "ServerAliveCountMax=10"]); + installCmd.addAll([arguments.user & "@" & arguments.host, "sudo bash /tmp/install-docker.sh"]); + + local.installResult = runLocalCommand(installCmd); + + if (local.installResult.exitCode neq 0) { + error("Failed to install Docker on remote server"); + } + + print.boldGreenLine("Docker installed successfully!").toConsole(); + + // Verify installation + var verifyCmd = ["ssh", "-p", arguments.port]; + verifyCmd.addAll(getSSHOptions()); + verifyCmd.addAll([arguments.user & "@" & arguments.host, "docker --version"]); + + local.verifyResult = runLocalCommand(verifyCmd); + + if (local.verifyResult.exitCode eq 0) { + print.greenLine("Docker version: " & trim(local.verifyResult.output)).toConsole(); + } + + return true; } -}'; - - file action='write' file='#fileSystemUtil.resolvePath("nginx.conf")#' mode='777' output='#trim(local.nginxContent)#'; - print.greenLine("Created nginx.conf"); + + /** + * Check if Docker Compose is available + */ + private function checkDockerCompose(string host, string user, numeric port) { + var local = {}; + + // Check for docker compose (new version) + var composeCmd = ["ssh", "-p", arguments.port]; + composeCmd.addAll(getSSHOptions()); + composeCmd.addAll([arguments.user & "@" & arguments.host, "docker compose version"]); + + local.composeResult = runLocalCommand(composeCmd); + + if (local.composeResult.exitCode eq 0) { + print.greenLine("Docker Compose is available").toConsole(); + print.cyanLine("Compose version: " & trim(local.composeResult.output)).toConsole(); + return true; + } + + // Check for docker-compose (old version) + var oldComposeCmd = ["ssh", "-p", arguments.port]; + oldComposeCmd.addAll(getSSHOptions()); + oldComposeCmd.addAll([arguments.user & "@" & arguments.host, "docker-compose --version"]); + + local.oldComposeResult = runLocalCommand(oldComposeCmd); + + if (local.oldComposeResult.exitCode eq 0) { + print.greenLine("Docker Compose (standalone) is available").toConsole(); + print.cyanLine("Compose version: " & trim(local.oldComposeResult.output)).toConsole(); + return true; + } + + print.yellowLine("Docker Compose is not available, but docker compose plugin should be included in modern Docker installations").toConsole(); + return false; } + + /** + * Get Docker installation script for Debian/Ubuntu + */ + private function getDockerInstallScriptDebian() { + var script = '##!/bin/bash +set -e - private function createDeploymentScript(string environment) { - local.scriptContent = '##!/bin/bash +echo "Installing Docker on Debian/Ubuntu..." -## Wheels Docker Deployment Script -## Environment: #arguments.environment# +## Set non-interactive mode +export DEBIAN_FRONTEND=noninteractive -set -e +## Update package index +apt-get update -echo "Starting deployment to #arguments.environment#..." +## Install prerequisites +apt-get install -y ca-certificates curl gnupg lsb-release -## Load environment variables -if [ -f .env.#arguments.environment# ]; then - export $(cat .env.#arguments.environment# | grep -v "^##" | xargs) -fi - -## Build the production image -echo "Building production image..." -docker build -t wheels-app:latest -f Dockerfile.production . +## Add Docker GPG key +mkdir -p /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg -## Tag image with version -VERSION=$(date +%Y%m%d%H%M%S) -docker tag wheels-app:latest wheels-app:$VERSION +## Set up repository +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null -## Deploy using docker-compose -echo "Deploying application..." -docker-compose -f docker-compose.production.yml up -d +## Install Docker with automatic yes to all prompts +apt-get update +apt-get install -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -## Wait for health checks -echo "Waiting for application to be healthy..." -sleep 30 +## Start and enable Docker +systemctl start docker +systemctl enable docker -## Check deployment status -if docker-compose -f docker-compose.production.yml ps | grep -q "healthy"; then - echo "Deployment successful!" +## Wait for Docker to be ready +sleep 3 - ## Clean up old images (keep last 5) - docker images | grep wheels-app | tail -n +6 | awk "{print $3}" | xargs -r docker rmi -else - echo "Deployment failed! Rolling back..." - docker-compose -f docker-compose.production.yml down - exit 1 +## Add current user to docker group (determine actual user if running via sudo) +ACTUAL_USER="${SUDO_USER:-$USER}" +if [ -n "$ACTUAL_USER" ] && [ "$ACTUAL_USER" != "root" ]; then + usermod -aG docker $ACTUAL_USER + echo "Added user $ACTUAL_USER to docker group" fi -echo "Deployment complete!"'; +## Set proper permissions on docker socket +chmod 666 /var/run/docker.sock + +echo "Docker installation completed successfully!" +'; + return script; + } + + /** + * Get Docker installation script for RHEL/CentOS + */ + private function getDockerInstallScriptRHEL() { + var script = '##!/bin/bash +set -e - file action='write' file='#fileSystemUtil.resolvePath("deploy.sh")#' mode='777' output='#trim(local.scriptContent)#'; +echo "Installing Docker on RHEL/CentOS/Fedora..." - // Make script executable - if (findNoCase("Windows", server.os.name) == 0) { - command("chmod +x deploy.sh").run(); - } +## Install prerequisites +yum install -y yum-utils - print.greenLine("Created deployment script: deploy.sh"); +## Add Docker repository +yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo - // Create example environment file - local.envContent = '## Environment variables for #arguments.environment# -## Copy this to .env.#arguments.environment# and fill in your values +## Install Docker +yum install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -## Database configuration -DB_HOST=your-db-host -DB_USER=your-db-user -DB_PASSWORD=your-db-password -DB_NAME=your-db-name +## Start and enable Docker +systemctl start docker +systemctl enable docker -## Application settings -APP_URL=https://your-domain.com -APP_KEY=your-secret-key +## Wait for Docker to be ready +sleep 3 -## Email configuration -MAIL_SERVER=smtp.example.com -MAIL_USERNAME=your-email@example.com -MAIL_PASSWORD=your-email-password +## Add current user to docker group +ACTUAL_USER="${SUDO_USER:-$USER}" +if [ -n "$ACTUAL_USER" ] && [ "$ACTUAL_USER" != "root" ]; then + usermod -aG docker $ACTUAL_USER + echo "Added user $ACTUAL_USER to docker group" +fi -## Other services -REDIS_URL=redis://localhost:6379 -S3_BUCKET=your-s3-bucket'; +## Set proper permissions on docker socket +chmod 666 /var/run/docker.sock - file action='write' file='#fileSystemUtil.resolvePath(".env.#arguments.environment#.example")#' mode='777' output='#trim(local.envContent)#'; - print.greenLine("Created example environment file: .env.#arguments.environment#.example"); +echo "Docker installation completed successfully!" +'; + return script; } -} + +} \ No newline at end of file diff --git a/cli/src/commands/wheels/docker/exec.cfc b/cli/src/commands/wheels/docker/exec.cfc new file mode 100644 index 0000000000..ec2a089b92 --- /dev/null +++ b/cli/src/commands/wheels/docker/exec.cfc @@ -0,0 +1,205 @@ +/** + * Execute commands in deployed containers + * + * {code:bash} + * wheels docker exec "ls -la" + * wheels docker exec "box repl" --interactive + * wheels docker exec "mysql -u root -p" service=db --interactive + * wheels docker exec "tail -f logs/application.log" servers=web1.example.com + * {code} + */ +component extends="DockerCommand" { + + /** + * @command Command to execute in container + * @servers Specific servers to execute on (comma-separated list) + * @service Service to execute in: app or db (default: app) + * @interactive Run command interactively (default: false) + */ + function run( + required string command, + string servers="", + string service="app", + boolean interactive=false + ) { + arguments = reconstructArgs(arguments); + + // Load servers + var serverList = []; + + // Check for deploy-servers file (text or json) in current directory + var textConfigPath = fileSystemUtil.resolvePath("deploy-servers.txt"); + var jsonConfigPath = fileSystemUtil.resolvePath("deploy-servers.json"); + + // If specific servers argument is provided + if (len(trim(arguments.servers))) { + // Check if it's a file + if (fileExists(fileSystemUtil.resolvePath(arguments.servers))) { + if (right(arguments.servers, 5) == ".json") { + serverList = loadServersFromConfig(arguments.servers); + } else { + serverList = loadServersFromTextFile(arguments.servers); + } + } else { + // Treat as comma-separated list of hosts + var hosts = listToArray(arguments.servers, ","); + for (var host in hosts) { + arrayAppend(serverList, { + "host": trim(host), + "user": "deploy", // Default user + "port": 22, + "remoteDir": "/home/deploy/app", // Default + "imageName": "app" // Default + }); + } + } + } + // Otherwise, look for default files + else if (fileExists(textConfigPath)) { + print.cyanLine("Found deploy-servers.txt, loading server configuration").toConsole(); + serverList = loadServersFromTextFile("deploy-servers.txt"); + } else if (fileExists(jsonConfigPath)) { + print.cyanLine("Found deploy-servers.json, loading server configuration").toConsole(); + serverList = loadServersFromConfig("deploy-servers.json"); + } else { + error("No server configuration found. Create deploy-servers.txt or deploy-servers.json in your project root."); + } + + if (arrayLen(serverList) == 0) { + error("No servers configured for execution"); + } + + // Validate interactive mode with multiple servers + if (arguments.interactive && arrayLen(serverList) > 1) { + error("Cannot run interactive commands on multiple servers simultaneously. Please specify a single server using 'servers=host'."); + } + + print.line(); + print.boldMagentaLine("Wheels Deploy Remote Execution"); + print.line("==================================================").toConsole(); + + for (var serverConfig in serverList) { + if (arrayLen(serverList) > 1) { + print.line().toConsole(); + print.boldCyanLine("=== Server: #serverConfig.host# ===").toConsole(); + print.line().toConsole(); + } + + try { + executeInContainer(serverConfig, arguments.command, arguments.service, arguments.interactive); + } catch (any e) { + // Check for UserInterruptException (CommandBox specific) or standard InterruptedException + if (findNoCase("UserInterruptException", e.message) || findNoCase("InterruptedException", e.message) || (structKeyExists(e, "type") && findNoCase("UserInterruptException", e.type))) { + print.line().toConsole(); + print.yellowLine("Command interrupted by user.").toConsole(); + break; + } + print.redLine("Failed to execute command on #serverConfig.host#: #e.message#").toConsole(); + } + } + } + + private function executeInContainer( + struct serverConfig, + string command, + string service, + boolean interactive + ) { + var local = {}; + local.host = arguments.serverConfig.host; + local.user = arguments.serverConfig.user; + local.port = structKeyExists(arguments.serverConfig, "port") ? arguments.serverConfig.port : 22; + local.imageName = structKeyExists(arguments.serverConfig, "imageName") ? arguments.serverConfig.imageName : "#local.user#-app"; + + // 1. Check SSH Connection + if (!testSSHConnection(local.host, local.user, local.port)) { + throw("SSH connection failed"); + } + + // 2. Determine Container Name + var containerName = ""; + + if (arguments.service == "app") { + // Logic similar to logs.cfc to find active app container + var findCmd = ["ssh", "-p", local.port]; + findCmd.addAll(getSSHOptions()); + // Filter by name (including blue/green variants) + findCmd.addAll([local.user & "@" & local.host, "docker ps --format '{{.Names}}' --filter name=" & local.imageName]); + + var findResult = runLocalCommand(findCmd, false); + var runningContainers = listToArray(trim(findResult.output), chr(10)); + + if (arrayLen(runningContainers) > 0) { + // Default to first found + containerName = runningContainers[1]; + + // Try to find exact match or blue/green + for (var container in runningContainers) { + if (container == local.imageName || container == local.imageName & "-blue" || container == local.imageName & "-green") { + containerName = container; + break; + } + } + } + } else { + // Attempt to find service container (e.g. db) + // Try common patterns: [project]-[service], [service] + var patterns = [ + local.imageName & "-" & arguments.service, + arguments.service + ]; + + for (var pattern in patterns) { + var findServiceCmd = ["ssh", "-p", local.port]; + findServiceCmd.addAll(getSSHOptions()); + findServiceCmd.addAll([local.user & "@" & local.host, "docker ps --format '{{.Names}}' --filter name=" & pattern]); + + var serviceResult = runLocalCommand(findServiceCmd, false); + if (serviceResult.exitCode == 0 && len(trim(serviceResult.output))) { + containerName = listFirst(trim(serviceResult.output), chr(10)); + break; + } + } + } + + if (!len(containerName)) { + throw("Could not find running container for service: " & arguments.service); + } + + // 3. Construct Docker Exec Command + var execCmd = ["ssh", "-p", local.port]; + + // If interactive, we need TTY allocation for SSH + if (arguments.interactive) { + execCmd.add("-t"); + } + + execCmd.addAll(getSSHOptions()); + + var dockerCmd = "docker exec"; + + // If interactive, we need interactive mode for Docker + if (arguments.interactive) { + dockerCmd &= " -it"; + } + + dockerCmd &= " " & containerName & " " & arguments.command; + + execCmd.addAll([local.user & "@" & local.host, dockerCmd]); + + // 4. Execute + print.cyanLine("Executing: " & arguments.command).toConsole(); + print.cyanLine("Container: " & containerName).toConsole(); + print.line().toConsole(); + + // Use runInteractiveCommand for both interactive and non-interactive + // For non-interactive, it streams output nicely. + // For interactive, we pass true to inheritInput. + var result = runInteractiveCommand(execCmd, arguments.interactive); + + if (result.exitCode != 0 && result.exitCode != 130) { + throw("Command failed with exit code: " & result.exitCode); + } + } + +} diff --git a/cli/src/commands/wheels/docker/login.cfc b/cli/src/commands/wheels/docker/login.cfc new file mode 100644 index 0000000000..ee40aaa969 --- /dev/null +++ b/cli/src/commands/wheels/docker/login.cfc @@ -0,0 +1,62 @@ +/** + * Login to a container registry + * + * {code:bash} + * wheels docker login --registry=dockerhub --username=myuser + * wheels docker login --registry=ecr --image=123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest + * {code} + */ +component extends="DockerCommand" { + + /** + * @registry Registry type: dockerhub, ecr, gcr, acr, ghcr, private (default: dockerhub) + * @username Registry username (required for dockerhub, ghcr, private) + * @password Registry password or token (optional, will prompt if empty) + * @image Image name (optional, but required for ECR/ACR to determine region/registry) + * @local Execute login locally (default: true) + */ + function run( + string registry="dockerhub", + string username="", + string password="", + string image="", + boolean local=true + ) { + arguments = reconstructArgs(arguments); + + // Validate registry type + var supportedRegistries = ["dockerhub", "ecr", "gcr", "acr", "ghcr", "private"]; + if (!arrayContains(supportedRegistries, lCase(arguments.registry))) { + error("Unsupported registry: #arguments.registry#. Supported: #arrayToList(supportedRegistries)#"); + } + + // Check if Docker is installed locally + if (arguments.local && !isDockerInstalled()) { + error("Docker is not installed or not accessible. Please ensure Docker Desktop or Docker Engine is running."); + } + + // Call loginToRegistry from base component + loginToRegistry( + registry=arguments.registry, + image=arguments.image, + username=arguments.username, + password=arguments.password, + isLocal=arguments.local + ); + + // Save configuration for push command + var config = { + "registry": arguments.registry, + "username": arguments.username, + "image": arguments.image + }; + + try { + var configPath = fileSystemUtil.resolvePath("docker-config.json"); + fileWrite(configPath, serializeJSON(config)); + print.greenLine("Configuration saved to docker-config.json").toConsole(); + } catch (any e) { + print.yellowLine("Warning: Could not save configuration: #e.message#").toConsole(); + } + } +} diff --git a/cli/src/commands/wheels/docker/logs.cfc b/cli/src/commands/wheels/docker/logs.cfc new file mode 100644 index 0000000000..12485a3846 --- /dev/null +++ b/cli/src/commands/wheels/docker/logs.cfc @@ -0,0 +1,217 @@ +/** + * View deployment logs from servers + * + * {code:bash} + * wheels docker logs + * wheels docker logs --follow + * wheels docker logs tail=50 servers=web1.example.com + * wheels docker logs service=db + * wheels docker logs since=1h + * {code} + */ +component extends="DockerCommand" { + + /** + * @servers Specific servers to check (comma-separated list) + * @tail Number of lines to show (default: 100) + * @follow Follow log output in real-time (default: false) + * @service Service to show logs for: app or db (default: app) + * @since Show logs since timestamp (e.g., "2023-01-01", "1h", "5m") + */ + function run( + string servers="", + string tail="100", + boolean follow=false, + string service="app", + string since="" + ) { + arguments = reconstructArgs(arguments); + + // Load servers + var serverList = []; + + // Check for deploy-servers file (text or json) in current directory + var textConfigPath = fileSystemUtil.resolvePath("deploy-servers.txt"); + var jsonConfigPath = fileSystemUtil.resolvePath("deploy-servers.json"); + + // If specific servers argument is provided + if (len(trim(arguments.servers))) { + // Check if it's a file + if (fileExists(fileSystemUtil.resolvePath(arguments.servers))) { + if (right(arguments.servers, 5) == ".json") { + serverList = loadServersFromConfig(arguments.servers); + } else { + serverList = loadServersFromTextFile(arguments.servers); + } + } else { + // Treat as comma-separated list of hosts + var hosts = listToArray(arguments.servers, ","); + for (var host in hosts) { + arrayAppend(serverList, { + "host": trim(host), + "user": "deploy", // Default user + "port": 22, + "remoteDir": "/home/deploy/app", // Default + "imageName": "app" // Default + }); + } + } + } + // Otherwise, look for default files + else if (fileExists(textConfigPath)) { + print.cyanLine("Found deploy-servers.txt, loading server configuration").toConsole(); + serverList = loadServersFromTextFile("deploy-servers.txt"); + } else if (fileExists(jsonConfigPath)) { + print.cyanLine("Found deploy-servers.json, loading server configuration").toConsole(); + serverList = loadServersFromConfig("deploy-servers.json"); + } else { + error("No server configuration found. Create deploy-servers.txt or deploy-servers.json in your project root."); + } + + if (arrayLen(serverList) == 0) { + error("No servers configured for logs"); + } + + // Validate follow mode with multiple servers + if (arguments.follow && arrayLen(serverList) > 1) { + error("Cannot follow logs from multiple servers simultaneously. Please specify a single server using 'servers=host'."); + } + + print.line(); + print.boldMagentaLine("Wheels Deployment Logs"); + print.line("==================================================").toConsole(); + + for (var serverConfig in serverList) { + if (arrayLen(serverList) > 1) { + print.line().toConsole(); + print.boldCyanLine("=== Server: #serverConfig.host# ===").toConsole(); + print.line().toConsole(); + } + + try { + fetchLogs(serverConfig, arguments.tail, arguments.follow, arguments.service, arguments.since); + } catch (any e) { + // Check for UserInterruptException (CommandBox specific) or standard InterruptedException + if (findNoCase("UserInterruptException", e.message) || findNoCase("InterruptedException", e.message) || (structKeyExists(e, "type") && findNoCase("UserInterruptException", e.type))) { + print.line().toConsole(); + print.yellowLine("Command interrupted by user.").toConsole(); + break; + } + print.redLine("Failed to fetch logs from #serverConfig.host#: #e.message#").toConsole(); + } + } + } + + private function fetchLogs( + struct serverConfig, + string tail, + boolean follow, + string service, + string since + ) { + var local = {}; + local.host = arguments.serverConfig.host; + local.user = arguments.serverConfig.user; + local.port = structKeyExists(arguments.serverConfig, "port") ? arguments.serverConfig.port : 22; + local.imageName = structKeyExists(arguments.serverConfig, "imageName") ? arguments.serverConfig.imageName : "#local.user#-app"; + + // 1. Check SSH Connection (skip if following to save time/output noise?) + // Better to check to avoid hanging on bad connection + if (!testSSHConnection(local.host, local.user, local.port)) { + throw("SSH connection failed"); + } + + // 2. Determine Container Name + var containerName = ""; + + if (arguments.service == "app") { + // Logic similar to status.cfc to find active app container + var findCmd = ["ssh", "-p", local.port]; + findCmd.addAll(getSSHOptions()); + // Filter by name (including blue/green variants) + findCmd.addAll([local.user & "@" & local.host, "docker ps --format '{{.Names}}' --filter name=" & local.imageName]); + + var findResult = runLocalCommand(findCmd, false); + var runningContainers = listToArray(trim(findResult.output), chr(10)); + + if (arrayLen(runningContainers) > 0) { + // Default to first found + containerName = runningContainers[1]; + + // Try to find exact match or blue/green + for (var container in runningContainers) { + if (container == local.imageName || container == local.imageName & "-blue" || container == local.imageName & "-green") { + containerName = container; + break; + } + } + } + } else { + // Attempt to find service container (e.g. db) + // Try common patterns: [project]-[service], [service] + var patterns = [ + local.imageName & "-" & arguments.service, + arguments.service + ]; + + for (var pattern in patterns) { + var findServiceCmd = ["ssh", "-p", local.port]; + findServiceCmd.addAll(getSSHOptions()); + findServiceCmd.addAll([local.user & "@" & local.host, "docker ps --format '{{.Names}}' --filter name=" & pattern]); + + var serviceResult = runLocalCommand(findServiceCmd, false); + if (serviceResult.exitCode == 0 && len(trim(serviceResult.output))) { + containerName = listFirst(trim(serviceResult.output), chr(10)); + break; + } + } + } + + if (!len(containerName)) { + throw("Could not find running container for service: " & arguments.service); + } + + // 3. Construct Docker Logs Command + var logsCmd = ["ssh", "-p", local.port]; + // If following, we need TTY allocation? ssh -t? + // Actually, for streaming output, standard ssh works, but we might need -t if we want to send Ctrl+C correctly? + // Let's stick to standard for now. + logsCmd.addAll(getSSHOptions()); + + var dockerCmd = "docker logs"; + + if (len(arguments.tail)) { + dockerCmd &= " --tail " & arguments.tail; + } + + if (len(arguments.since)) { + dockerCmd &= " --since " & arguments.since; + } + + if (arguments.follow) { + dockerCmd &= " -f"; + } + + dockerCmd &= " " & containerName; + + logsCmd.addAll([local.user & "@" & local.host, dockerCmd]); + + // 4. Execute + // If following, we want to print output as it comes. runLocalCommand does this. + // However, runLocalCommand waits for completion. For -f, it will run indefinitely until user interrupts. + // This is fine for CLI usage. + + print.cyanLine("Fetching logs from container: " & containerName).toConsole(); + if (arguments.follow) { + print.yellowLine("Following logs... (Press Ctrl+C to stop)").toConsole(); + } + print.line("----------------------------------------").toConsole(); + + var result = runInteractiveCommand(logsCmd); + + if (result.exitCode != 0 && result.exitCode != 130) { + throw("Command failed with exit code: " & result.exitCode); + } + } + +} diff --git a/cli/src/commands/wheels/docker/push.cfc b/cli/src/commands/wheels/docker/push.cfc new file mode 100644 index 0000000000..407885f10b --- /dev/null +++ b/cli/src/commands/wheels/docker/push.cfc @@ -0,0 +1,453 @@ +/** + * Push Docker images to container registries + * + * {code:bash} + * wheels docker push --local --registry=dockerhub --username=myuser + * wheels docker push --local --registry=dockerhub --username=myuser --tag=v1.0.0 + * wheels docker push --local --registry=ecr --image=123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest + * wheels docker push --local --registry=dockerhub --username=myuser --build + * wheels docker push --remote --registry=dockerhub --username=myuser + * {code} + */ +component extends="DockerCommand" { + + /** + * @local Push image from local machine + * @remote Push image from remote server(s) + * @servers Comma-separated list of server numbers to push from (e.g., "1,3,5") - for remote only + * @registry Registry type: dockerhub, ecr, gcr, acr, ghcr, private (default: dockerhub) + * @image Full image name with registry path (optional - auto-detected from project if not specified) + * @username Registry username (for dockerhub, ghcr, private registries) + * @password Registry password or token (leave empty to prompt) + * @tag Tag/version to apply (e.g., v1.0.0, latest). If specified, creates: username/projectname:tag + * @build Build the image before pushing (default: false) + * @namespace Registry namespace/username prefix (e.g., for dockerhub: myusername) + */ + function run( + boolean local=false, + boolean remote=false, + string servers="", + string registry="", + string image="", + string username="", + string password="", + string tag="latest", + boolean build=false, + string namespace="" + ) { + arguments = reconstructArgs(arguments); + + // Load defaults from config if available + var configPath = fileSystemUtil.resolvePath("docker-config.json"); + if (fileExists(configPath)) { + try { + var config = deserializeJSON(fileRead(configPath)); + + if (!len(trim(arguments.registry)) && structKeyExists(config, "registry")) { + arguments.registry = config.registry; + print.cyanLine("Using registry from config: #arguments.registry#").toConsole(); + } + + if (!len(trim(arguments.username)) && structKeyExists(config, "username")) { + arguments.username = config.username; + print.cyanLine("Using username from config: #arguments.username#").toConsole(); + } + + if (!len(trim(arguments.image)) && structKeyExists(config, "image")) { + arguments.image = config.image; + } + } catch (any e) { + // Ignore config errors + } + } + + // Default registry to dockerhub if still empty + if (!len(trim(arguments.registry))) { + arguments.registry = "dockerhub"; + } + + // Validate that exactly one push type is specified + if (!arguments.local && !arguments.remote) { + error("Please specify push type: --local or --remote"); + } + + if (arguments.local && arguments.remote) { + error("Cannot specify both --local and --remote. Please choose one."); + } + + // Validate registry type + local.supportedRegistries = ["dockerhub", "ecr", "gcr", "acr", "ghcr", "private"]; + if (!arrayContains(local.supportedRegistries, lCase(arguments.registry))) { + error("Unsupported registry: #arguments.registry#. Supported: #arrayToList(local.supportedRegistries)#"); + } + + // Route to appropriate push method + if (arguments.local) { + pushLocal(arguments.registry, arguments.image, arguments.username, arguments.password, arguments.tag, arguments.build, arguments.namespace); + } else { + pushRemote(arguments.servers, arguments.registry, arguments.image, arguments.username, arguments.password, arguments.tag, arguments.build, arguments.namespace); + } + } + + // ============================================================================= + // LOCAL PUSH + // ============================================================================= + + private function pushLocal(string registry, string customImage, string username, string password, string tag, boolean build, string namespace) { + print.line(); + print.boldMagentaLine("Wheels Docker Push - Local"); + print.line(); + + // Check if Docker is installed locally + if (!isDockerInstalled()) { + error("Docker is not installed or not accessible. Please ensure Docker Desktop or Docker Engine is running."); + } + + // Get project name + local.projectName = getProjectName(); + local.localImageName = local.projectName & ":latest"; + + print.cyanLine("Project: " & local.projectName).toConsole(); + print.cyanLine("Registry: " & arguments.registry).toConsole(); + print.line(); + + // Build image if requested + if (arguments.build) { + print.yellowLine("Building image before push...").toConsole(); + buildLocalImage(); + } + + // Check if local image exists + if (!checkLocalImageExists(local.localImageName)) { + print.yellowLine("Local image '#local.localImageName#' not found.").toConsole(); + print.line("Would you like to build it now? (y/n)").toConsole(); + local.answer = ask(""); + if (lCase(local.answer) == "y") { + buildLocalImage(); + } else { + error("Image not found. Build the image first with: wheels docker build --local"); + } + } + + print.greenLine("Found local image: " & local.localImageName).toConsole(); + + // Determine final image name based on registry and user input + local.finalImage = determineImageName( + arguments.registry, + arguments.customImage, + local.projectName, + arguments.tag, + arguments.username, + arguments.namespace + ); + + print.cyanLine("Target image: " & local.finalImage).toConsole(); + print.line(); + + // Tag the image if needed + if (local.finalImage != local.localImageName) { + print.yellowLine("Tagging image: " & local.localImageName & " -> " & local.finalImage).toConsole(); + try { + runLocalCommand(["docker", "tag", local.localImageName, local.finalImage]); + print.greenLine("Image tagged successfully").toConsole(); + } catch (any e) { + error("Failed to tag image: " & e.message); + } + } + + // Login to registry if password provided, otherwise assume already logged in + if (len(trim(arguments.password))) { + loginToRegistry( + registry=arguments.registry, + image=local.finalImage, + username=arguments.username, + password=arguments.password, + isLocal=true + ); + } else { + print.yellowLine("No password provided, attempting to push with existing credentials...").toConsole(); + } + + // Push the image + print.yellowLine("Pushing image to " & arguments.registry & "...").toConsole(); + + try { + runLocalCommand(["docker", "push", local.finalImage]); + print.line(); + print.boldGreenLine("Image pushed successfully to " & arguments.registry & "!").toConsole(); + print.line(); + print.yellowLine("Image: " & local.finalImage).toConsole(); + print.yellowLine("Pull with: docker pull " & local.finalImage).toConsole(); + print.line(); + } catch (any e) { + print.redLine("Failed to push image: " & e.message).toConsole(); + print.line(); + print.yellowLine("You may need to login first.").toConsole(); + print.line("Try running: wheels docker login --registry=" & arguments.registry & " --username=" & arguments.username).toConsole(); + print.line("Or provide a password/token with --password").toConsole(); + error("Push failed"); + } + } + + /** + * Build the local image + */ + private function buildLocalImage() { + print.yellowLine("Building Docker image...").toConsole(); + print.line(); + + // Check for docker-compose file + local.useCompose = hasDockerComposeFile(); + + if (local.useCompose) { + runLocalCommand(["docker", "compose", "build"]); + } else { + local.projectName = getProjectName(); + runLocalCommand(["docker", "build", "-t", local.projectName & ":latest", "."]); + } + + print.line(); + print.greenLine("Build completed successfully").toConsole(); + } + + private function hasDockerComposeFile() { + var composeFiles = ["docker-compose.yml", "docker-compose.yaml"]; + for (var composeFile in composeFiles) { + if (fileExists(getCWD() & "/" & composeFile)) { + return true; + } + } + return false; + } + + // ============================================================================= + // REMOTE PUSH + // ============================================================================= + + private function pushRemote(string serverNumbers, string registry, string image, string username, string password, string tag, boolean build, string namespace) { + // Check for deploy-servers file + var textConfigPath = fileSystemUtil.resolvePath("deploy-servers.txt"); + var jsonConfigPath = fileSystemUtil.resolvePath("deploy-servers.json"); + var allServers = []; + var serversToPush = []; + + if (fileExists(textConfigPath)) { + print.cyanLine("Found deploy-servers.txt, loading server configuration").toConsole(); + allServers = loadServersFromTextFile("deploy-servers.txt"); + serversToPush = filterServers(allServers, arguments.serverNumbers); + } else if (fileExists(jsonConfigPath)) { + print.cyanLine("Found deploy-servers.json, loading server configuration").toConsole(); + allServers = loadServersFromConfig("deploy-servers.json"); + serversToPush = filterServers(allServers, arguments.serverNumbers); + } else { + error("No server configuration found. Create deploy-servers.txt or deploy-servers.json in your project root."); + } + + if (arrayLen(serversToPush) == 0) { + error("No servers configured for pushing"); + } + + print.line().boldCyanLine("Pushing Docker images from #arrayLen(serversToPush)# server(s)...").toConsole(); + + // Push from all selected servers + pushFromServers(serversToPush, arguments.registry, arguments.image, arguments.username, arguments.password, arguments.tag); + + print.line().boldGreenLine("Push operations completed on all servers!").toConsole(); + } + + /** + * Filter servers based on comma-separated list + */ + private function filterServers(required array allServers, string serverNumbers="") { + if (!len(trim(arguments.serverNumbers))) { + return arguments.allServers; + } + + var selectedServers = []; + var numbers = listToArray(arguments.serverNumbers); + + for (var numStr in numbers) { + var num = val(trim(numStr)); + if (num > 0 && num <= arrayLen(arguments.allServers)) { + arrayAppend(selectedServers, arguments.allServers[num]); + } + } + + if (arrayLen(selectedServers) == 0) { + return arguments.allServers; + } + + print.greenLine("Selected #arrayLen(selectedServers)# of #arrayLen(arguments.allServers)# server(s)").toConsole(); + return selectedServers; + } + + /** + * Push from multiple servers + */ + private function pushFromServers(required array servers, string registry, string image, string username, string password, string tag) { + var successCount = 0; + var failureCount = 0; + + for (var i = 1; i <= arrayLen(servers); i++) { + var serverConfig = servers[i]; + print.line().boldCyanLine("---------------------------------------").toConsole(); + print.boldCyanLine("Pushing from server #i# of #arrayLen(servers)#: #serverConfig.host#").toConsole(); + print.line().boldCyanLine("---------------------------------------").toConsole(); + + try { + pushFromServer(serverConfig, arguments.registry, arguments.image, arguments.username, arguments.password, arguments.tag); + successCount++; + print.greenLine("Push from #serverConfig.host# completed successfully").toConsole(); + } catch (any e) { + failureCount++; + print.redLine("Failed to push from #serverConfig.host#: #e.message#").toConsole(); + } + } + + print.line().toConsole(); + print.boldCyanLine("Push Operations Summary:").toConsole(); + print.greenLine(" Successful: #successCount#").toConsole(); + if (failureCount > 0) { + print.redLine(" Failed: #failureCount#").toConsole(); + } + } + + /** + * Push from a single server + */ + private function pushFromServer(required struct serverConfig, string registry, string image, string username, string password, string tag) { + var local = {}; + local.host = arguments.serverConfig.host; + local.user = arguments.serverConfig.user; + local.port = structKeyExists(arguments.serverConfig, "port") ? arguments.serverConfig.port : 22; + + // Check SSH connection + if (!testSSHConnection(local.host, local.user, local.port)) { + error("SSH connection failed to #local.host#"); + } + print.greenLine("SSH connection successful").toConsole(); + + print.cyanLine("Registry: " & arguments.registry).toConsole(); + print.cyanLine("Image: " & arguments.image).toConsole(); + + // Apply additional tag if specified + if (len(trim(arguments.tag))) { + print.yellowLine("Tagging image with additional tag: " & arguments.tag).toConsole(); + local.tagCmd = "docker tag " & arguments.image & " " & arguments.tag; + executeRemoteCommand(local.host, local.user, local.port, local.tagCmd); + arguments.image = arguments.tag; + } + + // Get login command for registry + local.loginCmd = ""; + if (len(trim(arguments.password))) { + local.loginCmd = loginToRegistry( + registry=arguments.registry, + image=arguments.image, + username=arguments.username, + password=arguments.password, + isLocal=false + ); + } + + // Execute login on remote server + if (len(local.loginCmd)) { + print.yellowLine("Logging in to registry on remote server...").toConsole(); + executeRemoteCommand(local.host, local.user, local.port, local.loginCmd); + print.greenLine("Login successful").toConsole(); + } else { + print.yellowLine("No password provided, skipping login on remote server...").toConsole(); + } + + // Push the image + print.yellowLine("Pushing image from remote server...").toConsole(); + local.pushCmd = "docker push " & arguments.image; + executeRemoteCommand(local.host, local.user, local.port, local.pushCmd); + + print.boldGreenLine("Image pushed successfully from #local.host#!").toConsole(); + } + + // ============================================================================= + // HELPER FUNCTIONS + // ============================================================================= + + private function loadServersFromTextFile(required string textFile) { + var filePath = fileSystemUtil.resolvePath(arguments.textFile); + var fileContent = fileRead(filePath); + var lines = listToArray(fileContent, chr(10)); + var servers = []; + + for (var line in lines) { + line = trim(line); + if (len(line) == 0 || left(line, 1) == "##") continue; + + var parts = listToArray(line, " " & chr(9), true); + if (arrayLen(parts) < 2) continue; + + arrayAppend(servers, { + "host": trim(parts[1]), + "user": trim(parts[2]), + "port": arrayLen(parts) >= 3 ? val(trim(parts[3])) : 22 + }); + } + + return servers; + } + + private function loadServersFromConfig(required string configFile) { + var configPath = fileSystemUtil.resolvePath(arguments.configFile); + var configContent = fileRead(configPath); + var config = deserializeJSON(configContent); + return config.servers; + } + + private function testSSHConnection(string host, string user, numeric port) { + print.yellowLine("Testing SSH connection to " & arguments.host & "...").toConsole(); + var result = runProcess([ + "ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", + "-p", arguments.port, arguments.user & "@" & arguments.host, "echo connected" + ]); + return (result.exitCode eq 0); + } + + private function executeRemoteCommand(string host, string user, numeric port, string cmd) { + var result = runProcess([ + "ssh", "-o", "BatchMode=yes", "-p", arguments.port, + arguments.user & "@" & arguments.host, arguments.cmd + ]); + + if (result.exitCode neq 0) { + error("Remote command failed: " & arguments.cmd); + } + + return result; + } + + private function runProcess(array cmd) { + var local = {}; + local.javaCmd = createObject("java","java.util.ArrayList").init(); + for (var c in arguments.cmd) { + local.javaCmd.add(c & ""); + } + + local.pb = createObject("java","java.lang.ProcessBuilder").init(local.javaCmd); + local.pb.redirectErrorStream(true); + local.proc = local.pb.start(); + + local.isr = createObject("java","java.io.InputStreamReader").init(local.proc.getInputStream(), "UTF-8"); + local.br = createObject("java","java.io.BufferedReader").init(local.isr); + local.outputParts = []; + + while (true) { + local.line = local.br.readLine(); + if (isNull(local.line)) break; + arrayAppend(local.outputParts, local.line); + print.line(local.line).toConsole(); + } + + local.exitCode = local.proc.waitFor(); + local.output = arrayToList(local.outputParts, chr(10)); + + return { exitCode: local.exitCode, output: local.output }; + } +} \ No newline at end of file diff --git a/cli/src/commands/wheels/docker/stop.cfc b/cli/src/commands/wheels/docker/stop.cfc new file mode 100644 index 0000000000..be7aeeab5f --- /dev/null +++ b/cli/src/commands/wheels/docker/stop.cfc @@ -0,0 +1,519 @@ +/** + * Unified Docker stop command for Wheels apps + * + * {code:bash} + * wheels docker stop --local + * wheels docker stop --local --removeContainer + * wheels docker stop --remote + * wheels docker stop --remote --servers=1,3 + * wheels docker stop --remote --removeContainer + * {code} + */ +component extends="../base" { + + /** + * @local Stop containers on local machine + * @remote Stop containers on remote server(s) + * @servers Comma-separated list of server numbers to stop (e.g., "1,3,5") - for remote only + * @removeContainer Also remove the container after stopping (default: false) + */ + function run( + boolean local=false, + boolean remote=false, + string servers="", + boolean removeContainer=false + ) { + + // Validate that exactly one stop type is specified + if (!arguments.local && !arguments.remote) { + error("Please specify stop type: --local or --remote"); + } + + if (arguments.local && arguments.remote) { + error("Cannot specify both --local and --remote. Please choose one."); + } + + // Route to appropriate stop method + if (arguments.local) { + stopLocal(arguments.removeContainer); + } else { + stopRemote(arguments.servers, arguments.removeContainer); + } + } + + // ============================================================================= + // LOCAL STOP + // ============================================================================= + + private function stopLocal(boolean removeContainer) { + print.line(); + print.boldMagentaLine("Wheels Docker Local Stop"); + print.line(); + + // Check if Docker is installed locally + if (!isDockerInstalled()) { + error("Docker is not installed or not accessible. Please ensure Docker Desktop or Docker Engine is running."); + } + + // Check for docker-compose file + local.useCompose = hasDockerComposeFile(); + + if (local.useCompose) { + print.greenLine("Found docker-compose file, will stop docker-compose services").toConsole(); + + print.yellowLine("Stopping services with docker-compose...").toConsole(); + + try { + runLocalCommand(["docker", "compose", "down"]); + print.boldGreenLine("Docker Compose services stopped successfully!").toConsole(); + } catch (any e) { + print.yellowLine("Services might not be running").toConsole(); + } + + } else { + print.greenLine("No docker-compose file found, will use standard docker commands").toConsole(); + + // Get project name for container naming + local.containerName = getProjectName(); + + print.yellowLine("Stopping Docker container '" & local.containerName & "'...").toConsole(); + + try { + runLocalCommand(["docker", "stop", local.containerName]); + print.greenLine("Container stopped successfully").toConsole(); + + if (arguments.removeContainer) { + print.yellowLine("Removing Docker container '" & local.containerName & "'...").toConsole(); + runLocalCommand(["docker", "rm", local.containerName]); + print.greenLine("Container removed successfully").toConsole(); + } + + print.boldGreenLine("Container operations completed!").toConsole(); + } catch (any e) { + print.yellowLine("Container might not be running: " & e.message).toConsole(); + } + } + + print.line(); + print.yellowLine("Check container status with: docker ps -a").toConsole(); + print.line(); + } + + /** + * Check if Docker is installed locally + */ + private function isDockerInstalled() { + try { + var result = runLocalCommand(["docker", "--version"], false); + return (result.exitCode eq 0); + } catch (any e) { + return false; + } + } + + /** + * Run a local system command + */ + private function runLocalCommand(array cmd, boolean showOutput=true) { + var local = {}; + local.javaCmd = createObject("java","java.util.ArrayList").init(); + for (var c in arguments.cmd) { + local.javaCmd.add(c & ""); + } + + local.pb = createObject("java","java.lang.ProcessBuilder").init(local.javaCmd); + + // Set working directory to current directory + local.currentDir = createObject("java", "java.io.File").init(getCWD()); + local.pb.directory(local.currentDir); + + local.pb.redirectErrorStream(true); + local.proc = local.pb.start(); + + local.isr = createObject("java","java.io.InputStreamReader").init(local.proc.getInputStream(), "UTF-8"); + local.br = createObject("java","java.io.BufferedReader").init(local.isr); + local.outputParts = []; + + while (true) { + local.line = local.br.readLine(); + if (isNull(local.line)) break; + arrayAppend(local.outputParts, local.line); + if (arguments.showOutput) { + print.line(local.line).toConsole(); + } + } + + local.exitCode = local.proc.waitFor(); + local.output = arrayToList(local.outputParts, chr(10)); + + if (local.exitCode neq 0 && arguments.showOutput) { + error("Command failed with exit code: " & local.exitCode); + } + + return { exitCode: local.exitCode, output: local.output }; + } + + // ============================================================================= + // REMOTE STOP + // ============================================================================= + + private function stopRemote(string serverNumbers, boolean removeContainer) { + // Check for deploy-servers file (text or json) in current directory + var textConfigPath = fileSystemUtil.resolvePath("deploy-servers.txt"); + var jsonConfigPath = fileSystemUtil.resolvePath("deploy-servers.json"); + var allServers = []; + var serversToStop = []; + + if (fileExists(textConfigPath)) { + print.cyanLine("Found deploy-servers.txt, loading server configuration").toConsole(); + allServers = loadServersFromTextFile("deploy-servers.txt"); + serversToStop = filterServers(allServers, arguments.serverNumbers); + } else if (fileExists(jsonConfigPath)) { + print.cyanLine("Found deploy-servers.json, loading server configuration").toConsole(); + allServers = loadServersFromConfig("deploy-servers.json"); + serversToStop = filterServers(allServers, arguments.serverNumbers); + } else { + error("No server configuration found. Create deploy-servers.txt or deploy-servers.json in your project root." & chr(10) & chr(10) & + "Example deploy-servers.txt:" & chr(10) & + "192.168.1.100 ubuntu 22" & chr(10) & + "production.example.com deploy" & chr(10) & chr(10) & + "Or see examples/deploy-servers.example.txt for more details."); + } + + if (arrayLen(serversToStop) == 0) { + error("No servers configured to stop containers"); + } + + print.line().boldCyanLine("Stopping containers on #arrayLen(serversToStop)# server(s)...").toConsole(); + + // Stop containers on all selected servers + stopContainersOnServers(serversToStop, arguments.removeContainer); + + print.line().boldGreenLine("Container stop operations completed!").toConsole(); + } + + /** + * Filter servers based on comma-separated list of server numbers + */ + private function filterServers(required array allServers, string serverNumbers="") { + // If no specific servers requested, return all + if (!len(trim(arguments.serverNumbers))) { + return arguments.allServers; + } + + var selectedServers = []; + var numbers = listToArray(arguments.serverNumbers); + + for (var numStr in numbers) { + var num = val(trim(numStr)); + if (num > 0 && num <= arrayLen(arguments.allServers)) { + arrayAppend(selectedServers, arguments.allServers[num]); + } else { + print.yellowLine("Skipping invalid server number: " & numStr).toConsole(); + } + } + + if (arrayLen(selectedServers) == 0) { + print.yellowLine("No valid servers selected, using all servers").toConsole(); + return arguments.allServers; + } + + print.greenLine("Selected #arrayLen(selectedServers)# of #arrayLen(arguments.allServers)# server(s)").toConsole(); + return selectedServers; + } + + /** + * Stop containers on multiple servers sequentially + */ + private function stopContainersOnServers(required array servers, required boolean removeContainer) { + var successCount = 0; + var failureCount = 0; + var serverConfig = {}; + + for (var i = 1; i <= arrayLen(servers); i++) { + serverConfig = servers[i]; + print.line().boldCyanLine("---------------------------------------").toConsole(); + print.boldCyanLine("Stopping container on server #i# of #arrayLen(servers)#: #serverConfig.host#").toConsole(); + print.line().boldCyanLine("---------------------------------------").toConsole(); + + try { + stopContainerOnServer(serverConfig, arguments.removeContainer); + successCount++; + print.greenLine("Container on #serverConfig.host# stopped successfully").toConsole(); + } catch (any e) { + failureCount++; + print.redLine("Failed to stop container on #serverConfig.host#: #e.message#").toConsole(); + } + } + + print.line().toConsole(); + print.boldCyanLine("Stop Operations Summary:").toConsole(); + print.greenLine(" Successful: #successCount#").toConsole(); + if (failureCount > 0) { + print.redLine(" Failed: #failureCount#").toConsole(); + } + } + + /** + * Stop container on a single server + */ + private function stopContainerOnServer(required struct serverConfig, required boolean removeContainer) { + var local = {}; + local.host = arguments.serverConfig.host; + local.user = arguments.serverConfig.user; + local.port = structKeyExists(arguments.serverConfig, "port") ? arguments.serverConfig.port : 22; + local.imageName = structKeyExists(arguments.serverConfig, "imageName") ? arguments.serverConfig.imageName : "#local.user#-app"; + local.remoteDir = structKeyExists(arguments.serverConfig, "remoteDir") ? arguments.serverConfig.remoteDir : "/home/#local.user#/#local.user#-app"; + + // Check SSH connection + if (!testSSHConnection(local.host, local.user, local.port)) { + error("SSH connection failed to #local.host#. Check credentials and access."); + } + print.greenLine("SSH connection successful").toConsole(); + + // Check if docker-compose is being used on the remote server + local.checkComposeCmd = "test -f " & local.remoteDir & "/docker-compose.yml || test -f " & local.remoteDir & "/docker-compose.yaml"; + local.useCompose = false; + + try { + executeRemoteCommand(local.host, local.user, local.port, local.checkComposeCmd); + local.useCompose = true; + print.greenLine("Found docker-compose file on remote server").toConsole(); + } catch (any e) { + print.yellowLine("No docker-compose file found, using standard docker commands").toConsole(); + } + + if (local.useCompose) { + // Stop using docker-compose + print.yellowLine("Stopping services with docker-compose...").toConsole(); + + // Check if user can run docker without sudo + local.stopCmd = "cd " & local.remoteDir & " && "; + local.stopCmd &= "if groups | grep -q docker && [ -w /var/run/docker.sock ]; then "; + local.stopCmd &= "docker compose down; "; + local.stopCmd &= "else sudo docker compose down; fi"; + + try { + executeRemoteCommand(local.host, local.user, local.port, local.stopCmd); + print.greenLine("Docker Compose services stopped").toConsole(); + } catch (any e) { + print.yellowLine("Services might not be running: " & e.message).toConsole(); + } + } else { + // Stop the container using standard docker commands + print.yellowLine("Stopping Docker container '" & local.imageName & "'...").toConsole(); + + // Check if user can run docker without sudo + local.stopCmd = "if groups | grep -q docker && [ -w /var/run/docker.sock ]; then "; + local.stopCmd &= "docker stop " & local.imageName & "; "; + local.stopCmd &= "else sudo docker stop " & local.imageName & "; fi"; + + try { + executeRemoteCommand(local.host, local.user, local.port, local.stopCmd); + print.greenLine("Container stopped").toConsole(); + } catch (any e) { + print.yellowLine("Container might not be running: " & e.message).toConsole(); + } + + // Remove container if requested + if (arguments.removeContainer) { + print.yellowLine("Removing Docker container '" & local.imageName & "'...").toConsole(); + + // Check if user can run docker without sudo + local.removeCmd = "if groups | grep -q docker && [ -w /var/run/docker.sock ]; then "; + local.removeCmd &= "docker rm " & local.imageName & "; "; + local.removeCmd &= "else sudo docker rm " & local.imageName & "; fi"; + + try { + executeRemoteCommand(local.host, local.user, local.port, local.removeCmd); + print.greenLine("Container removed").toConsole(); + } catch (any e) { + print.yellowLine("Container might not exist: " & e.message).toConsole(); + } + } + } + + print.boldGreenLine("Operations on #local.host# completed!").toConsole(); + } + + /** + * Load servers from simple text file + */ + private function loadServersFromTextFile(required string textFile) { + var filePath = fileSystemUtil.resolvePath(arguments.textFile); + + if (!fileExists(filePath)) { + error("Text file not found: #filePath#"); + } + + try { + var fileContent = fileRead(filePath); + var lines = listToArray(fileContent, chr(10)); + var servers = []; + + for (var lineNum = 1; lineNum <= arrayLen(lines); lineNum++) { + var line = trim(lines[lineNum]); + + // Skip empty lines and comments + if (len(line) == 0 || left(line, 1) == "##") { + continue; + } + + var parts = listToArray(line, " " & chr(9), true); + + if (arrayLen(parts) < 2) { + continue; + } + + var serverConfig = { + "host": trim(parts[1]), + "user": trim(parts[2]), + "port": arrayLen(parts) >= 3 ? val(trim(parts[3])) : 22 + }; + + var projectName = getProjectName(); + serverConfig.remoteDir = "/home/#serverConfig.user#/#projectName#"; + serverConfig.imageName = projectName; + arrayAppend(servers, serverConfig); + } + + return servers; + + } catch (any e) { + error("Error reading text file: #e.message#"); + } + } + + /** + * Load servers configuration from JSON file + */ + private function loadServersFromConfig(required string configFile) { + var configPath = fileSystemUtil.resolvePath(arguments.configFile); + + if (!fileExists(configPath)) { + error("Config file not found: #configPath#"); + } + + try { + var configContent = fileRead(configPath); + var config = deserializeJSON(configContent); + + if (!structKeyExists(config, "servers") || !isArray(config.servers)) { + error("Invalid config file format. Expected { ""servers"": [ ... ] }"); + } + + var projectName = getProjectName(); + for (var i = 1; i <= arrayLen(config.servers); i++) { + var serverConfig = config.servers[i]; + if (!structKeyExists(serverConfig, "port")) { + serverConfig.port = 22; + } + if (!structKeyExists(serverConfig, "remoteDir")) { + serverConfig.remoteDir = "/home/#serverConfig.user#/#projectName#"; + } + if (!structKeyExists(serverConfig, "imageName")) { + serverConfig.imageName = projectName; + } + } + + return config.servers; + + } catch (any e) { + error("Error parsing config file: #e.message#"); + } + } + + // ============================================================================= + // HELPER FUNCTIONS + // ============================================================================= + + private function testSSHConnection(string host, string user, numeric port) { + var local = {}; + print.yellowLine("Testing SSH connection to " & arguments.host & "...").toConsole(); + local.result = runProcess([ + "ssh", + "-o", "BatchMode=yes", + "-o", "PreferredAuthentications=publickey", + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=10", + "-p", arguments.port, + arguments.user & "@" & arguments.host, + "echo connected" + ]); + return (local.result.exitCode eq 0 and findNoCase("connected", local.result.output)); + } + + private function executeRemoteCommand(string host, string user, numeric port, string cmd) { + var local = {}; + print.yellowLine("Running: ssh -p " & arguments.port & " " & arguments.user & "@" & arguments.host & " " & arguments.cmd).toConsole(); + + local.result = runProcess([ + "ssh", + "-o", "BatchMode=yes", + "-o", "PreferredAuthentications=publickey", + "-o", "StrictHostKeyChecking=no", + "-o", "ServerAliveInterval=30", + "-o", "ServerAliveCountMax=3", + "-p", arguments.port, + arguments.user & "@" & arguments.host, + arguments.cmd + ]); + + if (local.result.exitCode neq 0) { + error("Remote command failed: " & arguments.cmd); + } + + return local.result; + } + + private function runProcess(array cmd) { + var local = {}; + local.javaCmd = createObject("java","java.util.ArrayList").init(); + for (var c in arguments.cmd) { + local.javaCmd.add(c & ""); + } + + local.pb = createObject("java","java.lang.ProcessBuilder").init(local.javaCmd); + local.pb.redirectErrorStream(true); + local.proc = local.pb.start(); + + local.isr = createObject("java","java.io.InputStreamReader").init(local.proc.getInputStream(), "UTF-8"); + local.br = createObject("java","java.io.BufferedReader").init(local.isr); + local.outputParts = []; + + while (true) { + local.line = local.br.readLine(); + if (isNull(local.line)) break; + arrayAppend(local.outputParts, local.line); + print.line(local.line).toConsole(); + } + + local.exitCode = local.proc.waitFor(); + local.output = arrayToList(local.outputParts, chr(10)); + + return { exitCode: local.exitCode, output: local.output }; + } + + private function getProjectName() { + var cwd = getCWD(); + var dirName = listLast(cwd, "\/"); + dirName = lCase(dirName); + dirName = reReplace(dirName, "[^a-z0-9\-]", "-", "all"); + dirName = reReplace(dirName, "\-+", "-", "all"); + dirName = reReplace(dirName, "^\-|\-$", "", "all"); + return len(dirName) ? dirName : "wheels-app"; + } + + private function hasDockerComposeFile() { + var composeFiles = ["docker-compose.yml", "docker-compose.yaml"]; + + for (var composeFile in composeFiles) { + var composePath = getCWD() & "/" & composeFile; + if (fileExists(composePath)) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/cli/src/commands/wheels/docs/generate.cfc b/cli/src/commands/wheels/docs/generate.cfc index 3be5daf4f7..a12b93bc47 100644 --- a/cli/src/commands/wheels/docs/generate.cfc +++ b/cli/src/commands/wheels/docs/generate.cfc @@ -103,7 +103,7 @@ component extends="../base" { } // Display summary - detailOutput.separator(); + detailOutput.line(); detailOutput.statusSuccess("Documentation generated successfully!"); detailOutput.line(); diff --git a/cli/src/commands/wheels/env/list.cfc b/cli/src/commands/wheels/env/list.cfc index 54e7074154..373eec9215 100644 --- a/cli/src/commands/wheels/env/list.cfc +++ b/cli/src/commands/wheels/env/list.cfc @@ -9,6 +9,7 @@ component extends="../base" { property name="environmentService" inject="EnvironmentService@wheels-cli"; + property name="detailOutput" inject="DetailOutputService@wheels-cli"; /** * @format.hint Output format: table (default), json, or yaml @@ -31,22 +32,214 @@ component extends="../base" { arguments = reconstructArgs( argStruct=arguments, allowedValues={ - format: ["table", "json"], + format: ["table", "json", "yaml"], filter: ["All", "Active", "Inactive"], sort: ["name", "modified", "size"] } ); var projectRoot = resolvePath("."); arguments.rootPath = projectRoot; + var currentEnv = environmentService.getCurrentEnvironment(projectRoot); + print.line("Checking for available environments...").toConsole(); - var result = environmentService.list(argumentCollection=arguments); + var environments = environmentService.list(argumentCollection=arguments); // Handle different format outputs if (arguments.format == "json") { - print.line(deserializeJSON(result)); - } else{ - print.line(result); + var jsonOutput = formatAsJSON(environments, currentEnv); + print.line(jsonOutput).toConsole(); + } else if (arguments.format == "yaml") { + var yamlOutput = formatAsYAML(environments, currentEnv); + print.line(yamlOutput).toConsole(); + } else { + // Table format using detailOutput functions + formatAsTable(environments, arguments.verbose, currentEnv); + } + } + + /** + * Format as JSON + */ + private function formatAsJSON(environments, currentEnv) { + var output = { + environments: [], + current: arguments.currentEnv, + total: arrayLen(arguments.environments) + }; + + for (var env in arguments.environments) { + var envData = { + name: env.NAME, + type: env.TYPE, + active: env.ACTIVE, + database: env.DATABASE, + datasource: env.DATASOURCE, + template: env.TEMPLATE, + dbtype: env.DBTYPE, + lastModified: dateTimeFormat(env.CREATED, "yyyy-mm-dd'T'HH:nn:ss'Z'"), + status: env.STATUS, + source: env.SOURCE + }; + + if (structKeyExists(env, "DEBUG")) { + envData.debug = env.DEBUG; + } + if (structKeyExists(env, "CACHE")) { + envData.cache = env.CACHE; + } + if (structKeyExists(env, "CONFIGPATH")) { + envData.configPath = env.CONFIGPATH; + } + if (structKeyExists(env, "VALIDATIONERRORS") && arrayLen(env.VALIDATIONERRORS)) { + envData.errors = env.VALIDATIONERRORS; + } + + arrayAppend(output.environments, envData); + } + + return serializeJSON(output); + } + + /** + * Format as YAML + */ + private function formatAsYAML(environments, currentEnv) { + var yaml = []; + arrayAppend(yaml, "environments:"); + + for (var env in arguments.environments) { + arrayAppend(yaml, " - name: #env.NAME#"); + arrayAppend(yaml, " type: #env.TYPE#"); + arrayAppend(yaml, " active: #env.ACTIVE#"); + arrayAppend(yaml, " template: #env.TEMPLATE#"); + arrayAppend(yaml, " database: #env.DATABASE#"); + arrayAppend(yaml, " dbtype: #env.DBTYPE#"); + arrayAppend(yaml, " created: #dateTimeFormat(env.CREATED, 'yyyy-mm-dd HH:nn:ss')#"); + arrayAppend(yaml, " source: #env.SOURCE#"); + arrayAppend(yaml, " status: #env.STATUS#"); + + if (structKeyExists(env, "VALIDATIONERRORS") && arrayLen(env.VALIDATIONERRORS)) { + arrayAppend(yaml, " errors:"); + for (var error in env.VALIDATIONERRORS) { + arrayAppend(yaml, " - #error#"); + } + } + } + + arrayAppend(yaml, ""); + arrayAppend(yaml, "current: #arguments.currentEnv#"); + arrayAppend(yaml, "total: #arrayLen(arguments.environments)#"); + + return arrayToList(yaml, chr(10)); + } + + + /** + * Format as table using detailOutput functions + */ + private function formatAsTable(environments, verbose, currentEnv) { + if (arrayLen(arguments.environments) == 0) { + detailOutput.statusWarning("No environments configured"); + detailOutput.statusInfo("Create an environment with: wheels env setup "); + return; + } + + detailOutput.header("Available Environments"); + + if (arguments.verbose) { + // Verbose format using detailOutput functions + detailOutput.metric("Total Environments", "#arrayLen(arguments.environments)#"); + detailOutput.metric("Current Environment", "#arguments.currentEnv#"); + detailOutput.line(); + + for (var env in arguments.environments) { + var envName = env.NAME; + if (env.ACTIVE) { + envName &= " * [Active]"; + } + + detailOutput.subHeader(envName); + detailOutput.metric("Type", env.TYPE); + detailOutput.metric("Database", env.DATABASE); + detailOutput.metric("Datasource", env.DATASOURCE); + detailOutput.metric("Template", env.TEMPLATE); + detailOutput.metric("DB Type", env.DBTYPE); + + if (structKeyExists(env, "DEBUG")) { + var debugStatus = env.DEBUG == 'true' ? 'Enabled' : 'Disabled'; + detailOutput.metric("Debug", debugStatus); + } + if (structKeyExists(env, "CACHE")) { + var cacheStatus = env.CACHE == 'true' ? 'Enabled' : 'Disabled'; + detailOutput.metric("Cache", cacheStatus); + } + if (structKeyExists(env, "CONFIGPATH")) { + detailOutput.metric("Config", env.CONFIGPATH); + } + + detailOutput.metric("Modified", dateTimeFormat(env.CREATED, "yyyy-mm-dd HH:nn:ss")); + detailOutput.metric("Source", env.SOURCE); + + // Status with appropriate color + if (env.STATUS == "valid") { + detailOutput.metric("Status", "[OK] Valid"); + } else { + detailOutput.metric("Status", "[WARN] #env.STATUS#"); + } + + if (structKeyExists(env, "VALIDATIONERRORS") && arrayLen(env.VALIDATIONERRORS)) { + detailOutput.statusWarning("Issues:"); + for (var error in env.VALIDATIONERRORS) { + detailOutput.output(" - #error#", true); + } + } + + detailOutput.line(); + } + } else { + // In the formatAsTable function - compact table section + detailOutput.line(); + + // Prepare data for the table + var tableData = []; + var headers = ["Name", "Type", "Database", "Status", "Active", "DB Type"]; + + // make sure tableData is an array + if (!isArray(tableData)) { + tableData = []; + } + + for (var i = 1; i <= arrayLen(arguments.environments); i++) { + var env = arguments.environments[i]; + + var activeIndicator = env.ACTIVE ? "YES *" : "NO"; + var statusText = env.STATUS == "valid" ? "[OK] Valid" : "[WARN] " & env.STATUS; + + // create an ordered struct so JSON keeps this key order + var row = structNew("ordered"); + row["Name"] = env.NAME; + row["Type"] = env.TYPE; + row["Database"] = env.DATABASE; + row["Status"] = statusText; + row["Active"] = activeIndicator; + row["DB Type"] = env.DBTYPE; + + arrayAppend(tableData, row); + } + + // Display the table + detailOutput.getPrint().table( + data = tableData, + headers = headers + ).toConsole(); + + detailOutput.line(); + detailOutput.line(); + detailOutput.metric("Total Environments", "#arrayLen(arguments.environments)#"); + detailOutput.metric("Current Environment", "#arguments.currentEnv#"); + detailOutput.statusInfo("* = Currently active environment"); + detailOutput.statusInfo("Use 'wheels env list --verbose' for detailed information"); } } } \ No newline at end of file diff --git a/cli/src/commands/wheels/env/merge.cfc b/cli/src/commands/wheels/env/merge.cfc index 3e5b75ab1b..53f97e1377 100644 --- a/cli/src/commands/wheels/env/merge.cfc +++ b/cli/src/commands/wheels/env/merge.cfc @@ -10,6 +10,8 @@ */ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { + property name="detailOutput" inject="DetailOutputService@wheels-cli"; + /** * @source1.hint First source .env file to merge * @source2.hint Second source .env file to merge @@ -35,22 +37,25 @@ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { } if (ArrayLen(local.sourceFiles) < 2) { - error("At least two source files are required. Usage: wheels env merge file1 file2 [--output=filename] [--dryRun]"); + detailOutput.error("At least two source files are required. Usage: wheels env merge file1 file2 [--output=filename] [--dryRun]"); + return; } // Validate all source files exist for (local.file in local.sourceFiles) { if (!FileExists(ResolvePath(local.file))) { - error("Source file not found: #local.file#"); + detailOutput.error("Source file not found: #local.file#"); + return; } } - print.line(); - print.boldLine("Merging environment files:"); + print.line("Merging environment files...").toConsole(); + detailOutput.line(); + detailOutput.subHeader("Source Files"); for (local.i = 1; local.i <= ArrayLen(local.sourceFiles); local.i++) { - print.line(" #local.i#. #local.sourceFiles[local.i]#"); + detailOutput.metric("#local.i#.", local.sourceFiles[local.i]); } - print.line(); + detailOutput.line(); // Merge the files local.merged = mergeEnvFiles(local.sourceFiles); @@ -61,16 +66,16 @@ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { } else { // Write the merged file writeMergedFile(arguments.output, local.merged); - print.line(); - print.greenLine("Merged #ArrayLen(local.sourceFiles)# files into #arguments.output#"); - print.line(" Total variables: #StructCount(local.merged.vars)#"); + detailOutput.line(); + detailOutput.statusSuccess("Merged #ArrayLen(local.sourceFiles)# files into #arguments.output#"); + detailOutput.metric("Total variables", "#StructCount(local.merged.vars)#"); // Show conflicts if any if (ArrayLen(local.merged.conflicts)) { - print.line(); - print.yellowLine("Conflicts resolved (later files take precedence):"); + detailOutput.line(); + detailOutput.statusWarning("Conflicts resolved (later files take precedence):"); for (local.conflict in local.merged.conflicts) { - print.line(" #local.conflict#"); + detailOutput.output(" - #local.conflict#", true); } } } @@ -116,7 +121,7 @@ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { if (StructKeyExists(local.result.vars, local.key)) { if (local.result.vars[local.key] != local.fileVars[local.key]) { ArrayAppend(local.result.conflicts, - "#local.key#: '#local.result.vars[local.key]#' (#local.result.sources[local.key]#) → '#local.fileVars[local.key]#' (#local.file#)" + "#local.key#: '#local.result.vars[local.key]#' (#local.result.sources[local.key]#) -> '#local.fileVars[local.key]#' (#local.file#)" ); } } @@ -131,8 +136,12 @@ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { } private void function displayMergedResult(required struct merged, required boolean dryRun) { - print.boldLine("Merged result (#arguments.dryRun ? 'DRY RUN' : ''#):"); - print.line(); + detailOutput.header("Merged Result #arguments.dryRun ? '(DRY RUN)' : ''#"); + detailOutput.metric("Total variables", "#StructCount(arguments.merged.vars)#"); + if (ArrayLen(arguments.merged.conflicts)) { + detailOutput.metric("Conflicts resolved", "#ArrayLen(arguments.merged.conflicts)#"); + } + detailOutput.line(); // Group variables by prefix local.grouped = {}; @@ -163,7 +172,7 @@ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { ArraySort(local.prefixes, "textnocase"); for (local.prefix in local.prefixes) { - print.boldLine("#local.prefix# Variables:"); + detailOutput.subHeader("#local.prefix# Variables"); // Sort variables within group ArraySort(local.grouped[local.prefix], function(a, b) { return CompareNoCase(a.key, b.key); @@ -176,14 +185,15 @@ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { FindNoCase("key", local.var.key) || FindNoCase("token", local.var.key)) { local.displayValue = "***MASKED***"; } - print.line(" #local.var.key# = #local.displayValue# (from #local.var.source#)"); + detailOutput.metric(local.var.key, local.displayValue); + detailOutput.output(" (from #local.var.source#)", true); } - print.line(); + detailOutput.line(); } // Display ungrouped variables if (ArrayLen(local.ungrouped)) { - print.boldLine("Other Variables:"); + detailOutput.subHeader("Other Variables"); // Sort ungrouped variables ArraySort(local.ungrouped, function(a, b) { return CompareNoCase(a.key, b.key); @@ -196,7 +206,17 @@ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { FindNoCase("key", local.var.key) || FindNoCase("token", local.var.key)) { local.displayValue = "***MASKED***"; } - print.line(" #local.var.key# = #local.displayValue# (from #local.var.source#)"); + detailOutput.metric(local.var.key, local.displayValue); + detailOutput.output(" (from #local.var.source#)", true); + } + } + + // Show conflicts if any + if (ArrayLen(arguments.merged.conflicts)) { + detailOutput.line(); + detailOutput.statusWarning("Conflict Resolution Details:"); + for (local.conflict in arguments.merged.conflicts) { + detailOutput.output(" - #local.conflict#", true); } } } @@ -254,8 +274,10 @@ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { // Write the file try { FileWrite(ResolvePath(arguments.filename), ArrayToList(local.lines, Chr(10))); + detailOutput.create("merged environment file: #arguments.filename#"); } catch (any e) { - error("Failed to write merged file: #e.message#"); + detailOutput.error("Failed to write merged file: #e.message#"); + throw(type="FileWriteError", message="Failed to write merged file: #e.message#"); } } diff --git a/cli/src/commands/wheels/env/set.cfc b/cli/src/commands/wheels/env/set.cfc index 5f92d8a1b3..12ed06cd86 100644 --- a/cli/src/commands/wheels/env/set.cfc +++ b/cli/src/commands/wheels/env/set.cfc @@ -10,6 +10,9 @@ */ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { + // Inject DetailOutputService + property name="detailOutput" inject="DetailOutputService@wheels-cli"; + /** * @key=value.hint Environment variable(s) to set in KEY=VALUE format * @file.hint The .env file to update (defaults to .env) @@ -30,7 +33,7 @@ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { } if (StructIsEmpty(local.updates)) { - error("No key=value pairs provided. Usage: wheels env set KEY=VALUE"); + detailOutput.error("No key=value pairs provided. Usage: wheels env set KEY=VALUE"); } // Update the .env file @@ -122,9 +125,10 @@ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { try { FileWrite(arguments.envFile, local.envContent); - print.line(); - print.greenLine("Environment variables updated in #GetFileFromPath(arguments.envFile)#:"); + detailOutput.line(); + detailOutput.statusSuccess("Environment variables updated in #GetFileFromPath(arguments.envFile)#:"); + // Display updated values for (local.key in local.updatedKeys) { local.displayValue = arguments.updates[local.key]; // Mask sensitive values @@ -137,7 +141,7 @@ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { local.displayValue = "***MASKED***"; } - print.line(" #local.key# = #ReReplace(local.displayValue, ',$', '', 'all')#"); + detailOutput.metric(local.key, ReReplace(local.displayValue, ',$', '', 'all')); } // Warn if .env is not in .gitignore @@ -146,14 +150,21 @@ component extends="commandbox.modules.wheels-cli.commands.wheels.base" { if (FileExists(local.gitignore)) { local.gitignoreContent = FileRead(local.gitignore); if (!FindNoCase(GetFileFromPath(arguments.envFile), local.gitignoreContent)) { - print.line(); - print.yellowLine("Warning: #GetFileFromPath(arguments.envFile)# is not in .gitignore!"); - print.line(" Add it to .gitignore to prevent committing secrets."); + detailOutput.line(); + detailOutput.statusWarning("#GetFileFromPath(arguments.envFile)# is not in .gitignore!"); + detailOutput.output("Add it to .gitignore to prevent committing secrets."); } } } + + // Show next steps + detailOutput.line(); + detailOutput.statusInfo("Next steps:"); + detailOutput.output("- Restart your application for changes to take effect", true); + detailOutput.output("- Use 'wheels env show' to view all environment variables", true); + } catch (any e) { - error("Failed to update .env file: #e.message#"); + detailOutput.error("Failed to update .env file: #e.message#"); } } diff --git a/cli/src/commands/wheels/env/setup.cfc b/cli/src/commands/wheels/env/setup.cfc index 6e4cd763bf..3e3f8419a9 100644 --- a/cli/src/commands/wheels/env/setup.cfc +++ b/cli/src/commands/wheels/env/setup.cfc @@ -8,6 +8,7 @@ component extends="../base" { property name="environmentService" inject="EnvironmentService@wheels-cli"; + property name="detailOutput" inject="DetailOutputService@wheels-cli"; /** * @environment.hint Environment name (e.g. development, staging, production) @@ -50,7 +51,8 @@ component extends="../base" { // Show help if requested if ( arguments.help == true) { - return showSetupHelp(); + print.line(showSetupHelp()).toConsole(); + return; } else { while ( trim(arguments.environment) == "" ) { arguments.environment = ask( @@ -59,8 +61,9 @@ component extends="../base" { } } - print.yellowLine("Setting up #arguments.environment# environment...") - .line(); + print.line("Setting up #arguments.environment# environment...").toConsole(); + detailOutput.header("Environment Setup"); + detailOutput.statusInfo("Setting up #arguments.environment# environment"); // CHECK ENVIRONMENT EXISTENCE FIRST - before prompting for credentials var envFile = projectRoot & "/.env." & arguments.environment; @@ -70,32 +73,32 @@ component extends="../base" { if (arguments.force) { updateMode = "overwrite"; } else { - print.line(); - print.yellowLine("Environment '#arguments.environment#' already exists."); - print.line(); - print.yellowLine("What would you like to do?"); - print.line(" 1. Overwrite entire environment file"); - print.line(" 2. Update only database variables (preserve other settings)"); - print.line(" 3. Cancel"); - print.line(); + detailOutput.line(); + detailOutput.statusWarning("Environment '#arguments.environment#' already exists."); + detailOutput.statusInfo("What would you like to do?"); + detailOutput.line(); + detailOutput.output(" 1. Overwrite entire environment file", true); + detailOutput.output(" 2. Update only database variables (preserve other settings)", true); + detailOutput.output(" 3. Cancel", true); + detailOutput.line(); var choice = ask("Select option [1-3]: "); switch(choice) { case "1": updateMode = "overwrite"; - print.greenLine("Will overwrite environment file..."); + detailOutput.statusSuccess("Will overwrite environment file..."); break; case "2": updateMode = "update"; - print.greenLine("Will update only database variables..."); + detailOutput.statusSuccess("Will update only database variables..."); break; case "3": default: - print.yellowLine("Environment setup cancelled."); + detailOutput.statusWarning("Environment setup cancelled."); return; } - print.line(); + detailOutput.line(); } } @@ -109,13 +112,13 @@ component extends="../base" { !len(trim(arguments.password))); if (needsInteractiveDatasource) { - print.line(); - print.yellowLine("Database credentials not provided for #arguments.dbtype# database"); + detailOutput.line(); + detailOutput.statusWarning("Database credentials not provided for #arguments.dbtype# database"); if (confirm("Would you like to enter database credentials now? [y/n]")) { - print.line(); - print.cyanLine("Please provide database connection details:"); - print.line(); + detailOutput.line(); + detailOutput.subHeader("Database Configuration"); + detailOutput.line(); // Prompt for host if (!len(trim(arguments.host))) { @@ -162,20 +165,21 @@ component extends="../base" { } } - print.line(); - print.greenLine("Database credentials captured successfully!"); - print.line(); + detailOutput.line(); + detailOutput.statusSuccess("Database credentials captured successfully!"); + detailOutput.line(); } else { - print.yellowLine("Using default credentials. You can update them in .env.#arguments.environment# later."); - print.line(); + detailOutput.statusWarning("Using default credentials. You can update them in .env.#arguments.environment# later."); + detailOutput.line(); } } var result = environmentService.setup(argumentCollection = arguments, rootPath=projectRoot, updateMode=updateMode ); if (result.success) { - print.greenLine("Environment setup complete!") - .line(); + detailOutput.line(); + detailOutput.statusSuccess("Environment setup complete!"); + detailOutput.line(); // Create database if not skipped if (!arguments.skipDatabase && result.keyExists("config") && result.config.keyExists("datasourceInfo")) { @@ -183,8 +187,8 @@ component extends="../base" { result.config.datasourceInfo.datasource : "wheels_#arguments.environment#"; var databaseName = result.config.datasourceInfo.database; - print.yellowLine("Creating database '#databaseName#'...") - .line(); + print.line("Creating database '#databaseName#'...").toConsole(); + detailOutput.line(); try { command("wheels db create") @@ -196,25 +200,40 @@ component extends="../base" { force = true ) .run(); + detailOutput.statusSuccess("Database created successfully!"); } catch (any e) { - print.yellowLine("Warning: Database creation failed - #e.message#") - .line() - .yellowLine("You can create it manually with:") - .line(" wheels db create datasource=#datasourceName# database=#databaseName# environment=#arguments.environment# dbtype=#arguments.dbtype#") - .line(); + detailOutput.statusWarning("Database creation failed - #e.message#"); + detailOutput.line(); + detailOutput.statusInfo("You can create it manually with:"); + detailOutput.output(" wheels db create datasource=#datasourceName# database=#databaseName# environment=#arguments.environment# dbtype=#arguments.dbtype#", true); + detailOutput.line(); } } if (result.keyExists("nextSteps") && arrayLen(result.nextSteps)) { - print.yellowBoldLine("Next Steps:") - .line(); + detailOutput.statusInfo("Next Steps:"); + detailOutput.line(); for (var step in result.nextSteps) { - print.line(step); + detailOutput.output(" - #step#", true); } + detailOutput.line(); } + + // Show summary + detailOutput.subHeader("Summary"); + detailOutput.metric("Environment", arguments.environment); + detailOutput.metric("Template", arguments.template); + detailOutput.metric("Database Type", arguments.dbtype); + if (result.keyExists("config") && result.config.keyExists("datasourceInfo")) { + detailOutput.metric("Datasource", result.config.datasourceInfo.datasource); + detailOutput.metric("Database", result.config.datasourceInfo.database); + } + detailOutput.metric("Debug Mode", arguments.debug ? "Enabled" : "Disabled"); + detailOutput.metric("Cache Mode", arguments.cache ? "Enabled" : "Disabled"); + } else { - print.redLine("Setup failed: #result.error#"); + detailOutput.error("Setup failed: #result.error#"); setExitCode(1); } } diff --git a/cli/src/commands/wheels/env/show.cfc b/cli/src/commands/wheels/env/show.cfc index 942f081cb1..83029318b0 100644 --- a/cli/src/commands/wheels/env/show.cfc +++ b/cli/src/commands/wheels/env/show.cfc @@ -14,6 +14,9 @@ */ component extends="../base" { + // Inject DetailOutputService + property name="detailOutput" inject="DetailOutputService@wheels-cli"; + /** * @key.hint Specific environment variable key to show * @format.hint Output format: table (default) or json @@ -35,28 +38,35 @@ component extends="../base" { try { // Check if we're in a Wheels project if (!directoryExists(resolvePath("app"))) { - error("This command must be run from a Wheels project root directory"); + detailOutput.error("This command must be run from a Wheels project root directory"); } - print.greenBoldLine("Environment Variables Viewer").line(); + detailOutput.header("Environment Variables Viewer"); // Read the .env file var envFile = resolvePath(arguments.file); if (!fileExists(envFile)) { - print.yellowLine("No #arguments.file# file found in project root"); - print.line(); - print.line("Create a .env file with key=value pairs, for example:"); - print.line(); - print.cyanLine("## Database Configuration"); - print.cyanLine("DB_HOST=localhost"); - print.cyanLine("DB_PORT=3306"); - print.cyanLine("DB_NAME=myapp"); - print.cyanLine("DB_USER=wheels"); - print.cyanLine("DB_PASSWORD=secret"); - print.line(); - print.cyanLine("## Application Settings"); - print.cyanLine("WHEELS_ENV=development"); - print.cyanLine("WHEELS_RELOAD_PASSWORD=mypassword"); + detailOutput.statusWarning("No #arguments.file# file found in project root"); + detailOutput.line(); + detailOutput.subHeader("Create a .env file with key=value pairs, for example:"); + detailOutput.line(); + + // Create example rows for table + var exampleRows = [ + { "Variable" = "## Database Configuration", "Value" = "", "Source" = "" }, + { "Variable" = "DB_HOST", "Value" = "localhost", "Source" = ".env.example" }, + { "Variable" = "DB_PORT", "Value" = "3306", "Source" = ".env.example" }, + { "Variable" = "DB_NAME", "Value" = "myapp", "Source" = ".env.example" }, + { "Variable" = "DB_USER", "Value" = "wheels", "Source" = ".env.example" }, + { "Variable" = "DB_PASSWORD", "Value" = "secret", "Source" = ".env.example" }, + { "Variable" = "## Application Settings", "Value" = "", "Source" = "" }, + { "Variable" = "WHEELS_ENV", "Value" = "development", "Source" = ".env.example" }, + { "Variable" = "WHEELS_RELOAD_PASSWORD", "Value" = "mypassword", "Source" = ".env.example" } + ]; + + print.table(exampleRows); + detailOutput.line(); + detailOutput.statusInfo("Use 'wheels env set KEY=VALUE' to create environment variables"); return; } @@ -64,39 +74,57 @@ component extends="../base" { var envVars = parseEnvFile(envFile); if (structIsEmpty(envVars)) { - print.yellowLine("No environment variables found in #arguments.file#"); + detailOutput.statusWarning("No environment variables found in #arguments.file#"); return; } // Handle specific key request if (len(arguments.key)) { if (!structKeyExists(envVars, arguments.key)) { - print.yellowLine("Environment variable '#arguments.key#' not found in #arguments.file#"); - print.line(); - print.line("Available keys:"); + detailOutput.statusWarning("Environment variable '#arguments.key#' not found in #arguments.file#"); + + // Show available keys in a table + var availableRows = []; for (var availKey in structKeyArray(envVars).sort("text")) { - print.line(" - #availKey#"); + arrayAppend(availableRows, { + "Available Variables" = availKey, + "Current Value" = maskSensitiveValue(availKey, envVars[availKey]) + }); + } + + if (arrayLen(availableRows)) { + detailOutput.line(); + detailOutput.subHeader("Available Variables in #arguments.file#"); + print.table(availableRows); } return; } // Found the key - var displayValue = envVars[arguments.key]; - if (findNoCase("password", arguments.key) || findNoCase("secret", arguments.key) || findNoCase("key", arguments.key)) { - displayValue = repeatString("*", min(len(displayValue), 8)); - } + var displayValue = maskSensitiveValue(arguments.key, envVars[arguments.key]); if (arguments.format == "json") { - local.jsonData = serializeJSON({Key: arguments.key,value: displayValue,source: arguments.file}, true); - print.line(deserializeJSON(local.jsonData)); + local.jsonData = serializeJSON({ + Variable: arguments.key, + Value: displayValue, + Source: arguments.file + }, true); + detailOutput.code(deserializeJSON(local.jsonData), "json"); } else { var rows = [ { "Variable" = arguments.key, "Value" = displayValue, "Source" = arguments.file } ]; + detailOutput.subHeader("Environment Variable Details"); print.table(rows); + + // Add usage info + detailOutput.line(); + detailOutput.statusInfo("Usage:"); + detailOutput.output("- Access in app: application.env['#arguments.key#']", true); + detailOutput.output("- Use in config: set(value=application.env['#arguments.key#'])", true); } - return; // stop here, don’t print all vars + return; // stop here, don't print all vars } // Show all environment variables @@ -104,38 +132,62 @@ component extends="../base" { // Mask sensitive values in JSON output var maskedVars = {}; for (var envKey in envVars) { - maskedVars[envKey] = envVars[envKey]; - if (findNoCase("password", envKey) || findNoCase("secret", envKey) || findNoCase("key", envKey)) { - maskedVars[envKey] = repeatString("*", min(len(envVars[envKey]), 8)); - } + maskedVars[envKey] = maskSensitiveValue(envKey, envVars[envKey]); } - print.line(maskedVars); + detailOutput.code(serializeJSON(maskedVars, true), "json"); } else if (arguments.format == "table") { // Build rows for table var rows = []; + for (var envKey in envVars) { - var displayValue = envVars[envKey]; - if (findNoCase("password", envKey) || findNoCase("secret", envKey) || findNoCase("key", envKey)) { - displayValue = repeatString("*", min(len(displayValue), 8)); + var displayValue = maskSensitiveValue(envKey, envVars[envKey]); + + var row = structNew("ordered"); + row["Variable"] = envKey; + row["Value"] = displayValue; + row["Source"] = arguments.file; + + arrayAppend(rows, row); + } + + // Sort rows by Variable name + rows.sort(function(a, b) { + return compareNoCase(a.Variable, b.Variable); + }); + + detailOutput.subHeader("Environment Variables from #arguments.file#"); + detailOutput.getPrint().table(rows); + detailOutput.line(); + + // Show summary and tips + var sensitiveCount = 0; + for (var envKey in envVars) { + if (isSensitiveKey(envKey)) { + sensitiveCount++; } - arrayAppend(rows, { - "Variable" = envKey, - "Value" = displayValue, - "Source" = arguments.file - }); } - print.boldYellowLine("Environment Variables from #arguments.file#:"); - print.line(); - print.table(rows); - print.line(); - print.greyLine("Tip: Access these in your app with application.env['KEY_NAME']"); - print.greyLine("Or use them in config files: set(dataSourceName=application.env['DB_NAME'])"); - print.greyLine("Wheels automatically loads .env on application start"); + detailOutput.metric("Total variables", "#structCount(envVars)#"); + if (sensitiveCount > 0) { + detailOutput.metric("Sensitive variables", "#sensitiveCount# (masked)"); + } + detailOutput.line(); + detailOutput.statusInfo("Usage tips:"); + detailOutput.output("- Access in app: application.env['VARIABLE_NAME']", true); + detailOutput.output("- Use in config: set(value=application.env['VARIABLE_NAME'])", true); + detailOutput.output("- Wheels loads .env automatically on app start", true); + detailOutput.output("- Update: wheels env set KEY=VALUE", true); + + } else if (arguments.format == "list") { + detailOutput.subHeader("Environment Variables from #arguments.file#"); + for (var envKey in envVars) { + var displayValue = maskSensitiveValue(envKey, envVars[envKey]); + detailOutput.output("#envKey#=#displayValue#"); + } } } catch (any e) { - error("Error showing environment variables: #e.message#"); + detailOutput.error("Error showing environment variables: #e.message#"); } } @@ -182,4 +234,28 @@ component extends="../base" { return envVars; } + + /** + * Mask sensitive values + */ + private function maskSensitiveValue(required string key, required string value) { + if (isSensitiveKey(arguments.key)) { + return repeatString("*", min(len(arguments.value), 8)); + } + return arguments.value; + } + + /** + * Check if a key is sensitive + */ + private function isSensitiveKey(required string key) { + return ( + findNoCase("password", arguments.key) || + findNoCase("secret", arguments.key) || + findNoCase("key", arguments.key) || + findNoCase("token", arguments.key) || + findNoCase("auth", arguments.key) || + findNoCase("credential", arguments.key) + ); + } } \ No newline at end of file diff --git a/cli/src/commands/wheels/env/switch.cfc b/cli/src/commands/wheels/env/switch.cfc index ce68df30b0..66eb7bb635 100644 --- a/cli/src/commands/wheels/env/switch.cfc +++ b/cli/src/commands/wheels/env/switch.cfc @@ -9,6 +9,7 @@ component extends="../base" { property name="environmentService" inject="EnvironmentService@wheels-cli"; + property name="detailOutput" inject="DetailOutputService@wheels-cli"; /** * @environment.hint Environment name to switch to @@ -35,224 +36,202 @@ component extends="../base" { // Display switch information (unless quiet mode) if (!arguments.quiet) { - print.line() - .boldBlueLine("Environment Switch") - .line("=".repeatString(50)) - .line(); + detailOutput.header("Environment Switch"); + detailOutput.line(); if (currentEnv != "none") { - print.line("Current Environment: ") - .boldText(currentEnv) - .line(); + detailOutput.metric("Current Environment", currentEnv); } else { - print.yellowLine("No environment currently set"); + detailOutput.statusWarning("No environment currently set"); } - print.line("Target Environment: ") - .boldText(arguments.environment) - .line() - .line(); + detailOutput.metric("Target Environment", arguments.environment); + detailOutput.line(); } // Validation phase (if check is enabled and not forced) if (arguments.check && !arguments.force) { if (!arguments.quiet) { - print.text("Validating target environment... "); + detailOutput.output("Validating target environment..."); } var validation = validateEnvironment(arguments.environment, projectRoot); if (!validation.isValid) { if (!arguments.quiet) { - print.redLine("[FAILED]") - .redLine(" Validation failed: #validation.error#"); + detailOutput.statusFailed("Validation failed: #validation.error#"); } if (!arguments.force) { if (!arguments.quiet) { - print.line() - .redBoldLine("[X] Switch cancelled due to validation errors") - .yellowLine(" Use --force to override validation") - .line(); + detailOutput.error("Switch cancelled due to validation errors"); + detailOutput.statusInfo("Use --force to override validation"); } setExitCode(1); return; } else if (!arguments.quiet) { - print.yellowLine(" WARNING: Continuing anyway (--force enabled)"); + detailOutput.statusWarning("Continuing anyway (--force enabled)"); } } else if (!arguments.quiet) { - print.greenLine("[OK]"); + detailOutput.statusSuccess("Environment validation passed"); if (structKeyExists(validation, "warning") && len(validation.warning)) { - print.yellowLine(" Warning: #validation.warning#"); + detailOutput.statusWarning("#validation.warning#"); } } } else if (arguments.force && !arguments.quiet) { - print.yellowLine("WARNING: Validation skipped (--force enabled)"); + detailOutput.statusWarning("Validation skipped (--force enabled)"); } // Backup phase (if backup is requested) if (arguments.backup) { if (!arguments.quiet) { - print.text("Creating backup... "); + detailOutput.output("Creating backup..."); } var backupResult = createBackup(projectRoot); if (!backupResult.success) { if (!arguments.quiet) { - print.redLine("[FAILED]") - .redLine(" Backup failed: #backupResult.error#"); + detailOutput.statusFailed("Backup failed: #backupResult.error#"); } if (!arguments.force) { if (!arguments.quiet) { - print.line() - .redBoldLine("[X] Switch cancelled due to backup failure") - .line(); + detailOutput.error("Switch cancelled due to backup failure"); } setExitCode(1); return; } else if (!arguments.quiet) { - print.yellowLine(" WARNING: Continuing without backup (--force enabled)"); + detailOutput.statusWarning("Continuing without backup (--force enabled)"); } } else if (!arguments.quiet) { - print.greenLine("[OK]") - .greyLine(" Backup saved: #backupResult.filename#"); + detailOutput.statusSuccess("Backup created"); + detailOutput.metric("Backup saved", "#backupResult.filename#"); } } // Confirm for production switches (unless forced or quiet) if (arguments.environment == "production" && currentEnv != "production" && !arguments.force && !arguments.quiet) { - print.yellowLine("WARNING: Switching to PRODUCTION environment") - .line(" This will:") - .line(" - Disable debug mode") - .line(" - Enable full caching") - .line(" - Hide detailed error messages") - .line(); + detailOutput.statusWarning("Switching to PRODUCTION environment"); + detailOutput.output("This will:"); + detailOutput.output("- Disable debug mode", true); + detailOutput.output("- Enable full caching", true); + detailOutput.output("- Hide detailed error messages", true); + detailOutput.line(); var confirmed = ask("Are you sure you want to continue? (yes/no): "); if (confirmed != "yes" && confirmed != "y") { - print.redLine("[X] Switch cancelled") - .line(); + detailOutput.statusInfo("Switch cancelled"); return; } - print.line(); + detailOutput.line(); } - // Show progress (unless quiet) + // Perform the switch if (!arguments.quiet) { - print.text("Switching environment... "); + detailOutput.output("Switching environment..."); } - // Perform the switch var result = environmentService.switch(arguments.environment, projectRoot); if (result.success) { if (!arguments.quiet) { - print.greenLine("[OK]"); // Show what was done if (structKeyExists(result, "oldEnvironment") && len(result.oldEnvironment)) { - print.text("Updated environment variable... ") - .greenLine("[OK]"); + detailOutput.update(".env file: Updated from #result.oldEnvironment# to #arguments.environment#", true); } else { - print.text("Set environment variable... ") - .greenLine("[OK]"); + detailOutput.create(".env file", "Set to #arguments.environment# environment"); } } // Restart services if requested if (arguments.restart) { if (!arguments.quiet) { - print.text("Restarting application... "); + detailOutput.output("Restarting application..."); } var restartResult = restartApplication(projectRoot); if (restartResult.success) { if (!arguments.quiet) { - print.greenLine("[OK]") - .greyLine(" #restartResult.message#"); + detailOutput.statusSuccess("Application restarted"); + detailOutput.metric("Status", restartResult.message); } } else { if (!arguments.quiet) { - print.yellowLine("[WARNING]") - .yellowLine(" Restart failed: #restartResult.error#") - .yellowLine(" Please restart manually"); + detailOutput.statusWarning("Restart failed: #restartResult.error#"); + detailOutput.output("Please restart manually"); } } } // Display success message and details (unless quiet) if (!arguments.quiet) { - print.line() - .line("=".repeatString(50)) - .greenBoldLine("[SUCCESS] Environment switched successfully!") - .line(); + detailOutput.line(); + detailOutput.statusSuccess("Environment switched successfully!"); + detailOutput.line(); // Show environment details if available if (structKeyExists(result, "database") || structKeyExists(result, "debug") || structKeyExists(result, "cache")) { - print.boldLine("Environment Details:") - .line("- Environment: #arguments.environment#"); + detailOutput.subHeader("Environment Details"); + detailOutput.metric("Environment", arguments.environment); if (len(result.database) && result.database != "default") { - print.line("- Database: #result.database#"); + detailOutput.metric("Database", result.database); } if (structKeyExists(result, "debug")) { - print.line("- Debug Mode: #result.debug ? 'Enabled' : 'Disabled'#"); + detailOutput.metric("Debug Mode", result.debug ? "Enabled" : "Disabled"); } if (len(result.cache) && result.cache != "default") { - print.line("- Cache: #result.cache#"); + detailOutput.metric("Cache", result.cache); } - print.line(); + detailOutput.line(); } // Show next steps (unless restart was done) if (!arguments.restart) { - print.yellowBoldLine("IMPORTANT:") - .line("- Restart your application server for changes to take effect") - .line("- Run 'wheels reload' if using Wheels development server") - .line("- Or use 'wheels env switch #arguments.environment# --restart' next time") - .line(); + detailOutput.statusInfo("IMPORTANT"); + detailOutput.output("- Restart your application server for changes to take effect",true); + detailOutput.output("- Run 'wheels reload' if using Wheels development server",true); + detailOutput.output("- Or use 'wheels env switch #arguments.environment# --restart' next time",true); + detailOutput.line(); } // Environment-specific tips if (arguments.environment == "production") { - print.cyanLine("Production Tips:") - .line("- Ensure all migrations are up to date") - .line("- Clear application caches after restart") - .line("- Monitor error logs for any issues") - .line(); + detailOutput.subHeader("Production Tips"); + detailOutput.output("- Ensure all migrations are up to date", true); + detailOutput.output("- Clear application caches after restart", true); + detailOutput.output("- Monitor error logs for any issues", true); + detailOutput.line(); } else if (arguments.environment == "development") { - print.cyanLine("Development Mode:") - .line("- Debug information will be displayed") - .line("- Caching may be disabled") - .line("- Detailed error messages will be shown") - .line(); + detailOutput.subHeader("Development Mode"); + detailOutput.output("- Debug information will be displayed", true); + detailOutput.output("- Caching may be disabled", true); + detailOutput.output("- Detailed error messages will be shown", true); + detailOutput.line(); } } else { // Minimal output in quiet mode - just success - print.greenLine("Environment switched to #arguments.environment#"); + detailOutput.statusSuccess("Environment switched to #arguments.environment#"); } } else { if (!arguments.quiet) { - print.redLine("[FAILED]"); - print.line() - .redBoldLine("[X] Failed to switch environment") - .redLine(" Error: #result.error#") - .line(); + detailOutput.statusFailed("Failed to switch environment"); + detailOutput.error("#result.error#"); + detailOutput.line(); // Provide helpful suggestions - print.yellowLine("Suggestions:") - .line("- Check if you have write permissions for .env file") - .line("- Ensure the environment name is valid") - .line("- Try running with administrator/sudo privileges if needed") - .line("- Use --force to bypass validation checks") - .line(); + detailOutput.statusInfo("Suggestions"); + detailOutput.output("- Check if you have write permissions for .env file", true); + detailOutput.output("- Ensure the environment name is valid", true); + detailOutput.output("- Try running with administrator/sudo privileges if needed", true); + detailOutput.output("- Use --force to bypass validation checks", true); } else { // Minimal output in quiet mode - print.redLine("Failed: #result.error#"); + detailOutput.statusFailed("#result.error#"); } setExitCode(1); diff --git a/cli/src/commands/wheels/env/validate.cfc b/cli/src/commands/wheels/env/validate.cfc index 29bb2db699..ad1eaa4507 100644 --- a/cli/src/commands/wheels/env/validate.cfc +++ b/cli/src/commands/wheels/env/validate.cfc @@ -10,6 +10,8 @@ */ component extends="../base" { + property name="detailOutput" inject="DetailOutputService@wheels-cli"; + /** * @file.hint The .env file to validate (defaults to .env) * @required.hint Comma-separated list of required keys @@ -26,14 +28,12 @@ component extends="../base" { local.envFile = resolvePath(arguments.file); if (!fileExists(local.envFile)) { - print.redLine("File not found: #arguments.file#"); + detailOutput.error("File not found: #arguments.file#"); setExitCode(1); return; } - print.line(); - print.boldLine("Validating: #arguments.file#"); - print.line(); + detailOutput.header("Validating: #arguments.file#"); local.issues = []; local.warnings = []; @@ -48,8 +48,8 @@ component extends="../base" { try { local.envVars = deserializeJSON(local.content); if (arguments.verbose) { - print.greenLine("Valid JSON format detected"); - print.line(); + detailOutput.statusInfo("Valid JSON format detected"); + detailOutput.line(); } } catch (any e) { arrayAppend(local.issues, { @@ -163,38 +163,38 @@ component extends="../base" { ) { // Display errors if (arrayLen(arguments.issues)) { - print.boldRedLine("Errors found:"); + detailOutput.statusFailed("Errors found:"); for (local.issue in arguments.issues) { if (local.issue.line > 0) { - print.redLine(" Line #local.issue.line#: #local.issue.message#"); + detailOutput.output("- Line #local.issue.line#: #local.issue.message#", true); } else { - print.redLine(" #local.issue.message#"); + detailOutput.output("- #local.issue.message#", true); } } - print.line(); + detailOutput.line(); } // Display warnings if (arrayLen(arguments.warnings)) { - print.boldYellowLine("Warnings:"); + detailOutput.statusWarning("Warnings:"); for (local.warning in arguments.warnings) { if (local.warning.line > 0) { - print.yellowLine(" Line #local.warning.line#: #local.warning.message#"); + detailOutput.output("- Line #local.warning.line#: #local.warning.message#", true); } else { - print.yellowLine(" #local.warning.message#"); + detailOutput.output("- #local.warning.message#", true); } } - print.line(); + detailOutput.line(); } // Display summary local.keyCount = structCount(arguments.envVars); - print.boldLine("Summary:"); - print.line(" Total variables: #local.keyCount#"); + detailOutput.subHeader("Summary"); + detailOutput.metric("Total variables", local.keyCount); if (arguments.verbose && local.keyCount > 0) { - print.line(); - print.boldLine("Environment Variables:"); + detailOutput.line(); + detailOutput.subHeader("Environment Variables:"); // Group by prefix local.grouped = {}; @@ -214,8 +214,8 @@ component extends="../base" { // Display grouped variables for (local.prefix in local.grouped) { - print.line(); - print.line(" #local.prefix#:"); + detailOutput.line(); + detailOutput.output("#local.prefix#:"); for (local.key in local.grouped[local.prefix]) { local.value = arguments.envVars[local.key]; // Mask sensitive values @@ -223,14 +223,14 @@ component extends="../base" { findNoCase("key", local.key) || findNoCase("token", local.key)) { local.value = "***MASKED***"; } - print.line(" #local.key# = #local.value#"); + detailOutput.output("- #local.key# = #local.value#", true); } } // Display ungrouped variables if (arrayLen(local.ungrouped)) { - print.line(); - print.line(" Other:"); + detailOutput.line(); + detailOutput.output("Other:"); for (local.key in local.ungrouped) { local.value = arguments.envVars[local.key]; // Mask sensitive values @@ -238,23 +238,24 @@ component extends="../base" { findNoCase("key", local.key) || findNoCase("token", local.key)) { local.value = "***MASKED***"; } - print.line(" #local.key# = #local.value#"); + detailOutput.output(" #local.key# = #local.value#", true); } } } - print.line(); + detailOutput.line(); // Final status if (arrayLen(arguments.issues) == 0) { if (arrayLen(arguments.warnings) == 0) { - print.greenLine("Validation passed with no issues!"); + detailOutput.statusSuccess("Validation passed with no issues!"); } else { - print.yellowLine("Validation passed with #arrayLen(arguments.warnings)# warning#arrayLen(arguments.warnings) != 1 ? 's' : ''#"); + detailOutput.statusWarning("Validation passed with #arrayLen(arguments.warnings)# warning#arrayLen(arguments.warnings) != 1 ? 's' : ''#"); } } else { - print.redLine("Validation failed with #arrayLen(arguments.issues)# error#arrayLen(arguments.issues) != 1 ? 's' : ''#"); + detailOutput.statusFailed("Validation failed with #arrayLen(arguments.issues)# error#arrayLen(arguments.issues) != 1 ? 's' : ''#"); setExitCode(1); + return; } } } \ No newline at end of file diff --git a/cli/src/commands/wheels/generate/controller.cfc b/cli/src/commands/wheels/generate/controller.cfc index 19a93a0e6d..d83cd3bc42 100644 --- a/cli/src/commands/wheels/generate/controller.cfc +++ b/cli/src/commands/wheels/generate/controller.cfc @@ -44,7 +44,7 @@ component aliases="wheels g controller" extends="../base" { // Validate controller name var validation = codeGenerationService.validateName(listLast(arguments.name, "/"), "controller"); if (!validation.valid) { - error("Invalid controller name: " & arrayToList(validation.errors, ", ")); + detailOutput.error("Invalid controller name: " & arrayToList(validation.errors, ", ")); return; } diff --git a/cli/src/commands/wheels/generate/frontend.cfc b/cli/src/commands/wheels/generate/frontend.cfc deleted file mode 100644 index 94d4f0f1b6..0000000000 --- a/cli/src/commands/wheels/generate/frontend.cfc +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Scaffold and integrate modern frontend frameworks with Wheels - * - * {code:bash} - * wheels generate frontend framework=react - * wheels generate frontend framework=vue - * wheels generate frontend framework=alpine - * {code} - */ -component extends="../base" { - - /** - * Initialize the command - */ - function init() { - super.init(); - return this; - } - - /** - * @framework Frontend framework to use (react, vue, alpine) - * @path Directory to install frontend (defaults to /app/assets/frontend) - * @api Generate API endpoint for frontend - */ - function run( - required string framework, - string path="app/assets/frontend", - boolean api=false - ) { - // Welcome message - print.line(); - print.boldMagentaLine("Wheels Frontend Framework Generator"); - print.line(); - - // Validate framework - local.supportedFrameworks = ["react", "vue", "alpine"]; - if (!arrayContains(local.supportedFrameworks, lCase(arguments.framework))) { - error("Unsupported framework: #arguments.framework#. Please choose from: #arrayToList(local.supportedFrameworks)#"); - } - - // Ensure target directory exists - local.targetPath = fileSystemUtil.resolvePath(arguments.path); - if (!directoryExists(local.targetPath)) { - directoryCreate(local.targetPath); - print.greenLine("Created directory: #arguments.path#"); - } - - print.line("Setting up #arguments.framework# in #arguments.path#..."); - - switch(lCase(arguments.framework)) { - case "alpine": - setupAlpine(local.targetPath); - break; - case "react": - setupReact(local.targetPath); - break; - case "vue": - setupVue(local.targetPath); - break; - } - - // Generate API endpoint if requested - if (arguments.api) { - print.line(); - print.line("Generating API endpoint for frontend..."); - // Create a simple API controller for the frontend - command("wheels generate api-resource") - .params(name="frontend-data") - .run(); - } - - print.line(); - print.greenLine("Frontend setup complete!"); - print.line(); - } - - private function setupAlpine(required string path) { - // Create Alpine.js setup - local.indexContent = ' - - - - - Wheels + Alpine.js - - - - -
-

Wheels + Alpine.js

-
-

- -
-
- -'; - - file action='write' file='#arguments.path#/index.html' mode='777' output='#trim(local.indexContent)#'; - print.greenLine("Created Alpine.js template at #arguments.path#/index.html"); - - // Create example component - local.componentContent = '// Alpine.js component example -Alpine.data(''wheelsApp'', () => ({ - items: [], - loading: false, - - async init() { - await this.fetchData(); - }, - - async fetchData() { - this.loading = true; - try { - // Replace with your actual API endpoint - const response = await fetch(''/api/items''); - this.items = await response.json(); - } catch (error) { - console.error(''Error fetching data:'', error); - } finally { - this.loading = false; - } - } -}));'; - - file action='write' file='#arguments.path#/app.js' mode='777' output='#trim(local.componentContent)#'; - print.greenLine("Created Alpine.js component at #arguments.path#/app.js"); - } - - private function setupReact(required string path) { - // Create basic React setup with CDN - local.indexContent = ' - - - - - Wheels + React - - - - - - -
-
-
- - -'; - - file action='write' file='#arguments.path#/index.html' mode='777' output='#trim(local.indexContent)#'; - print.greenLine("Created React template at #arguments.path#/index.html"); - - // Create React component - local.componentContent = 'const { useState, useEffect } = React; - -function App() { - const [message, setMessage] = useState(''Welcome to React with Wheels!''); - const [count, setCount] = useState(0); - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); - - useEffect(() => { - fetchData(); - }, []); - - const fetchData = async () => { - setLoading(true); - try { - // Replace with your actual API endpoint - const response = await fetch(''/api/items''); - const result = await response.json(); - setData(result); - } catch (error) { - console.error(''Error fetching data:'', error); - } finally { - setLoading(false); - } - }; - - return ( -
-

Wheels + React

-

{message}

- - {loading &&

Loading...

} -
- ); -} - -ReactDOM.render(, document.getElementById(''root''));'; - - file action='write' file='#arguments.path#/app.jsx' mode='777' output='#trim(local.componentContent)#'; - print.greenLine("Created React component at #arguments.path#/app.jsx"); - - // Create package.json for proper React setup - local.packageJson = '{ - "name": "wheels-react-frontend", - "version": "1.0.0", - "description": "React frontend for Wheels application", - "scripts": { - "dev": "echo ''For production setup, run: npm install && npm run build''", - "build": "echo ''Configure your build process here''" - }, - "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, - "devDependencies": { - "@vitejs/plugin-react": "^4.0.0", - "vite": "^4.3.0" - } -}'; - - file action='write' file='#arguments.path#/package.json' mode='777' output='#trim(local.packageJson)#'; - print.yellowLine("Note: For production React setup, run: cd #arguments.path# && npm install"); - } - - private function setupVue(required string path) { - // Create basic Vue setup with CDN - local.indexContent = ' - - - - - Wheels + Vue.js - - - - -
-

Wheels + Vue.js

-

{{ message }}

- -
Loading...
-
    -
  • {{ item.name }}
  • -
-
- - -'; - - file action='write' file='#arguments.path#/index.html' mode='777' output='#trim(local.indexContent)#'; - print.greenLine("Created Vue.js template at #arguments.path#/index.html"); - - // Create Vue component - local.componentContent = 'const { createApp } = Vue; - -createApp({ - data() { - return { - message: ''Welcome to Vue.js with Wheels!'', - count: 0, - items: [], - loading: false - } - }, - - mounted() { - this.fetchData(); - }, - - methods: { - incrementCount() { - this.count++; - }, - - async fetchData() { - this.loading = true; - try { - // Replace with your actual API endpoint - const response = await fetch(''/api/items''); - this.items = await response.json(); - } catch (error) { - console.error(''Error fetching data:'', error); - } finally { - this.loading = false; - } - } - } -}).mount(''##app'');'; - - file action='write' file='#arguments.path#/app.js' mode='777' output='#trim(local.componentContent)#'; - print.greenLine("Created Vue.js component at #arguments.path#/app.js"); - - // Create package.json for proper Vue setup - local.packageJson = '{ - "name": "wheels-vue-frontend", - "version": "1.0.0", - "description": "Vue frontend for Wheels application", - "scripts": { - "dev": "echo ''For production setup, run: npm install && npm run build''", - "build": "echo ''Configure your build process here''" - }, - "dependencies": { - "vue": "^3.3.0" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^4.2.0", - "vite": "^4.3.0" - } -}'; - - file action='write' file='#arguments.path#/package.json' mode='777' output='#trim(local.packageJson)#'; - print.yellowLine("Note: For production Vue setup, run: cd #arguments.path# && npm install"); - } -} diff --git a/cli/src/commands/wheels/generate/frontend.cfc.bak b/cli/src/commands/wheels/generate/frontend.cfc.bak deleted file mode 100644 index 1929dcc9c7..0000000000 --- a/cli/src/commands/wheels/generate/frontend.cfc.bak +++ /dev/null @@ -1,504 +0,0 @@ -/** - * Scaffold and integrate modern frontend frameworks with Wheels - * - * {code:bash} - * wheels generate frontend --framework=react - * wheels generate frontend --framework=vue - * wheels generate frontend --framework=alpine - * {code} - */ -component extends="../base" { - - /** - * @framework Frontend framework to use (react, vue, alpine) - * @path Directory to install frontend (defaults to /app/assets/frontend) - * @api Generate API endpoint for frontend - */ - function run( - required string framework, - string path="app/assets/frontend", - boolean api=false - ) { - // Welcome message - print.line(); - print.boldMagentaLine("Wheels Frontend Generator"); - print.line(); - - // Validate framework selection - local.supportedFrameworks = ["react", "vue", "alpine"]; - if (!arrayContains(local.supportedFrameworks, lCase(arguments.framework))) { - error("Unsupported framework: #arguments.framework#. Please choose from: #arrayToList(local.supportedFrameworks)#"); - } - - // Create directory if it doesn't exist - local.fullPath = fileSystemUtil.resolvePath(arguments.path); - if (!directoryExists(local.fullPath)) { - directoryCreate(local.fullPath); - } - - // Create package.json - local.packageJson = createPackageJson(arguments.framework); - file action='write' file='#local.fullPath#/package.json' mode='777' output='#local.packageJson#'; - - // Create base configuration files - switch (lCase(arguments.framework)) { - case "react": - setupReactProject(local.fullPath); - break; - case "vue": - setupVueProject(local.fullPath); - break; - case "alpine": - setupAlpineProject(local.fullPath); - break; - } - - // Create asset pipeline integration - setupAssetPipeline(arguments.framework, local.fullPath); - - // Generate API if requested - if (arguments.api) { - command("wheels generate api-resource") - .params(name="api", docs=true) - .run(); - } - - // Final instructions - print.line(); - print.greenLine("Frontend scaffolding for #arguments.framework# has been set up in #arguments.path#"); - print.yellowLine("Next steps:"); - print.line("1. Navigate to #arguments.path#"); - print.line("2. Run 'npm install' to install dependencies"); - print.line("3. Run 'npm run dev' for development or 'npm run build' for production"); - print.line(); - } - - /** - * Generate package.json content based on selected framework - */ - private string function createPackageJson(required string framework) { - local.packageJson = { - "name": "cfwheels-frontend", - "version": "1.0.0", - "description": "Frontend for Wheels application", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": {}, - "devDependencies": { - "vite": "^4.0.0" - } - }; - - // Add framework-specific dependencies - switch (lCase(arguments.framework)) { - case "react": - local.packageJson.dependencies["react"] = "^18.2.0"; - local.packageJson.dependencies["react-dom"] = "^18.2.0"; - local.packageJson.devDependencies["@vitejs/plugin-react"] = "^3.0.0"; - break; - case "vue": - local.packageJson.dependencies["vue"] = "^3.2.45"; - local.packageJson.devDependencies["@vitejs/plugin-vue"] = "^4.0.0"; - break; - case "alpine": - local.packageJson.dependencies["alpinejs"] = "^3.10.5"; - break; - } - - return serializeJSON(local.packageJson, "struct"); - } - - /** - * Setup React project structure - */ - private void function setupReactProject(required string path) { - // Create src directory - if (!directoryExists(arguments.path & "/src")) { - directoryCreate(arguments.path & "/src"); - } - - // Create main.jsx - local.mainContent = 'import React from "react"; -import ReactDOM from "react-dom/client"; -import App from "./App"; -import "./index.css"; - -ReactDOM.createRoot(document.getElementById("root")).render( - - - -);'; - file action='write' file='#arguments.path#/src/main.jsx' mode='777' output='#local.mainContent#'; - - // Create App.jsx - local.appContent = 'import { useState } from "react"; -import "./App.css"; - -function App() { - const [count, setCount] = useState(0); - - return ( -
-
-

Wheels + React

-

- -

-
-
- ); -} - -export default App;'; - file action='write' file='#arguments.path#/src/App.jsx' mode='777' output='#local.appContent#'; - - // Create index.css - local.cssContent = ':root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - color: white; - cursor: pointer; - transition: border-color 0.25s; -}'; - file action='write' file='#arguments.path#/src/index.css' mode='777' output='#local.cssContent#'; - - // Create vite.config.js - local.viteContent = 'import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; - -export default defineConfig({ - plugins: [react()], - build: { - outDir: "../../../public/assets/frontend", - emptyOutDir: true, - manifest: true, - }, -});'; - file action='write' file='#arguments.path#/vite.config.js' mode='777' output='#local.viteContent#'; - - print.greenLine("React project structure created"); - } - - /** - * Setup Vue project structure - */ - private void function setupVueProject(required string path) { - // Create src directory - if (!directoryExists(arguments.path & "/src")) { - directoryCreate(arguments.path & "/src"); - } - - // Create main.js - local.mainContent = 'import { createApp } from "vue"; -import App from "./App.vue"; -import "./style.css"; - -createApp(App).mount("#app");'; - file action='write' file='#arguments.path#/src/main.js' mode='777' output='#local.mainContent#'; - - // Create App.vue - local.appContent = ' - - - -'; - file action='write' file='#arguments.path#/src/App.vue' mode='777' output='#local.appContent#'; - - // Create style.css - local.cssContent = ':root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - color: white; - cursor: pointer; - transition: border-color 0.25s; -}'; - file action='write' file='#arguments.path#/src/style.css' mode='777' output='#local.cssContent#'; - - // Create vite.config.js - local.viteContent = 'import { defineConfig } from "vite"; -import vue from "@vitejs/plugin-vue"; - -export default defineConfig({ - plugins: [vue()], - build: { - outDir: "../../../public/assets/frontend", - emptyOutDir: true, - manifest: true, - }, -});'; - file action='write' file='#arguments.path#/vite.config.js' mode='777' output='#local.viteContent#'; - - print.greenLine("Vue project structure created"); - } - - /** - * Setup Alpine.js project structure - */ - private void function setupAlpineProject(required string path) { - // Create src directory - if (!directoryExists(arguments.path & "/src")) { - directoryCreate(arguments.path & "/src"); - } - - // Create main.js - local.mainContent = 'import Alpine from "alpinejs"; -import "./style.css"; - -window.Alpine = Alpine; -Alpine.start();'; - file action='write' file='#arguments.path#/src/main.js' mode='777' output='#local.mainContent#'; - - // Create index.html for Alpine - local.htmlContent = ' - - - - - Wheels + Alpine.js - - -
-
-

Wheels + Alpine.js

-

- -

-
-
- - -'; - file action='write' file='#arguments.path#/index.html' mode='777' output='#local.htmlContent#'; - - // Create style.css - local.cssContent = ':root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - color: white; - cursor: pointer; - transition: border-color 0.25s; -}'; - file action='write' file='#arguments.path#/src/style.css' mode='777' output='#local.cssContent#'; - - // Create vite.config.js - local.viteContent = 'import { defineConfig } from "vite"; - -export default defineConfig({ - build: { - outDir: "../../../public/assets/frontend", - emptyOutDir: true, - manifest: true, - }, -});'; - file action='write' file='#arguments.path#/vite.config.js' mode='777' output='#local.viteContent#'; - - print.greenLine("Alpine.js project structure created"); - } - - /** - * Setup asset pipeline integration - */ - private void function setupAssetPipeline(required string framework, required string path) { - // Create integration helper for the view - local.assetsHelperPath = fileSystemUtil.resolvePath("app/helpers/frontend_assets.cfm"); - - local.assetsHelperContent = ' -/** - * Includes frontend assets in the view - * Usage: #frontendAssets()# - */ -function frontendAssets() { - local.manifestPath = expandPath("/public/assets/frontend/manifest.json"); - local.scriptTag = ""; - local.styleTag = ""; - - if (fileExists(local.manifestPath)) { - local.manifest = deserializeJSON(fileRead(local.manifestPath)); - - // Get the main entry point - if (structKeyExists(local.manifest, "src/main.'; - - // Add framework-specific file extension - switch (lCase(arguments.framework)) { - case "react": - local.assetsHelperContent &= 'jsx'; - break; - case "vue": - case "alpine": - local.assetsHelperContent &= 'js'; - break; - } - - local.assetsHelperContent &= '")) { - local.entry = local.manifest["src/main.'; - - // Add framework-specific file extension again - switch (lCase(arguments.framework)) { - case "react": - local.assetsHelperContent &= 'jsx'; - break; - case "vue": - case "alpine": - local.assetsHelperContent &= 'js'; - break; - } - - local.assetsHelperContent &= '"]; - - // Script tag for the main JS file - if (structKeyExists(local.entry, "file")) { - local.scriptTag = \'\'; - } - - // CSS files - if (structKeyExists(local.entry, "css") && isArray(local.entry.css)) { - for (local.cssFile in local.entry.css) { - local.styleTag &= \'\'; - } - } - } - } - - return local.styleTag & local.scriptTag; -} -'; - - file action='write' file='#local.assetsHelperPath#' mode='777' output='#local.assetsHelperContent#'; - - // Create a sample view that uses the frontend - local.layoutPath = fileSystemUtil.resolvePath("app/views/layouts/frontend.cfm"); - - local.layoutContent = ' - - - - - Wheels with #arguments.framework# - #frontendAssets()# - - -
0) { - error("The following function(s) already exist in helper files: #arrayToList(existingFunctions, ', ')#"); + detailOutput.error("The following function(s) already exist in helper files: #arrayToList(existingFunctions, ', ')#"); return; } } @@ -81,7 +81,7 @@ component aliases='wheels g helper' extends="../base" { createHelperTest(arguments.name, functionList); // Show usage example - detailOutput.separator(); + detailOutput.line(); detailOutput.output("Usage example:"); detailOutput.code('// Helper functions are automatically available globally result = #functionList[1]#("some input"); diff --git a/cli/src/commands/wheels/generate/migration.cfc b/cli/src/commands/wheels/generate/migration.cfc index cfdf20b882..11d865eb12 100644 --- a/cli/src/commands/wheels/generate/migration.cfc +++ b/cli/src/commands/wheels/generate/migration.cfc @@ -35,7 +35,7 @@ component aliases='wheels g migration' extends="../base" { // Validate migration name if (!reFindNoCase("^[A-Za-z][A-Za-z0-9_]*$", arguments.name)) { - error("Invalid migration name. Use only letters, numbers, and underscores, starting with a letter."); + detailOutput.error("Invalid migration name. Use only letters, numbers, and underscores, starting with a letter."); return; } diff --git a/cli/src/commands/wheels/generate/model.cfc b/cli/src/commands/wheels/generate/model.cfc index abac51f60d..69db36bfea 100644 --- a/cli/src/commands/wheels/generate/model.cfc +++ b/cli/src/commands/wheels/generate/model.cfc @@ -85,7 +85,7 @@ component aliases='wheels g model' extends="../base" { // Validate model name var validation = codeGenerationService.validateName(arguments.name, "model"); if (!validation.valid) { - error("Invalid model name: " & arrayToList(validation.errors, ", ")); + detailOutput.error("Invalid model name: " & arrayToList(validation.errors, ", ")); return; } diff --git a/cli/src/commands/wheels/generate/route.cfc b/cli/src/commands/wheels/generate/route.cfc index a44e7ae564..4533e46dff 100644 --- a/cli/src/commands/wheels/generate/route.cfc +++ b/cli/src/commands/wheels/generate/route.cfc @@ -47,7 +47,8 @@ component aliases='wheels g route, wheels g routes, wheels generate routes' ext if (!len(arguments.objectname) && !len(arguments.get) && !len(arguments.post) && !len(arguments.put) && !len(arguments.patch) && !len(arguments.delete) && !len(arguments.root) && !arguments.resources) { - error("Please provide either an objectname for a resources route or specify a route type (--get, --post, etc.)"); + details.error("Please provide either an objectname for a resources route or specify a route type (--get, --post, etc.)"); + return; } var target = fileSystemUtil.resolvePath("config/routes.cfm"); @@ -138,7 +139,8 @@ component aliases='wheels g route, wheels g routes, wheels generate routes' ext private struct function parseRouteArgument(required string argument) { // Handle edge case of just a comma if (trim(arguments.argument) == ",") { - error("Invalid route format. Pattern cannot be empty. Expected: pattern or pattern,handler"); + details.error("Invalid route format. Pattern cannot be empty. Expected: pattern or pattern,handler"); + return; } // Use includeEmptyFields=true to catch empty patterns/handlers @@ -147,7 +149,8 @@ component aliases='wheels g route, wheels g routes, wheels generate routes' ext // Validate we have at least one part with content if (arrayLen(parts) == 0) { - error("Invalid route format. Expected: pattern or pattern,handler"); + details.error("Invalid route format. Expected: pattern or pattern,handler"); + return; } // Trim all parts @@ -157,7 +160,8 @@ component aliases='wheels g route, wheels g routes, wheels generate routes' ext // Check if first part (pattern) is empty if (!len(parts[1])) { - error("Invalid route format. Pattern cannot be empty. Expected: pattern or pattern,handler"); + details.error("Invalid route format. Pattern cannot be empty. Expected: pattern or pattern,handler"); + return; } if (arrayLen(parts) == 1) { @@ -166,7 +170,8 @@ component aliases='wheels g route, wheels g routes, wheels generate routes' ext } else if (arrayLen(parts) == 2) { // Both pattern and handler provided if (!len(parts[2])) { - error("Invalid route format. Handler cannot be empty. Expected: pattern,controller##action"); + details.error("Invalid route format. Handler cannot be empty. Expected: pattern,controller##action"); + return; } // Validate handler format @@ -174,7 +179,8 @@ component aliases='wheels g route, wheels g routes, wheels generate routes' ext result.inject = 'pattern="' & parts[1] & '", to="' & parts[2] & '"'; } else { - error("Invalid route format. Expected: pattern,handler (got too many comma-separated values)"); + details.error("Invalid route format. Expected: pattern,handler (got too many comma-separated values)"); + return; } return result; @@ -192,38 +198,45 @@ component aliases='wheels g route, wheels g routes, wheels generate routes' ext // If there's an odd number of # characters, user provided single hash if (hashCount % 2 != 0) { - error("Invalid handler format. Use double hash (#doubleHash#) to separate controller and action, not single hash (#hashChar#). Example: controller#doubleHash#action"); + details.error("Invalid handler format. Use double hash (#doubleHash#) to separate controller and action, not single hash (#hashChar#). Example: controller#doubleHash#action"); + return; } // Must contain ## separator if (!find(doubleHash, arguments.handler)) { - error("Invalid handler format. Handler must contain #doubleHash# separator. Expected: controller#doubleHash#action"); + details.error("Invalid handler format. Handler must contain #doubleHash# separator. Expected: controller#doubleHash#action"); + return; } // Check if handler starts with ## (missing controller) if (left(arguments.handler, 2) == doubleHash) { - error("Invalid handler format - missing controller. Expected: controller#doubleHash#action"); + details.error("Invalid handler format - missing controller. Expected: controller#doubleHash#action"); + return; } // Check if handler ends with ## (missing action) if (right(arguments.handler, 2) == doubleHash) { - error("Invalid handler format - missing action. Expected: controller#doubleHash#action"); + details.error("Invalid handler format - missing action. Expected: controller#doubleHash#action"); + return; } // Split by ## and validate var parts = listToArray(arguments.handler, doubleHash); if (arrayLen(parts) != 2) { - error("Invalid handler format. Handler must have exactly one #doubleHash# separator. Expected: controller#doubleHash#action"); + details.error("Invalid handler format. Handler must have exactly one #doubleHash# separator. Expected: controller#doubleHash#action"); + return; } // Double-check that both parts have content (in case of edge cases) if (!len(trim(parts[1]))) { - error("Invalid handler format - missing controller. Expected: controller#doubleHash#action"); + details.error("Invalid handler format - missing controller. Expected: controller#doubleHash#action"); + return; } if (!len(trim(parts[2]))) { - error("Invalid handler format - missing action. Expected: controller#doubleHash#action"); + details.error("Invalid handler format - missing action. Expected: controller#doubleHash#action"); + return; } } diff --git a/cli/src/commands/wheels/generate/scaffold.cfc b/cli/src/commands/wheels/generate/scaffold.cfc index 72b5605d20..3841c4e8a7 100644 --- a/cli/src/commands/wheels/generate/scaffold.cfc +++ b/cli/src/commands/wheels/generate/scaffold.cfc @@ -69,7 +69,7 @@ component aliases="wheels g scaffold, wheels g resource, wheels generate resourc if (!validation.valid) { detailOutput.error("Cannot scaffold '#arguments.name#':"); for (var error in validation.errors) { - print.redLine(" - #error#"); + detailOutput.error("#error#"); } setExitCode(1); return; @@ -92,7 +92,7 @@ component aliases="wheels g scaffold, wheels g resource, wheels generate resourc if (!result.success) { detailOutput.error("Scaffolding failed!"); for (var error in result.errors) { - print.redLine(" - #error#"); + detailOutput.error("#error#"); } setExitCode(1); return; diff --git a/cli/src/commands/wheels/generate/service.cfc b/cli/src/commands/wheels/generate/service.cfc deleted file mode 100644 index a71fb0af00..0000000000 --- a/cli/src/commands/wheels/generate/service.cfc +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Generate a service object for business logic - * - * Examples: - * wheels generate service Payment - * wheels generate service UserAuthentication --methods="login,logout,register,verify" - * wheels generate service OrderProcessing --dependencies="PaymentService,EmailService" - * wheels generate service DataExport --type=singleton - */ -component aliases='wheels g service' extends="../base" { - - property name="codeGenerationService" inject="CodeGenerationService@wheels-cli"; - property name="helpers" inject="helpers@wheels-cli"; - property name="detailOutput" inject="DetailOutputService@wheels-cli"; - - /** - * @name.hint Name of the service (e.g., PaymentService, UserService) - * @methods.hint Comma-separated list of methods to generate - * @dependencies.hint Comma-separated list of service dependencies - * @type.hint Service type: transient or singleton (default: transient) - * @description.hint Service description - * @force.hint Overwrite existing files - */ - function run( - required string name, - string methods = "", - string dependencies = "", - string type = "transient", - string description = "", - boolean force = false - ) { - detailOutput.header("Generating service: #arguments.name#"); - - // Ensure name ends with "Service" - if (!reFindNoCase("Service$", arguments.name)) { - arguments.name &= "Service"; - } - - // Validate service name - var validation = codeGenerationService.validateName(arguments.name, "service"); - if (!validation.valid) { - error("Invalid service name: " & arrayToList(validation.errors, ", ")); - return; - } - - // Validate type - if (!listFindNoCase("transient,singleton", arguments.type)) { - error("Invalid service type. Must be 'transient' or 'singleton'."); - return; - } - - // Set up paths - var servicesDir = helpers.getAppPath() & "/services"; - if (!directoryExists(servicesDir)) { - directoryCreate(servicesDir); - detailOutput.output("Created services directory: /services"); - } - - var servicePath = servicesDir & "/" & arguments.name & ".cfc"; - - // Check if file exists - if (fileExists(servicePath) && !arguments.force) { - error("Service already exists: #arguments.name#.cfc. Use force=true to overwrite."); - return; - } - - // Parse methods and dependencies - var methodList = len(arguments.methods) ? listToArray(arguments.methods, ",") : []; - var dependencyList = len(arguments.dependencies) ? listToArray(arguments.dependencies, ",") : []; - - // Generate service content - var serviceContent = generateServiceContent(arguments, methodList, dependencyList); - - // Write service file - fileWrite(servicePath, serviceContent); - detailOutput.success("Created service: /services/#arguments.name#.cfc"); - - // Create test file - createServiceTest(arguments.name, methodList); - - // Show usage example - detailOutput.separator(); - detailOutput.output("Usage example:"); - - if (arguments.type == "singleton") { - detailOutput.code('// In Application.cfc onApplicationStart() -application.#lCase(arguments.name)# = createObject("component", "services.#arguments.name#").init(); - -// Usage anywhere in the application -result = application.#lCase(arguments.name)#.someMethod();', "cfscript"); - } else { - detailOutput.code('// In your controller or model -service = createObject("component", "services.#arguments.name#").init(); -result = service.someMethod(); - -// Or with dependency injection -property name="#lCase(left(arguments.name, len(arguments.name)-7))#Service" inject="services.#arguments.name#";', "cfscript"); - } - } - - /** - * Generate service component content - */ - private string function generateServiceContent(required struct args, required array methods, required array dependencies) { - var content = "/**" & chr(10); - content &= " * #args.name#" & chr(10); - if (len(args.description)) { - content &= " * #args.description#" & chr(10); - } - content &= " * Type: #args.type#" & chr(10); - content &= " */" & chr(10); - content &= "component"; - - if (args.type == "singleton") { - content &= " singleton"; - } - - content &= " {" & chr(10) & chr(10); - - // Properties for dependencies - if (arrayLen(dependencies)) { - content &= chr(9) & "// Service dependencies" & chr(10); - for (var dep in dependencies) { - var depName = trim(dep); - if (!reFindNoCase("Service$", depName)) { - depName &= "Service"; - } - content &= chr(9) & "property name=""#lCase(left(depName, 1)) & mid(depName, 2, len(depName))#"" type=""services.#depName#"";" & chr(10); - } - content &= chr(10); - } - - // Constructor - content &= chr(9) & "/**" & chr(10); - content &= chr(9) & " * Constructor" & chr(10); - if (arrayLen(dependencies)) { - for (var dep in dependencies) { - var depName = trim(dep); - if (!reFindNoCase("Service$", depName)) { - depName &= "Service"; - } - var paramName = lCase(left(depName, 1)) & mid(depName, 2, len(depName)); - content &= chr(9) & " * @#paramName#.hint #depName# instance" & chr(10); - } - } - content &= chr(9) & " */" & chr(10); - content &= chr(9) & "function init("; - - if (arrayLen(dependencies)) { - var depParams = []; - for (var dep in dependencies) { - var depName = trim(dep); - if (!reFindNoCase("Service$", depName)) { - depName &= "Service"; - } - var paramName = lCase(left(depName, 1)) & mid(depName, 2, len(depName)); - arrayAppend(depParams, chr(10) & chr(9) & chr(9) & "services.#depName# #paramName#"); - } - content &= arrayToList(depParams, ","); - content &= chr(10) & chr(9); - } - - content &= ") {" & chr(10); - - // Set dependencies - if (arrayLen(dependencies)) { - for (var dep in dependencies) { - var depName = trim(dep); - if (!reFindNoCase("Service$", depName)) { - depName &= "Service"; - } - var paramName = lCase(left(depName, 1)) & mid(depName, 2, len(depName)); - content &= chr(9) & chr(9) & "variables.#paramName# = arguments.#paramName#;" & chr(10); - } - content &= chr(10); - } - - content &= chr(9) & chr(9) & "return this;" & chr(10); - content &= chr(9) & "}" & chr(10) & chr(10); - - // Generate methods - if (arrayLen(methods)) { - for (var method in methods) { - content &= generateServiceMethod(trim(method)); - } - } else { - // Generate a sample method - content &= generateServiceMethod("process"); - } - - // Private helper methods section - content &= chr(9) & "// ========================================" & chr(10); - content &= chr(9) & "// Private Methods" & chr(10); - content &= chr(9) & "// ========================================" & chr(10) & chr(10); - - content &= chr(9) & "/**" & chr(10); - content &= chr(9) & " * Private helper method example" & chr(10); - content &= chr(9) & " */" & chr(10); - content &= chr(9) & "private any function validateInput(required any input) {" & chr(10); - content &= chr(9) & chr(9) & "// Add validation logic here" & chr(10); - content &= chr(9) & chr(9) & "return true;" & chr(10); - content &= chr(9) & "}" & chr(10); - - content &= "}"; - - return content; - } - - /** - * Generate individual service method - */ - private string function generateServiceMethod(required string methodName) { - var content = chr(9) & "/**" & chr(10); - content &= chr(9) & " * #humanize(methodName)#" & chr(10); - content &= chr(9) & " * @data.hint Input data for processing" & chr(10); - content &= chr(9) & " * @options.hint Additional options" & chr(10); - content &= chr(9) & " * @return Result of the operation" & chr(10); - content &= chr(9) & " */" & chr(10); - content &= chr(9) & "public any function #methodName#(" & chr(10); - content &= chr(9) & chr(9) & "required any data," & chr(10); - content &= chr(9) & chr(9) & "struct options = {}" & chr(10); - content &= chr(9) & ") {" & chr(10); - - content &= chr(9) & chr(9) & "try {" & chr(10); - content &= chr(9) & chr(9) & chr(9) & "// Validate input" & chr(10); - content &= chr(9) & chr(9) & chr(9) & "if (!validateInput(arguments.data)) {" & chr(10); - content &= chr(9) & chr(9) & chr(9) & chr(9) & "throw(type=""ValidationException"", message=""Invalid input data"");" & chr(10); - content &= chr(9) & chr(9) & chr(9) & "}" & chr(10); - content &= chr(10); - content &= chr(9) & chr(9) & chr(9) & "// Process the data" & chr(10); - content &= chr(9) & chr(9) & chr(9) & "local.result = {" & chr(10); - content &= chr(9) & chr(9) & chr(9) & chr(9) & "success = true," & chr(10); - content &= chr(9) & chr(9) & chr(9) & chr(9) & "data = arguments.data," & chr(10); - content &= chr(9) & chr(9) & chr(9) & chr(9) & "message = ""#humanize(methodName)# completed successfully""" & chr(10); - content &= chr(9) & chr(9) & chr(9) & "};" & chr(10); - content &= chr(10); - content &= chr(9) & chr(9) & chr(9) & "// TODO: Implement actual business logic here" & chr(10); - content &= chr(10); - content &= chr(9) & chr(9) & chr(9) & "return local.result;" & chr(10); - content &= chr(10); - content &= chr(9) & chr(9) & "} catch (any e) {" & chr(10); - content &= chr(9) & chr(9) & chr(9) & "// Handle errors" & chr(10); - content &= chr(9) & chr(9) & chr(9) & "return {" & chr(10); - content &= chr(9) & chr(9) & chr(9) & chr(9) & "success = false," & chr(10); - content &= chr(9) & chr(9) & chr(9) & chr(9) & "error = e.message," & chr(10); - content &= chr(9) & chr(9) & chr(9) & chr(9) & "detail = e.detail" & chr(10); - content &= chr(9) & chr(9) & chr(9) & "};" & chr(10); - content &= chr(9) & chr(9) & "}" & chr(10); - content &= chr(9) & "}" & chr(10) & chr(10); - - return content; - } - - /** - * Create test file for service - */ - private void function createServiceTest(required string serviceName, required array methods) { - var testsDir = helpers.getTestPath() & "/specs/services"; - - if (!directoryExists(testsDir)) { - directoryCreate(testsDir, true); - } - - var testPath = testsDir & "/" & serviceName & "Spec.cfc"; - - if (!fileExists(testPath)) { - var testContent = generateServiceTest(serviceName, methods); - fileWrite(testPath, testContent); - detailOutput.output("Created test: /tests/specs/services/#serviceName#Spec.cfc"); - } - } - - /** - * Generate service test content - */ - private string function generateServiceTest(required string serviceName, required array methods) { - var content = "component extends=""wheels.Test"" {" & chr(10) & chr(10); - - content &= chr(9) & "function setup() {" & chr(10); - content &= chr(9) & chr(9) & "// Create service instance" & chr(10); - content &= chr(9) & chr(9) & "service = createObject(""component"", ""services.#serviceName#"").init();" & chr(10); - content &= chr(9) & "}" & chr(10) & chr(10); - - content &= chr(9) & "function test_service_initialization() {" & chr(10); - content &= chr(9) & chr(9) & "assert(isObject(service), ""Service should be initialized"");" & chr(10); - content &= chr(9) & chr(9) & "assert(isInstanceOf(service, ""services.#serviceName#""), ""Service should be correct type"");" & chr(10); - content &= chr(9) & "}" & chr(10) & chr(10); - - var methodsToTest = arrayLen(methods) ? methods : ["process"]; - - for (var method in methodsToTest) { - content &= chr(9) & "function test_#trim(method)#() {" & chr(10); - content &= chr(9) & chr(9) & "// Arrange" & chr(10); - content &= chr(9) & chr(9) & "local.testData = {" & chr(10); - content &= chr(9) & chr(9) & chr(9) & "// Add test data here" & chr(10); - content &= chr(9) & chr(9) & "};" & chr(10); - content &= chr(10); - content &= chr(9) & chr(9) & "// Act" & chr(10); - content &= chr(9) & chr(9) & "local.result = service.#trim(method)#(local.testData);" & chr(10); - content &= chr(10); - content &= chr(9) & chr(9) & "// Assert" & chr(10); - content &= chr(9) & chr(9) & "assert(structKeyExists(local.result, ""success""), ""Result should have success flag"");" & chr(10); - content &= chr(9) & chr(9) & "assert(local.result.success, ""Operation should succeed"");" & chr(10); - content &= chr(9) & "}" & chr(10) & chr(10); - } - - content &= "}"; - - return content; - } - - /** - * Convert method name to human readable format - */ - private string function humanize(required string text) { - var result = reReplace(text, "([A-Z])", " \1", "all"); - result = trim(result); - result = uCase(left(result, 1)) & mid(result, 2, len(result)); - return result; - } -} \ No newline at end of file diff --git a/cli/src/commands/wheels/generate/test.cfc b/cli/src/commands/wheels/generate/test.cfc index c61915d236..daa7b6d674 100644 --- a/cli/src/commands/wheels/generate/test.cfc +++ b/cli/src/commands/wheels/generate/test.cfc @@ -62,12 +62,14 @@ component aliases='wheels g test' extends="../base" { // Try legacy directory testsdirectory = fileSystemUtil.resolvePath( "tests" ); if( !directoryExists( testsdirectory ) ) { - error( "Tests directory not found. Are you running this from your site root?" ); + details.error( "Tests directory not found. Are you running this from your site root?" ); + return; } } if( arguments.type == "view" && !len(arguments.name)){ - error( "If creating a view test, we need to know the name of the view as well as the target"); + details.error( "If creating a view test, we need to know the name of the view as well as the target"); + return; } // Determine test directory and name based on type diff --git a/cli/src/commands/wheels/generate/view.cfc b/cli/src/commands/wheels/generate/view.cfc index 3e0686e5e7..9f6c0ff3c0 100644 --- a/cli/src/commands/wheels/generate/view.cfc +++ b/cli/src/commands/wheels/generate/view.cfc @@ -53,7 +53,8 @@ component aliases='wheels g view' extends="../base" { // Validate directory if( !directoryExists( viewdirectory ) ) { - error( "[#viewdirectory#] can't be found. Are you running this from your site root?" ); + detailOutput.error( "[#viewdirectory#] can't be found. Are you running this from your site root?" ); + return; } // Validate views subdirectory, create if doesnt' exist diff --git a/cli/src/commands/wheels/get/environment.cfc b/cli/src/commands/wheels/get/environment.cfc index 3b4a754ccb..e6b44bcafc 100644 --- a/cli/src/commands/wheels/get/environment.cfc +++ b/cli/src/commands/wheels/get/environment.cfc @@ -7,6 +7,8 @@ */ component aliases="wheels get env" extends="../base" { + property name="detailOutput" inject="DetailOutputService@wheels-cli"; + /** * @help Show the current environment setting */ @@ -68,16 +70,22 @@ component aliases="wheels get env" extends="../base" { local.configSource = "Using default"; } - print.line(); - print.boldLine("Current Environment:"); - print.greenLine(local.environment); - print.line(); + detailOutput.header("Current Environment: #local.environment#"); + detailOutput.metric("Configured in", local.configSource); + - // Show where it's configured - print.line("Configured in: " & local.configSource); + // Add usage information + detailOutput.line(); + if (local.configSource == "Using default") { + detailOutput.output("To set an environment:"); + detailOutput.output("- wheels env set environment_name", true); + detailOutput.output("- wheels env switch environment_name", true); + detailOutput.output("- Set WHEELS_ENV in .env file",true); + } } catch (any e) { - error("Error reading environment: " & e.message); + detailOutput.error("Error reading environment: #e.message#"); + return; } } } \ No newline at end of file diff --git a/cli/src/commands/wheels/get/settings.cfc b/cli/src/commands/wheels/get/settings.cfc index 202e00705f..0378a70349 100644 --- a/cli/src/commands/wheels/get/settings.cfc +++ b/cli/src/commands/wheels/get/settings.cfc @@ -9,6 +9,8 @@ */ component extends="../base" { + property name="detailOutput" inject="DetailOutputService@wheels-cli"; + /** * @settingName Optional specific setting name or pattern to filter * @help Display all settings or a specific setting value @@ -56,37 +58,61 @@ component extends="../base" { if (StructCount(local.settings) == 0) { if (Len(arguments.settingName)) { - print.yellowLine("No settings found matching '#arguments.settingName#'"); + detailOutput.statusWarning("No settings found matching '#arguments.settingName#'"); } else { - print.yellowLine("No settings found"); + detailOutput.statusWarning("No settings found"); } return; } - print.line(); - print.boldLine("Wheels Settings (#local.environment# environment):"); - print.line(); + detailOutput.header("Wheels Settings (#local.environment# environment)"); + detailOutput.line(); // Sort settings by key local.sortedKeys = StructKeyArray(local.settings); ArraySort(local.sortedKeys, "textnocase"); - // Display settings + // Display settings in a table format + var rows = []; for (local.key in local.sortedKeys) { local.value = local.settings[local.key]; local.displayValue = formatSettingValue(local.value); - print.text(PadRight(local.key & ":", 30)); - print.greenLine(local.displayValue); + arrayAppend(rows, { + "Setting" = local.key, + "Value" = local.displayValue + }); + } + + // Display the table + detailOutput.getPrint().table(rows); + + detailOutput.line(); + detailOutput.metric("Total settings", StructCount(local.settings)); + + // Add helpful information + detailOutput.line(); + detailOutput.statusInfo("Settings loaded from:"); + if (FileExists(local.settingsFile)) { + detailOutput.output("- config/settings.cfm (global defaults)", true); + } + if (FileExists(local.envSettingsFile)) { + detailOutput.output("- config/#local.environment#/settings.cfm (environment overrides)", true); + } + if (!FileExists(local.settingsFile) && !FileExists(local.envSettingsFile)) { + detailOutput.output("- Default Wheels settings only (no config files found)", true); } - print.line(); - print.line("Total settings: " & StructCount(local.settings)); + detailOutput.line(); + if (Len(arguments.settingName) && StructCount(local.settings) > 0) { + detailOutput.statusInfo("Filtered by: '#arguments.settingName#'"); + detailOutput.output("- Showing #StructCount(local.settings)# matching setting(s)", true); + } } catch (any e) { - error("Error reading settings: " & e.message); + detailOutput.error("Error reading settings: #e.message#"); if (StructKeyExists(e, "detail") && Len(e.detail)) { - error("Details: " & e.detail); + detailOutput.output("Details: #e.detail#"); } } } @@ -236,12 +262,4 @@ component extends="../base" { return "[complex value]"; } } - - private string function PadRight(required string text, required numeric width) { - if (Len(arguments.text) >= arguments.width) { - return arguments.text; - } - return arguments.text & RepeatString(" ", arguments.width - Len(arguments.text)); - } - } \ No newline at end of file diff --git a/cli/src/commands/wheels/init.cfc b/cli/src/commands/wheels/init.cfc index ed5eb93000..6bc4208038 100644 --- a/cli/src/commands/wheels/init.cfc +++ b/cli/src/commands/wheels/init.cfc @@ -12,27 +12,32 @@ **/ component extends="base" { + property name="detailOutput" inject="DetailOutputService@wheels-cli"; + /** * **/ function run() { + + requireWheelsApp(getCWD()); + detailOutput.header("Wheels init") + .output("This function will attempt to add a few things") + .output("to an EXISTING Wheels installation to help") + .output("the CLI interact.") + .line() + .output("We're going to assume the following:") + .output("- you've already setup a local datasource/database", true) + .output("- you've already set a reload password", true) + .line() + .output("We're going to try and do the following:") + .output("- create a box.json to help keep track of the wheels version", true) + .output("- create a server.json", true) + .divider() + .line(); - print.greenBoldLine( "==================================== Wheels init ===================================" ) - .greenBoldLine( " This function will attempt to add a few things " ) - .greenBoldLine( " to an EXISTING Wheels installation to help " ) - .greenBoldLine( " the CLI interact." ) - .greenBoldLine( " " ) - .greenBoldLine( " We're going to assume the following:" ) - .greenBoldLine( " - you've already setup a local datasource/database" ) - .greenBoldLine( " - you've already set a reload password" ) - .greenBoldLine( " " ) - .greenBoldLine( " We're going to try and do the following:" ) - .greenBoldLine( " - create a box.json to help keep track of the wheels version" ) - .greenBoldLine( " - create a server.json" ) - .greenBoldLine( "====================================================================================" ) - .line().toConsole(); if(!confirm("Sound ok? [y/n] ")){ - error("Ok, aborting..."); + detailOutput.getPrint().redBoldLine("Ok, aborting...").toConsole(); + return; } var serverJsonLocation=fileSystemUtil.resolvePath("server.json"); @@ -40,36 +45,40 @@ component extends="base" { var boxJsonLocation=fileSystemUtil.resolvePath("box.json"); var wheelsVersion = $getWheelsVersion(); - print.greenline(wheelsVersion); + detailOutput.statusInfo(wheelsVersion); // Create a wheels/box.json if one doesn't exist if(!fileExists(wheelsBoxJsonLocation)){ var wheelsBoxJSON = fileRead( getTemplate('/WheelsBoxJSON.txt' ) ); wheelsBoxJSON = replaceNoCase( wheelsBoxJSON, "|version|", trim(wheelsVersion), 'all' ); - // Make box.json - print.greenline( "========= Creating wheels/box.json" ).toConsole(); + // Make box.json + detailOutput.statusInfo("Creating wheels/box.json"); file action='write' file=wheelsBoxJsonLocation mode ='777' output='#trim(wheelsBoxJSON)#'; + detailOutput.create(wheelsBoxJsonLocation); + detailOutput.statusSuccess("Created wheels/box.json"); } else { - print.greenline( "========= wheels/box.json exists, skipping" ).toConsole(); + detailOutput.statusInfo("wheels/box.json exists, skipping"); } // Create a server.json if one doesn't exist if(!fileExists(serverJsonLocation)){ var appName = ask( message = "Please enter an application name: we use this to make the server.json servername unique: ", defaultResponse = 'myapp'); appName = helpers.stripSpecialChars(appName); - var setEngine = ask( message = 'Please enter a default cfengine: ', defaultResponse = 'lucee5' ); + var setEngine = ask( message = 'Please enter a default cfengine: ', defaultResponse = 'lucee6' ); // Make server.json server name unique to this app: assumes lucee by default - print.greenline( "========= Creating default server.json" ).toConsole(); + detailOutput.statusInfo("Creating default server.json"); var serverJSON = fileRead( getTemplate('/ServerJSON.txt' ) ); - serverJSON = replaceNoCase( serverJSON, "|appName|", trim(appName), 'all' ); - serverJSON = replaceNoCase( serverJSON, "|setEngine|", setEngine, 'all' ); - file action='write' file=serverJsonLocation mode ='777' output='#trim(serverJSON)#'; + serverJSON = replaceNoCase( serverJSON, "|appName|", trim(appName), 'all' ); + serverJSON = replaceNoCase( serverJSON, "|setEngine|", setEngine, 'all' ); + file action='write' file=serverJsonLocation mode ='777' output='#trim(serverJSON)#'; + detailOutput.create(serverJsonLocation); + detailOutput.statusSuccess("Created server.json"); } else { - print.greenline( "========= server.json exists, skipping" ).toConsole(); + detailOutput.statusInfo("server.json exists, skipping"); } // Create a box.json if one doesn't exist @@ -82,14 +91,16 @@ component extends="base" { boxJSON = replaceNoCase( boxJSON, "|version|", trim(wheelsVersion), 'all' ); boxJSON = replaceNoCase( boxJSON, "|appName|", trim(appName), 'all' ); - // Make box.json - print.greenline( "========= Creating box.json" ).toConsole(); + // Make box.json + detailOutput.statusInfo("Creating box.json"); file action='write' file=boxJsonLocation mode ='777' output='#trim(boxJSON)#'; + detailOutput.create(boxJsonLocation); + detailOutput.statusSuccess("Created box.json"); } else { - print.greenline( "========= box.json exists, skipping" ).toConsole(); + detailOutput.statusInfo("box.json exists, skipping"); } } -} +} \ No newline at end of file diff --git a/cli/src/commands/wheels/plugins/info.cfc b/cli/src/commands/wheels/plugins/info.cfc index 6ff900c435..a1fe3a12c8 100644 --- a/cli/src/commands/wheels/plugins/info.cfc +++ b/cli/src/commands/wheels/plugins/info.cfc @@ -8,6 +8,7 @@ component aliases="wheels plugin info" extends="../base" { property name="pluginService" inject="PluginService@wheels-cli"; property name="packageService" inject="PackageService"; + property name="detailOutput" inject="DetailOutputService@wheels-cli"; /** * @name.hint Name of the plugin to show info for @@ -16,12 +17,8 @@ component aliases="wheels plugin info" extends="../base" { requireWheelsApp(getCWD()); arguments = reconstructArgs(argStruct=arguments); try { - print.line() - .boldMagentaLine("===========================================================") - .boldMagentaText(" Plugin Information: ") - .boldWhiteLine(arguments.name) - .boldMagentaLine("===========================================================") - .line(); + detailOutput.header("Plugin Information: #arguments.name#"); + detailOutput.line(); // Check local installation status in /plugins folder only var isInstalled = false; @@ -45,10 +42,9 @@ component aliases="wheels plugin info" extends="../base" { var forgeboxInfo = {}; if (isInstalled) { - print.boldLine("Status:") - .text(" ") - .boldGreenLine("Installed locally") - .line(); + detailOutput.subHeader("Status"); + detailOutput.statusSuccess("Installed locally"); + detailOutput.line(); // Try to get information from local plugin's box.json var localPluginInfo = getLocalPluginInfo(pluginPath); @@ -58,17 +54,14 @@ component aliases="wheels plugin info" extends="../base" { displayLocalPluginInfo(localPluginInfo, installedVersion); } else { // Fallback if we can't read local plugin info - print.boldLine("Details:") - .text(" Version: ") - .cyanLine(installedVersion) - .line(); + detailOutput.subHeader("Details"); + detailOutput.metric("Version", installedVersion); + detailOutput.line(); } } else { - print.boldLine("Status:") - .text(" ") - .yellowBoldText("[X] ") - .yellowLine("Not installed") - .line(); + detailOutput.subHeader("Status"); + detailOutput.statusWarning("Not installed"); + detailOutput.line(); // Try to get detailed ForgeBox information only if not installed try { @@ -85,31 +78,32 @@ component aliases="wheels plugin info" extends="../base" { // Display ForgeBox information section if (hasForgeboxData) { - print.line(forgeboxResult); + // Display the raw forgebox output + detailOutput.code(forgeboxResult); } else { // Plugin not found anywhere - show helpful message instead of error - print.redLine("Plugin Not Installed") - .line() - .line("The plugin '#arguments.name#' was not found in:") - .line(" Local installation (box.json dependencies)") - .line(" ForgeBox repository") - .line() - .line("Possible reasons:") - .line(" Plugin name may be misspelled") - .line(" Plugin may not exist on ForgeBox") - .line(" Network connection issues") - .line() - .line("Suggestions:") - .cyanLine(" Search for available plugins: wheels plugin list --available") - .cyanLine(" Verify the correct plugin name") - .line(); + detailOutput.statusFailed("Plugin Not Found"); + detailOutput.line(); + detailOutput.output("The plugin '#arguments.name#' was not found in:"); + detailOutput.output("- Local installation (box.json dependencies)", true); + detailOutput.output("- ForgeBox repository", true); + detailOutput.line(); + detailOutput.statusInfo("Possible reasons"); + detailOutput.output("- Plugin name may be misspelled", true); + detailOutput.output("- Plugin may not exist on ForgeBox", true); + detailOutput.output("- Network connection issues", true); + detailOutput.line(); + detailOutput.statusInfo("Suggestions"); + detailOutput.output("- Search for available plugins: wheels plugin list --available", true); + detailOutput.output("- Verify the correct plugin name", true); + detailOutput.line(); return; } } // Show installation/update commands only if plugin exists somewhere if (isInstalled || hasForgeboxData) { - print.boldLine("Commands:"); + detailOutput.subHeader("Commands"); if (isInstalled) { // Check if update available - handle version prefixes like "^0.0.4" @@ -119,26 +113,21 @@ component aliases="wheels plugin info" extends="../base" { var latestVersion = trim(forgeboxInfo.latestVersion); if (cleanInstalledVersion != latestVersion) { - print.text(" ") - .yellowBoldText("[!] ") - .yellowLine("Update Available: #cleanInstalledVersion# -> #latestVersion#") - .line(); + detailOutput.statusWarning("Update Available: #cleanInstalledVersion# -> #latestVersion#"); + detailOutput.line(); } } - print.text(" Update: ") - .cyanLine("wheels plugin update #arguments.name#"); + detailOutput.output("- Update: wheels plugin update #arguments.name#", true); } else { - print.text(" Install: ") - .cyanLine("wheels plugin install #arguments.name#"); + detailOutput.output("- Install: wheels plugin install #arguments.name#", true); } - print.text(" Search: ") - .cyanLine("wheels plugin search") - .line(); + detailOutput.output("- Search: wheels plugin search", true); + detailOutput.line(); } } catch (any e) { - error("Error getting plugin info: #e.message#"); + detailOutput.error("Error getting plugin info: #e.message#"); } } @@ -171,51 +160,54 @@ component aliases="wheels plugin info" extends="../base" { * Display local plugin information from box.json */ private function displayLocalPluginInfo(required struct pluginInfo, required string installedVersion) { - print.line(); - // Display name prominently if (structKeyExists(pluginInfo, "name") && isSimpleValue(pluginInfo.name) && len(pluginInfo.name)) { - print.boldCyanLine(pluginInfo.name); + detailOutput.subHeader(pluginInfo.name); } // Display short description if (structKeyExists(pluginInfo, "shortDescription") && isSimpleValue(pluginInfo.shortDescription) && len(pluginInfo.shortDescription)) { - print.line(pluginInfo.shortDescription).line(); - } else { - print.line(); + detailOutput.output(pluginInfo.shortDescription); } + detailOutput.line(); + // Details section - print.boldLine("Details:"); + detailOutput.subHeader("Details"); // Display version if (structKeyExists(pluginInfo, "version") && isSimpleValue(pluginInfo.version) && len(pluginInfo.version)) { - print.text(" Version: ").cyanLine(pluginInfo.version); + detailOutput.metric("Version", pluginInfo.version); } else { - print.text(" Version: ").cyanLine(installedVersion); + detailOutput.metric("Version", installedVersion); } // Display slug if (structKeyExists(pluginInfo, "slug") && isSimpleValue(pluginInfo.slug) && len(pluginInfo.slug)) { - print.text(" Slug: ").line(pluginInfo.slug); + detailOutput.metric("Slug", pluginInfo.slug); } // Display type if (structKeyExists(pluginInfo, "type") && isSimpleValue(pluginInfo.type) && len(pluginInfo.type)) { - print.text(" Type: ").line(pluginInfo.type); + detailOutput.metric("Type", pluginInfo.type); } // Display author if (structKeyExists(pluginInfo, "author") && isSimpleValue(pluginInfo.author) && len(pluginInfo.author)) { - print.text(" Author: ").line(pluginInfo.author); + detailOutput.metric("Author", pluginInfo.author); } // Display keywords - handle both string and array formats if (structKeyExists(pluginInfo, "keywords")) { + var keywordDisplay = ""; if (isSimpleValue(pluginInfo.keywords) && len(pluginInfo.keywords)) { - print.text(" Keywords: ").line(pluginInfo.keywords); + keywordDisplay = pluginInfo.keywords; } else if (isArray(pluginInfo.keywords) && arrayLen(pluginInfo.keywords) > 0) { - print.text(" Keywords: ").line(arrayToList(pluginInfo.keywords, ', ')); + keywordDisplay = arrayToList(pluginInfo.keywords, ', '); + } + + if (len(keywordDisplay)) { + detailOutput.metric("Keywords", keywordDisplay); } } @@ -237,35 +229,41 @@ component aliases="wheels plugin info" extends="../base" { } if (hasLinks) { - print.line().boldLine("Links:"); + detailOutput.line(); + detailOutput.subHeader("Links"); // Display homepage if (structKeyExists(pluginInfo, "homepage") && isSimpleValue(pluginInfo.homepage) && len(pluginInfo.homepage)) { - print.text(" Homepage: ").blueLine(pluginInfo.homepage); + detailOutput.output("- Homepage: #pluginInfo.homepage#", true); } // Display repository - handle both string and struct formats if (structKeyExists(pluginInfo, "repository")) { + var repoUrl = ""; if (isSimpleValue(pluginInfo.repository) && len(pluginInfo.repository)) { - print.text(" Repository: ").blueLine(pluginInfo.repository); + repoUrl = pluginInfo.repository; } else if (isStruct(pluginInfo.repository) && structKeyExists(pluginInfo.repository, "URL") && isSimpleValue(pluginInfo.repository.URL) && len(pluginInfo.repository.URL)) { - print.text(" Repository: ").blueLine(pluginInfo.repository.URL); + repoUrl = pluginInfo.repository.URL; + } + + if (len(repoUrl)) { + detailOutput.output("- Repository: #repoUrl#", true); } } // Display documentation URL if (structKeyExists(pluginInfo, "documentation") && isSimpleValue(pluginInfo.documentation) && len(pluginInfo.documentation)) { - print.text(" Docs: ").blueLine(pluginInfo.documentation); + detailOutput.output("- Docs: #pluginInfo.documentation#", true); } // Display bugs URL if (structKeyExists(pluginInfo, "bugs") && isSimpleValue(pluginInfo.bugs) && len(pluginInfo.bugs)) { - print.text(" Issues: ").blueLine(pluginInfo.bugs); + detailOutput.output("- Issues: #pluginInfo.bugs#", true); } } - print.line(); + detailOutput.line(); } /** diff --git a/cli/src/commands/wheels/plugins/init.cfc b/cli/src/commands/wheels/plugins/init.cfc index d6937178d4..1b9696490c 100644 --- a/cli/src/commands/wheels/plugins/init.cfc +++ b/cli/src/commands/wheels/plugins/init.cfc @@ -14,6 +14,9 @@ component aliases="wheels plugin init" extends="../base" { * @license.hint License type * @license.options MIT,Apache-2.0,GPL-3.0,BSD-3-Clause,ISC,Proprietary */ + + property name="detailOutput" inject="DetailOutputService@wheels-cli"; + function run( required string name, string author = "", @@ -51,11 +54,6 @@ component aliases="wheels plugin init" extends="../base" { } } - print.line() - .boldCyanLine("===========================================================") - .boldCyanLine(" Initializing Wheels Plugin: #pluginName#") - .boldCyanLine("===========================================================") - .line(); // Create plugin in /plugins directory var pluginsBaseDir = fileSystemUtil.resolvePath("plugins"); @@ -67,16 +65,13 @@ component aliases="wheels plugin init" extends="../base" { } if (directoryExists(pluginDir)) { - print.boldRedText("[ERROR] ") - .redLine("Plugin already exists") - .line() - .yellowLine("Plugin '#simplePluginName#' already exists in /plugins folder") - .line(); + detailOutput.error("Plugin already exists"); setExitCode(1); return; } + detailOutput.header(" Initializing Wheels Plugin: #pluginName#"); - print.line("Creating plugin in /plugins/#simplePluginName#/..."); + detailOutput.output("Creating plugin in /plugins/#simplePluginName#/..."); // Create plugin directory directoryCreate(pluginDir); @@ -243,32 +238,26 @@ node_modules/ fileWrite(pluginDir & "/tests/#simplePluginName#Test.cfc", testFile); - print.line() - .boldCyanLine("===========================================================") - .line() - .boldGreenText("[OK] ") - .greenLine("Plugin created successfully in /plugins/#simplePluginName#/") - .line(); - - print.boldLine("Files Created:") - .line(" #simplePluginName#.cfc Main plugin component") - .line(" index.cfm Documentation page") - .line(" box.json Package metadata") - .line(" README.md Project documentation") - .line(); - - print.boldLine("Next Steps:") - .cyanLine(" 1. Edit #simplePluginName#.cfc to add your plugin functions") - .cyanLine(" 2. Update index.cfm and README.md with usage examples") - .cyanLine(" 3. Test: wheels reload (then call your functions)") - .cyanLine(" 4. Publish: box login && box publish"); + detailOutput.line(); + detailOutput.statusSuccess("Plugin created successfully in /plugins/#simplePluginName#/"); + detailOutput.line(); + + detailOutput.statusInfo("Files Created:"); + detailOutput.output("- #simplePluginName#.cfc: Main plugin component",true); + detailOutput.output("- index.cfm: Documentation page",true); + detailOutput.output("- box.json: Package metadata",true); + detailOutput.output("- README.md: Project documentation",true); + detailOutput.line(); + + detailOutput.statusInfo("Next Steps:"); + detailOutput.output("1. Edit #simplePluginName#.cfc to add your plugin functions", true); + detailOutput.output("2. Update index.cfm and README.md with usage examples", true); + detailOutput.output("3. Test: wheels reload (then call your functions)", true); + detailOutput.output("4. Publish: box login && box publish", true); } catch (any e) { - print.line() - .boldRedText("[ERROR] ") - .redLine("Error initializing plugin") - .line() - .yellowLine("Error: #e.message#"); + detailOutput.statusFailed("Error initializing plugin"); + detailOutput.error("#e.message#"); setExitCode(1); } } diff --git a/cli/src/commands/wheels/plugins/install.cfc b/cli/src/commands/wheels/plugins/install.cfc index 85a4dbd3e1..b411e4569d 100644 --- a/cli/src/commands/wheels/plugins/install.cfc +++ b/cli/src/commands/wheels/plugins/install.cfc @@ -8,6 +8,7 @@ component aliases="wheels plugin install" extends="../base" { property name="pluginService" inject="PluginService@wheels-cli"; + property name="detailOutput" inject="DetailOutputService@wheels-cli"; /** * @name.hint Plugin name or repository URL @@ -22,55 +23,59 @@ component aliases="wheels plugin install" extends="../base" { requireWheelsApp(getCWD()); arguments = reconstructArgs(argStruct=arguments); - print.line() - .boldCyanLine("===========================================================") - .boldCyanLine(" Installing Plugin") - .boldCyanLine("===========================================================") - .line(); + detailOutput.header("Installing Plugin"); + detailOutput.line(); var packageSpec = arguments.name; if (len(arguments.version)) { packageSpec &= "@" & arguments.version; } - print.line("Plugin: #arguments.name#"); + detailOutput.metric("Plugin", arguments.name); if (len(arguments.version)) { - print.line("Version: #arguments.version#"); + detailOutput.metric("Version", arguments.version); } else { - print.line("Version: latest"); + detailOutput.metric("Version", "latest"); } - print.line(); + if (arguments.dev) { + detailOutput.metric("Type", "Development dependency"); + } + detailOutput.line(); var result = pluginService.install(argumentCollection = arguments); - print.boldCyanLine("===========================================================") - .line(); + detailOutput.divider("=", 60); + detailOutput.line(); if (result.success) { - print.boldGreenText("[OK] ") - .greenLine("Plugin installed successfully!") - .line(); + detailOutput.statusSuccess("Plugin installed successfully!"); + detailOutput.line(); if (result.keyExists("plugin") && result.plugin.keyExists("description")) { - print.line("#result.plugin.description#") - .line(); + detailOutput.output("#result.plugin.description#"); + detailOutput.line(); } - print.boldLine("Commands:") - .cyanLine(" wheels plugin list View all installed plugins") - .cyanLine(" wheels plugin info #arguments.name# View plugin details"); + detailOutput.subHeader("Commands"); + detailOutput.output("- wheels plugin list View all installed plugins", true); + detailOutput.output("- wheels plugin info #arguments.name# View plugin details", true); + + if (result.keyExists("plugin") && result.plugin.keyExists("homepage")) { + detailOutput.line(); + detailOutput.subHeader("Documentation"); + detailOutput.output("- #result.plugin.homepage#", true); + } } else { - print.boldRedText("[ERROR] ") - .redLine("Failed to install plugin") - .line() - .yellowLine("Error: #result.error#") - .line(); + detailOutput.statusFailed("Failed to install plugin"); + detailOutput.error("Error: #result.error#"); + detailOutput.line(); - print.line("Possible solutions:") - .line(" - Verify the plugin name is correct") - .line(" - Check if the plugin exists on ForgeBox:") - .cyanLine(" wheels plugin list --available") - .line(" - Ensure the plugin type is 'cfwheels-plugins'"); + detailOutput.statusInfo("Possible solutions"); + detailOutput.output("- Verify the plugin name is correct", true); + detailOutput.output("- Check if the plugin exists on ForgeBox:", true); + detailOutput.output(" wheels plugin list --available", true); + detailOutput.output("- Ensure the plugin type is 'cfwheels-plugins'", true); + detailOutput.output("- Try clearing package cache: box clean", true); setExitCode(1); } diff --git a/cli/src/commands/wheels/plugins/list.cfc b/cli/src/commands/wheels/plugins/list.cfc index acd991f4fa..953bd92643 100644 --- a/cli/src/commands/wheels/plugins/list.cfc +++ b/cli/src/commands/wheels/plugins/list.cfc @@ -8,6 +8,7 @@ component aliases="wheels plugin list" extends="../base" { property name="pluginService" inject="PluginService@wheels-cli"; + property name="detailOutput" inject="DetailOutputService@wheels-cli"; /** * @format.hint Output format: table (default) or json @@ -28,11 +29,8 @@ component aliases="wheels plugin list" extends="../base" { if (arguments.available) { // Show available plugins from ForgeBox - print.line() - .boldCyanLine("===========================================================") - .boldCyanLine(" Available Wheels Plugins on ForgeBox") - .boldCyanLine("===========================================================") - .line(); + detailOutput.header("Available Wheels Plugins on ForgeBox"); + detailOutput.line(); command('forgebox show').params(type="cfwheels-plugins").run(); return; } @@ -41,18 +39,16 @@ component aliases="wheels plugin list" extends="../base" { var plugins = pluginService.list(); if (arrayLen(plugins) == 0) { - print.line() - .boldCyanLine("===========================================================") - .boldCyanLine(" Installed Wheels Plugins") - .boldCyanLine("===========================================================") - .line(); - print.yellowLine("No plugins installed in /plugins folder") - .line(); - print.line("Install plugins with:") - .cyanLine(" wheels plugin install ") - .line(); - print.line("See available plugins:") - .cyanLine(" wheels plugin list --available"); + detailOutput.header("Installed Wheels Plugins"); + detailOutput.line(); + detailOutput.statusWarning("No plugins installed in /plugins folder"); + detailOutput.line(); + detailOutput.subHeader("Install plugins with"); + detailOutput.output("- wheels plugin install ", true); + detailOutput.line(); + detailOutput.subHeader("See available plugins"); + detailOutput.output("- wheels plugin list --available", true); + detailOutput.output("- wheels plugin search ", true); return; } @@ -65,70 +61,70 @@ component aliases="wheels plugin list" extends="../base" { print.line(serializeJSON(jsonOutput, true)); } else { // Table format output - print.line() - .boldCyanLine("===========================================================") - .boldCyanLine(" Installed Wheels Plugins (#arrayLen(plugins)#)") - .boldCyanLine("===========================================================") - .line(); - - // Calculate column widths dynamically - var maxNameLength = 20; // minimum width - var maxVersionLength = 10; // minimum width + detailOutput.header("Installed Wheels Plugins (#arrayLen(plugins)#)"); + detailOutput.line(); + // Create table rows + var rows = []; for (var plugin in plugins) { - if (len(plugin.name) > maxNameLength) { - maxNameLength = len(plugin.name); + var row = { + "Plugin Name": plugin.name, + "Version": plugin.version + }; + + if (plugin.keyExists("description") && len(plugin.description)) { + row["Description"] = left(plugin.description, 50); + } else { + row["Description"] = ""; } - if (len(plugin.version) > maxVersionLength) { - maxVersionLength = len(plugin.version); + + // Add author if available + if (plugin.keyExists("author") && len(plugin.author)) { + row["Author"] = left(plugin.author, 20); } + + arrayAppend(rows, row); } - // Add padding - maxNameLength += 2; - maxVersionLength += 2; - - // Print table header - print.boldText(padRight("Plugin Name", maxNameLength)) - .boldText(padRight("Version", maxVersionLength)) - .boldLine("Description"); + // Display the table + detailOutput.getPrint().table(rows); + + detailOutput.line(); + detailOutput.divider("-", 60); + detailOutput.line(); - print.line(repeatString("-", maxNameLength + maxVersionLength + 40)); - - // Display plugins in table format + // Show summary + detailOutput.metric("Total plugins", "#arrayLen(plugins)#"); + var devPlugins = 0; for (var plugin in plugins) { - var name = padRight(plugin.name, maxNameLength); - var version = padRight(plugin.version, maxVersionLength); - var description = plugin.keyExists("description") && len(plugin.description) ? - left(plugin.description, 40) : ""; - - print.cyanText(name) - .greenText(version) - .line(description); + if (plugin.keyExists("type") && findNoCase("dev", plugin.type)) { + devPlugins++; + } } - - print.line() - .boldLine("-----------------------------------------------------------") - .line(); - - print.boldGreenText("[OK] ") - .line("#arrayLen(plugins)# plugin#arrayLen(plugins) != 1 ? 's' : ''# installed") - .line(); - - print.line("Commands:") - .cyanLine(" wheels plugin info View plugin details") - .cyanLine(" wheels plugin update:all Update all plugins") - .cyanLine(" wheels plugin outdated Check for updates"); - } - } - - /** - * Pad string to right with spaces - */ - private function padRight(required string text, required numeric length) { - if (len(arguments.text) >= arguments.length) { - return left(arguments.text, arguments.length); + if (devPlugins > 0) { + detailOutput.metric("Development plugins", "#devPlugins#"); + } + + // Show most recent plugin if available + if (arrayLen(plugins) > 0) { + var recentPlugin = plugins[1]; // Assuming first is most recent + detailOutput.metric("Latest plugin", "#recentPlugin.name# (#recentPlugin.version#)"); + } + + detailOutput.line(); + + // Show commands + detailOutput.subHeader("Commands"); + detailOutput.output("- wheels plugin info View plugin details", true); + detailOutput.output("- wheels plugin update:all Update all plugins", true); + detailOutput.output("- wheels plugin outdated Check for updates", true); + detailOutput.output("- wheels plugin install Install new plugin", true); + detailOutput.output("- wheels plugin remove Remove a plugin", true); + detailOutput.line(); + + // Add tip + detailOutput.statusInfo("Tip"); + detailOutput.output("Add --format=json for JSON output", true); } - return arguments.text & repeatString(" ", arguments.length - len(arguments.text)); } -} +} \ No newline at end of file diff --git a/cli/src/commands/wheels/plugins/outdated.cfc b/cli/src/commands/wheels/plugins/outdated.cfc index 89895cf8db..d91e7d92f6 100644 --- a/cli/src/commands/wheels/plugins/outdated.cfc +++ b/cli/src/commands/wheels/plugins/outdated.cfc @@ -8,6 +8,7 @@ component aliases="wheels plugin outdated,wheels plugins outdated" extends="../b property name="pluginService" inject="PluginService@wheels-cli"; property name="forgebox" inject="ForgeBox"; + property name="detailOutput" inject="DetailOutputService@wheels-cli"; /** * @format.hint Output format: table (default) or json @@ -24,34 +25,31 @@ component aliases="wheels plugin outdated,wheels plugins outdated" extends="../b } ); try { - print.line() - .boldCyanLine("===========================================================") - .boldCyanLine(" Checking for Plugin Updates") - .boldCyanLine("===========================================================") - .line(); + detailOutput.header("Checking for Plugin Updates"); + detailOutput.line(); // Get list of installed plugins from /plugins folder var plugins = pluginService.list(); if (arrayLen(plugins) == 0) { - print.yellowLine("No plugins installed in /plugins folder") - .line() - .line("Install plugins with:") - .cyanLine(" wheels plugin install "); + detailOutput.statusWarning("No plugins installed in /plugins folder"); + detailOutput.line(); + detailOutput.subHeader("Install plugins with"); + detailOutput.output("- wheels plugin install ", true); return; } var outdatedPlugins = []; var checkErrors = []; + detailOutput.output("Checking #arrayLen(plugins)# installed plugin(s)..."); + detailOutput.line(); + // Check each plugin for (var plugin in plugins) { try { var pluginSlug = plugin.slug ?: plugin.name; var displayName = plugin.name; - var padding = repeatString(" ", max(40 - len(displayName), 1)); - - print.text(" " & displayName & padding); // Get latest version using forgebox show command for fresh data var forgeboxResult = command('forgebox show') @@ -72,44 +70,38 @@ component aliases="wheels plugin outdated,wheels plugins outdated" extends="../b // Compare versions if (cleanCurrent != cleanLatest && latestVersion != "unknown") { - print.yellowBoldText("[OUTDATED] ") - .yellowLine("#currentVersion# -> #latestVersion#"); - arrayAppend(outdatedPlugins, { name: plugin.name, slug: pluginSlug, currentVersion: currentVersion, latestVersion: latestVersion }); + detailOutput.update(displayName, true); } else { - print.greenBoldText("[OK] ") - .greenLine("v#currentVersion#"); + detailOutput.identical("#displayName#:v#currentVersion# (up to date)", true); } } catch (any e) { - print.redBoldText("[ERROR] ") - .redLine("Could not check version"); + detailOutput.conflict(displayName, true); arrayAppend(checkErrors, plugin.name); } } - print.line() - .boldCyanLine("===========================================================") - .line(); + detailOutput.line(); + detailOutput.divider("=", 60); + detailOutput.line(); // Handle no outdated plugins if (arrayLen(outdatedPlugins) == 0) { - print.boldGreenText("[OK] ") - .greenLine("All plugins are up to date!") - .line(); + detailOutput.statusSuccess("All plugins are up to date!"); + detailOutput.line(); if (arrayLen(checkErrors) > 0) { - print.yellowLine("Could not check #arrayLen(checkErrors)# plugin#arrayLen(checkErrors) != 1 ? 's' : ''#:") - .line(); + detailOutput.statusWarning("Could not check #arrayLen(checkErrors)# plugin(s)"); for (var errorPlugin in checkErrors) { - print.yellowLine(" - #errorPlugin#"); + detailOutput.output("- #errorPlugin#: ", true); } - print.line(); + detailOutput.line(); } return; @@ -124,84 +116,64 @@ component aliases="wheels plugin outdated,wheels plugins outdated" extends="../b }; print.line(serializeJSON(jsonOutput, true)); } else { - print.boldYellowLine("Found #arrayLen(outdatedPlugins)# outdated plugin#arrayLen(outdatedPlugins) != 1 ? 's' : ''#:") - .line(); - - // Calculate column widths - var maxNameLength = 20; - var maxCurrentLength = 10; - var maxLatestLength = 10; + detailOutput.subHeader("Found #arrayLen(outdatedPlugins)# outdated plugin(s)"); + detailOutput.line(); + // Create table for outdated plugins + var rows = []; for (var plugin in outdatedPlugins) { - if (len(plugin.name) > maxNameLength) { - maxNameLength = len(plugin.name); - } - if (len(plugin.currentVersion) > maxCurrentLength) { - maxCurrentLength = len(plugin.currentVersion); - } - if (len(plugin.latestVersion) > maxLatestLength) { - maxLatestLength = len(plugin.latestVersion); - } + arrayAppend(rows, { + "Plugin": plugin.name, + "Current": plugin.currentVersion, + "Latest": plugin.latestVersion + }); } - maxNameLength += 2; - maxCurrentLength += 2; - maxLatestLength += 2; - - // Print table header - print.boldText(padRight("Plugin", maxNameLength)) - .boldText(padRight("Current", maxCurrentLength)) - .boldLine(padRight("Latest", maxLatestLength)); - - print.line(repeatString("-", maxNameLength + maxCurrentLength + maxLatestLength)); - - // Display outdated plugins - for (var plugin in outdatedPlugins) { - print.cyanText(padRight(plugin.name, maxNameLength)) - .yellowText(padRight(plugin.currentVersion, maxCurrentLength)) - .greenLine(padRight(plugin.latestVersion, maxLatestLength)); - } - - print.line() - .boldLine("-----------------------------------------------------------") - .line(); + // Display the table + print.table(rows); + + detailOutput.line(); + detailOutput.divider("-", 60); + detailOutput.line(); if (arrayLen(checkErrors) > 0) { - print.yellowLine("Could not check #arrayLen(checkErrors)# plugin#arrayLen(checkErrors) != 1 ? 's' : ''#:") - .line(); + detailOutput.statusWarning("Could not check #arrayLen(checkErrors)# plugin(s)"); for (var errorPlugin in checkErrors) { - print.yellowLine(" - #errorPlugin#"); + detailOutput.output("- #errorPlugin#", true); } - print.line(); + detailOutput.line(); } + // Show summary + detailOutput.metric("Total plugins checked", "#arrayLen(plugins)#"); + detailOutput.metric("Outdated plugins", "#arrayLen(outdatedPlugins)#"); + detailOutput.metric("Up to date", "#arrayLen(plugins) - arrayLen(outdatedPlugins)#"); + detailOutput.line(); + // Show update commands - print.boldLine("Commands:") - .line(); + detailOutput.subHeader("Update Commands"); if (arrayLen(outdatedPlugins) == 1) { - print.cyanLine(" wheels plugin update #outdatedPlugins[1].name#"); + detailOutput.output("- Update this plugin:", true); + detailOutput.output(" wheels plugin update #outdatedPlugins[1].name#", true); } else { - print.line("Update all outdated plugins:") - .cyanLine(" wheels plugin update:all") - .line() - .line("Update specific plugin:") - .cyanLine(" wheels plugin update "); + detailOutput.output("- Update all outdated plugins:", true); + detailOutput.output(" wheels plugin update:all", true); + detailOutput.output("- Update specific plugin:", true); + detailOutput.output(" wheels plugin update ", true); } + + detailOutput.line(); + + // Add helpful tip + detailOutput.statusInfo("Tip"); + detailOutput.output("Add --format=json for JSON output", true); + detailOutput.line(); } } catch (any e) { - error("Error checking for outdated plugins: #e.message#"); - } - } - - /** - * Pad string to right with spaces - */ - private function padRight(required string text, required numeric length) { - if (len(arguments.text) >= arguments.length) { - return left(arguments.text, arguments.length); + detailOutput.error("Error checking for outdated plugins: #e.message#"); + return; } - return arguments.text & repeatString(" ", arguments.length - len(arguments.text)); } } \ No newline at end of file diff --git a/cli/src/commands/wheels/plugins/remove.cfc b/cli/src/commands/wheels/plugins/remove.cfc index 28862f2869..7f62a959b2 100644 --- a/cli/src/commands/wheels/plugins/remove.cfc +++ b/cli/src/commands/wheels/plugins/remove.cfc @@ -4,9 +4,10 @@ * wheels plugins remove wheels-vue-cli * wheels plugins remove wheels-docker */ -component alias="wheels plugin remove" extends="../base" { +component aliases="wheels plugin remove" extends="../base" { property name="pluginService" inject="PluginService@wheels-cli"; + property name="detailOutput" inject="DetailOutputService@wheels-cli"; /** * @name.hint Plugin name to remove @@ -23,21 +24,21 @@ component alias="wheels plugin remove" extends="../base" { if (!arguments.force) { var confirm = ask("Are you sure you want to remove the plugin '#arguments.name#'? (y/n): "); if (!reFindNoCase("^y(es)?$", trim(confirm))) { - print.yellowLine("Plugin removal cancelled."); + detailOutput.statusInfo("Plugin removal cancelled."); return; } } - print.yellowLine("[*] Removing plugin: #arguments.name#...") - .line(); + detailOutput.output("Removing plugin: #arguments.name#..."); var result = pluginService.remove(name = arguments.name); if (result.success) { - print.greenLine("[OK] Plugin removed successfully"); - print.line("Run 'wheels plugins list' to see remaining plugins"); + detailOutput.statusSuccess("Plugin removed successfully"); + detailOutput.line(); + detailOutput.statusInfo("Run 'wheels plugins list' to see remaining plugins"); } else { - print.redLine("[ERROR] Failed to remove plugin: #result.error#"); + detailOutput.statusFailed("Failed to remove plugin: #result.error#"); setExitCode(1); } } diff --git a/cli/src/commands/wheels/plugins/search.cfc b/cli/src/commands/wheels/plugins/search.cfc index 7a5c599e70..a2f6066b9d 100644 --- a/cli/src/commands/wheels/plugins/search.cfc +++ b/cli/src/commands/wheels/plugins/search.cfc @@ -8,6 +8,7 @@ component aliases="wheels plugin search" extends="../base" { property name="forgebox" inject="ForgeBox"; + property name="detailOutput" inject="DetailOutputService@wheels-cli"; /** * @query.hint Search term to filter plugins @@ -30,22 +31,17 @@ component aliases="wheels plugin search" extends="../base" { } ); - print.line() - .boldCyanLine("===========================================================") - .boldCyanLine(" Searching ForgeBox for Wheels Plugins") - .boldCyanLine("===========================================================") - .line() - .console(); + detailOutput.header("Searching ForgeBox for Wheels Plugins"); + detailOutput.line(); if (len(arguments.query)) { - print.line("Search term: #arguments.query#") - .line().console(); + detailOutput.metric("Search term", arguments.query); + detailOutput.line(); } try { - print.line("Searching, Please wait ...") - .line() - .toConsole(); + detailOutput.output("Searching, please wait..."); + detailOutput.line(); // Use forgebox show command to get all cfwheels-plugins var forgeboxResult = command('forgebox show') @@ -114,15 +110,14 @@ component aliases="wheels plugin search" extends="../base" { } } - print.line() - .toConsole(); + detailOutput.line(); if (!arrayLen(results)) { - print.yellowLine("No plugins found" & (len(arguments.query) ? " matching '#arguments.query#'" : "")) - .line() - .line("Try:") - .cyanLine(" wheels plugin search ") - .cyanLine(" wheels plugin list --available").console(); + detailOutput.statusWarning("No plugins found" & (len(arguments.query) ? " matching '#arguments.query#'" : "")); + detailOutput.line(); + detailOutput.subHeader("Try"); + detailOutput.output("- wheels plugin search ", true); + detailOutput.output("- wheels plugin list --available", true); return; } @@ -149,85 +144,63 @@ component aliases="wheels plugin search" extends="../base" { }; print.line(serializeJSON(jsonOutput, true)); } else { - print.boldLine("Found #arrayLen(results)# plugin#arrayLen(results) != 1 ? 's' : ''#:") - .line(); + detailOutput.subHeader("Found #arrayLen(results)# plugin(s)"); + detailOutput.line(); - // Calculate column widths - var maxNameLength = 20; - var maxSlugLength = 20; - var maxVersionLength = 10; - var maxDownloadsLength = 10; - var maxDescLength = 30; + // Create table for results + var rows = []; for (var plugin in results) { - if (len(plugin.name) > maxNameLength) { - maxNameLength = len(plugin.name); + // use ordered struct so JSON keeps key order + var row = structNew("ordered"); + + row["Name"] = plugin.name; + row["Slug"] = plugin.slug; + row["Version"] = plugin.version; + row["Downloads"] = numberFormat(plugin.downloads ?: 0); + row["Description"] = plugin.description ?: "No description"; + + // Truncate long descriptions + if (len(row["Description"]) > 50) { + row["Description"] = left(row["Description"], 47) & "..."; } - if (len(plugin.slug) > maxSlugLength) { - maxSlugLength = len(plugin.slug); - } - if (len(plugin.version) > maxVersionLength) { - maxVersionLength = len(plugin.version); - } - var dlStr = numberFormat(plugin.downloads ?: 0); - if (len(dlStr) > maxDownloadsLength) { - maxDownloadsLength = len(dlStr); - } - } - maxNameLength += 2; - maxSlugLength += 2; - maxVersionLength += 2; - maxDownloadsLength += 2; - - // Print table header - print.boldText(padRight("Name", maxNameLength)) - .boldText(padRight("Slug", maxSlugLength)) - .boldText(padRight("Version", maxVersionLength)) - .boldText(padRight("Downloads", maxDownloadsLength)) - .boldLine("Description"); + arrayAppend(rows, row); + } - print.line(repeatString("-", maxNameLength + maxSlugLength + maxVersionLength + maxDownloadsLength + maxDescLength)); - // Display results - for (var plugin in results) { - var desc = plugin.description ?: "No description"; - if (len(desc) > maxDescLength) { - desc = left(desc, maxDescLength - 3) & "..."; - } + // Display the table + detailOutput.getPrint().table(rows); + + detailOutput.line(); + detailOutput.divider(); + detailOutput.line(); - print.boldText(padRight(plugin.name, maxNameLength)) - .cyanText(padRight(plugin.slug, maxSlugLength)) - .greenText(padRight(plugin.version, maxVersionLength)) - .yellowText(padRight(numberFormat(plugin.downloads ?: 0), maxDownloadsLength)) - .line(desc); + // Show summary + detailOutput.metric("Total plugins found", "#arrayLen(results)#"); + detailOutput.metric("Sort order", arguments.orderBy); + if (arguments.orderBy == "downloads" && arrayLen(results) > 0) { + detailOutput.metric("Most popular", "#results[1].name# (#numberFormat(results[1].downloads)# downloads)"); } - - print.line() - .boldLine("-----------------------------------------------------------") - .line() - .boldLine("Commands:") - .cyanLine(" wheels plugin install Install a plugin") - .cyanLine(" wheels plugin info View plugin details"); + detailOutput.line(); + + // Show commands + detailOutput.subHeader("Commands"); + detailOutput.output("- Install: wheels plugin install ", true); + detailOutput.output("- Details: wheels plugin info ", true); + detailOutput.output("- List installed: wheels plugin list", true); + detailOutput.line(); + + // Add tip about JSON format + detailOutput.statusInfo("Tip"); + detailOutput.output("Add --format=json for JSON output", true); + detailOutput.output("Sort with --orderBy=name,downloads,updated", true); + detailOutput.line(); } } catch (any e) { - print.line() - .boldRedText("[ERROR] ") - .redLine("Error searching for plugins") - .line() - .yellowLine("Error: #e.message#"); + detailOutput.error("Error searching for plugins: #e.message#"); setExitCode(1); } } - - /** - * Pad string to right with spaces - */ - private function padRight(required string text, required numeric length) { - if (len(arguments.text) >= arguments.length) { - return left(arguments.text, arguments.length); - } - return arguments.text & repeatString(" ", arguments.length - len(arguments.text)); - } } \ No newline at end of file diff --git a/cli/src/commands/wheels/plugins/update.cfc b/cli/src/commands/wheels/plugins/update.cfc index f8b64e7fd3..2a12eac6eb 100644 --- a/cli/src/commands/wheels/plugins/update.cfc +++ b/cli/src/commands/wheels/plugins/update.cfc @@ -10,6 +10,7 @@ component aliases="wheels plugin update" extends="../base" { property name="packageService" inject="PackageService"; property name="forgebox" inject="ForgeBox"; property name="fileSystemUtil" inject="FileSystem"; + property name="detailOutput" inject="DetailOutputService@wheels-cli"; /** * @name.hint Name or slug of the plugin to update @@ -24,20 +25,13 @@ component aliases="wheels plugin update" extends="../base" { requireWheelsApp(getCWD()); arguments = reconstructArgs(argStruct=arguments); try { - print.line() - .boldCyanLine("===========================================================") - .boldCyanLine(" Updating Plugin: #arguments.name#") - .boldCyanLine("===========================================================") - .line(); + detailOutput.header("Updating Plugin: #arguments.name#"); // Find plugin in /plugins folder var pluginsDir = fileSystemUtil.resolvePath("plugins"); if (!directoryExists(pluginsDir)) { - print.boldRedText("[ERROR] ") - .redLine("Plugins directory not found") - .line() - .yellowLine("Plugin '#arguments.name#' is not installed") - .line(); + detailOutput.error("Plugins directory not found"); + detailOutput.statusWarning("Plugin '#arguments.name#' is not installed"); setExitCode(1); return; } @@ -46,13 +40,11 @@ component aliases="wheels plugin update" extends="../base" { var foundPlugin = pluginService.findPluginByName(pluginsDir, arguments.name); if (!foundPlugin.found) { - print.boldRedText("[ERROR] ") - .redLine("Plugin not found") - .line() - .yellowLine("Plugin '#arguments.name#' is not installed") - .line() - .line("Install this plugin:") - .cyanLine(" wheels plugin install #arguments.name#"); + detailOutput.error("Plugin not found"); + detailOutput.statusWarning("Plugin '#arguments.name#' is not installed"); + detailOutput.line(); + detailOutput.subHeader("Install this plugin"); + detailOutput.output("- wheels plugin install #arguments.name#", true); setExitCode(1); return; } @@ -62,8 +54,9 @@ component aliases="wheels plugin update" extends="../base" { var pluginSlug = pluginInfo.slug; var currentVersion = pluginInfo.version; - print.line("Plugin: #pluginInfo.name#"); - print.line("Current version: #currentVersion#"); + detailOutput.subHeader("Plugin Information"); + detailOutput.metric("Plugin", pluginInfo.name); + detailOutput.metric("Current version", currentVersion); // Determine target version var targetVersion = len(arguments.version) ? arguments.version : "latest"; @@ -82,21 +75,18 @@ component aliases="wheels plugin update" extends="../base" { latestVersion = mid(forgeboxResult, versionMatch.pos[2], versionMatch.len[2]); } if (len(latestVersion)) { - print.line("Latest version: #latestVersion#"); + detailOutput.metric("Latest version", latestVersion); if (!arguments.force) { // Check if already at target version if (len(arguments.version)) { // User specified a version if (currentVersion == arguments.version) { - print.line() - .boldCyanLine("===========================================================") - .line() - .boldGreenText("[OK] ") - .greenLine("Plugin is already at version #arguments.version#") - .line() - .line("Use --force to reinstall anyway:"); - print.cyanLine(" wheels plugin update #arguments.name# --force"); + detailOutput.line(); + detailOutput.statusSuccess("Plugin is already at version #arguments.version#"); + detailOutput.line(); + detailOutput.subHeader("Use --force to reinstall anyway"); + detailOutput.output("- wheels plugin update #arguments.name# --force", true); return; } targetVersion = arguments.version; @@ -106,14 +96,11 @@ component aliases="wheels plugin update" extends="../base" { var cleanLatest = trim(reReplace(latestVersion, "[^0-9\.]", "", "ALL")); if (cleanCurrent == cleanLatest) { - print.line() - .boldCyanLine("===========================================================") - .line() - .boldGreenText("[OK] ") - .greenLine("Plugin is already at the latest version (#latestVersion#)") - .line() - .line("Use --force to reinstall anyway:"); - print.cyanLine(" wheels plugin update #arguments.name# --force"); + detailOutput.line(); + detailOutput.statusSuccess("Plugin is already at the latest version (#latestVersion#)"); + detailOutput.line(); + detailOutput.statusInfo("Use --force to reinstall anyway"); + detailOutput.output("- wheels plugin update #arguments.name# --force", true); return; } targetVersion = latestVersion; @@ -125,31 +112,29 @@ component aliases="wheels plugin update" extends="../base" { } else { // Couldn't get version from ForgeBox if (!arguments.force && !len(arguments.version)) { - print.line() - .yellowLine("Unable to check latest version from ForgeBox") - .line() - .line("Options:") - .line(" - Specify a version:") - .cyanLine(" wheels plugin update #arguments.name# --version=x.x.x") - .line(" - Force reinstall:") - .cyanLine(" wheels plugin update #arguments.name# --force"); + detailOutput.statusWarning("Unable to check latest version from ForgeBox"); + detailOutput.line(); + detailOutput.subHeader("Options"); + detailOutput.output("- Specify a version:", true); + detailOutput.output(" wheels plugin update #arguments.name# --version=x.x.x", true); + detailOutput.output("- Force reinstall:", true); + detailOutput.output(" wheels plugin update #arguments.name# --force", true); return; } } } catch (any e) { // Error querying ForgeBox - print.line() - .yellowLine("Error checking ForgeBox: #e.message#") - .line(); + detailOutput.statusWarning("Error checking ForgeBox: #e.message#"); + detailOutput.line(); if (!arguments.force && !len(arguments.version)) { - print.yellowLine("Unable to verify if update is needed") - .line() - .line("Options:") - .line(" - Specify a version:") - .cyanLine(" wheels plugin update #arguments.name# --version=x.x.x") - .line(" - Force reinstall:") - .cyanLine(" wheels plugin update #arguments.name# --force"); + detailOutput.statusWarning("Unable to verify if update is needed"); + detailOutput.line(); + detailOutput.subHeader("Options"); + detailOutput.output("- Specify a version:", true); + detailOutput.output(" wheels plugin update #arguments.name# --version=x.x.x", true); + detailOutput.output("- Force reinstall:", true); + detailOutput.output(" wheels plugin update #arguments.name# --force", true); return; } @@ -159,17 +144,18 @@ component aliases="wheels plugin update" extends="../base" { } } - print.line() - .line("Target version: #targetVersion#") - .boldCyanLine("===========================================================") - .line(); + detailOutput.line(); + detailOutput.metric("Target version", targetVersion); + detailOutput.divider(); + detailOutput.line(); - // Remove old version - print.line("Removing old version..."); + // Update process + detailOutput.output("Removing old version..."); directoryDelete(foundPlugin.path, true); + detailOutput.remove(foundPlugin.folderName); // Install new version - print.line("Installing new version..."); + detailOutput.output("Installing new version..."); var packageSpec = pluginSlug; if (targetVersion != "latest") { @@ -203,35 +189,28 @@ component aliases="wheels plugin update" extends="../base" { } } - print.line() - .boldCyanLine("===========================================================") - .line() - .boldGreenText("[OK] ") - .greenLine("Plugin '#pluginInfo.name#' updated successfully!") - .line(); + detailOutput.line(); + detailOutput.statusSuccess("Plugin '#pluginInfo.name#' updated successfully!"); + detailOutput.line(); - // Show post-update info - print.boldLine("Commands:") - .cyanLine(" wheels plugin info #arguments.name# View plugin details") - .cyanLine(" wheels plugin list View all installed plugins"); + // Show version comparison + detailOutput.subHeader("Update Summary"); + detailOutput.metric("Plugin", pluginInfo.name); + detailOutput.update("Version", "v#currentVersion# → v#targetVersion#"); + detailOutput.metric("Location", "/plugins/#foundPlugin.folderName#"); + detailOutput.line(); + + // Show post-update commands + detailOutput.subHeader("Commands"); + detailOutput.output("- wheels plugin info #arguments.name# View plugin details", true); + detailOutput.output("- wheels plugin list View all installed plugins", true); + detailOutput.output("- wheels reload Reload application", true); + detailOutput.line(); } catch (any e) { - print.line() - .boldRedText("[ERROR] ") - .redLine("Error updating plugin") - .line() - .yellowLine("Error: #e.message#"); + detailOutput.error("Error updating plugin: #e.message#"); setExitCode(1); + return; } } - - /** - * Resolve a file path - */ - private function resolvePath(path) { - if (left(arguments.path, 1) == "/" || mid(arguments.path, 2, 1) == ":") { - return arguments.path; - } - return expandPath(".") & "/" & arguments.path; - } -} +} \ No newline at end of file diff --git a/cli/src/commands/wheels/plugins/updateAll.cfc b/cli/src/commands/wheels/plugins/updateAll.cfc index 534eaa52dc..abac4c946d 100644 --- a/cli/src/commands/wheels/plugins/updateAll.cfc +++ b/cli/src/commands/wheels/plugins/updateAll.cfc @@ -10,6 +10,7 @@ component aliases="wheels plugin update:all,wheels plugins update:all" extends=" property name="packageService" inject="PackageService"; property name="forgebox" inject="ForgeBox"; property name="fileSystemUtil" inject="FileSystem"; + property name="detailOutput" inject="DetailOutputService@wheels-cli"; /** * @dryRun.hint Show what would be updated without actually updating @@ -22,23 +23,23 @@ component aliases="wheels plugin update:all,wheels plugins update:all" extends=" requireWheelsApp(getCWD()); arguments = reconstructArgs(argStruct=arguments); try { - print.line() - .boldCyanLine("===========================================================") - .boldCyanLine(" Checking for Plugin Updates") - .boldCyanLine("===========================================================") - .line(); + detailOutput.header("Checking for Plugin Updates"); + detailOutput.line(); // Get list of installed plugins from /plugins folder var plugins = pluginService.list(); if (arrayLen(plugins) == 0) { - print.yellowLine("No plugins installed in /plugins folder") - .line(); - print.line("Install plugins with:") - .cyanLine(" wheels plugin install "); + detailOutput.statusWarning("No plugins installed in /plugins folder"); + detailOutput.line(); + detailOutput.subHeader("Install plugins with"); + detailOutput.output("- wheels plugin install ", true); return; } + detailOutput.output("Checking #arrayLen(plugins)# installed plugin(s)..."); + detailOutput.line(); + var updatesAvailable = []; var upToDate = []; var errors = []; @@ -48,11 +49,6 @@ component aliases="wheels plugin update:all,wheels plugins update:all" extends=" try { var pluginSlug = plugin.slug ?: plugin.name; - // Format plugin name with padding for alignment - var displayName = plugin.name; - var padding = repeatString(" ", max(40 - len(displayName), 1)); - print.text(" " & displayName & padding); - // Get latest version using forgebox show command for fresh data var forgeboxResult = command('forgebox show') .params(pluginSlug) @@ -72,8 +68,6 @@ component aliases="wheels plugin update:all,wheels plugins update:all" extends=" // Compare versions if (cleanCurrent != cleanLatest && latestVersion != "unknown") { - print.yellowBoldText("[UPDATE] ") - .yellowLine("#currentVersion# -> #latestVersion#"); arrayAppend(updatesAvailable, { name: plugin.name, slug: pluginSlug, @@ -81,71 +75,75 @@ component aliases="wheels plugin update:all,wheels plugins update:all" extends=" currentVersion: currentVersion, latestVersion: latestVersion }); + detailOutput.update("#plugin.name# (v#currentVersion# → v#latestVersion#)"); } else { - print.greenBoldText("[OK] ") - .greenLine("v#currentVersion#"); arrayAppend(upToDate, plugin.name); + detailOutput.identical("#plugin.name# (v#currentVersion#)"); } } catch (any e) { - print.redBoldText("[ERROR] ") - .redLine("Failed to check"); arrayAppend(errors, { name: plugin.name, error: e.message }); + detailOutput.conflict("#plugin.name#"); } } - print.line() - .boldLine("-----------------------------------------------------------") - .line(); + detailOutput.line(); + detailOutput.divider("-", 60); + detailOutput.line(); // Show summary if (arrayLen(updatesAvailable) == 0) { - print.boldGreenLine("[OK] All plugins are up to date!") - .line(); - + detailOutput.statusSuccess("All plugins are up to date!"); + detailOutput.line(); + if (arrayLen(errors) > 0) { - print.yellowLine("Note: #arrayLen(errors)# plugin(s) could not be checked"); + detailOutput.statusWarning("Note: #arrayLen(errors)# plugin(s) could not be checked"); + for (var error in errors) { + detailOutput.output("- #error.name#: #error.error#", true); + } } return; } - // Show available updates table - print.boldLine("Updates Available:") - .line(); + // Show available updates + detailOutput.subHeader("Updates Available (#arrayLen(updatesAvailable)#)"); + detailOutput.line(); + // Create table for updates + var updateRows = []; for (var update in updatesAvailable) { - var namePad = repeatString(" ", max(35 - len(update.name), 1)); - var versionInfo = update.currentVersion & " -> " & update.latestVersion; - print.text(" ") - .boldText(update.name) - .text(namePad) - .yellowLine(versionInfo); + arrayAppend(updateRows, { + "Plugin": update.name, + "Current": update.currentVersion, + "Latest": update.latestVersion + }); } - - print.line(); + + print.table(updateRows); + detailOutput.line(); if (arguments.dryRun) { - print.yellowBoldLine("[DRY RUN] No updates will be performed") - .line(); - print.line("Remove --dryRun to actually update plugins"); + detailOutput.statusWarning("[DRY RUN] No updates will be performed"); + detailOutput.line(); + detailOutput.output("Remove --dryRun to actually update plugins"); return; } // Confirm updates if (!arguments.force) { - var continue = ask("Update #arrayLen(updatesAvailable)# plugin#arrayLen(updatesAvailable) != 1 ? 's' : ''#? (y/N): "); + var continue = ask("Update #arrayLen(updatesAvailable)# plugin(s)? (y/N): "); if (lCase(continue) != "y") { - print.yellowLine("Update cancelled"); + detailOutput.statusInfo("Update cancelled"); return; } } - print.line() - .boldLine("Updating Plugins...") - .line(); + detailOutput.line(); + detailOutput.subHeader("Updating Plugins..."); + detailOutput.line(); // Perform updates var successCount = 0; @@ -153,14 +151,12 @@ component aliases="wheels plugin update:all,wheels plugins update:all" extends=" for (var update in updatesAvailable) { try { - var updatePad = repeatString(" ", max(35 - len(update.name), 1)); - print.text(" " & update.name & updatePad); - // Remove old version var pluginsDir = fileSystemUtil.resolvePath("plugins"); var oldPluginPath = pluginsDir & "/" & update.folderName; if (directoryExists(oldPluginPath)) { directoryDelete(oldPluginPath, true); + detailOutput.remove("#update.name# (v#update.currentVersion#)"); } // Install new version to /plugins folder @@ -191,53 +187,59 @@ component aliases="wheels plugin update:all,wheels plugins update:all" extends=" } } - print.greenLine("[OK] Updated"); + detailOutput.update("#update.name# (v#update.latestVersion#)"); successCount++; } catch (any e) { - print.redLine("[ERROR] #e.message#"); + detailOutput.statusFailed("Failed to update #update.name#: #e.message#"); failCount++; } } // Show final summary - print.line() - .boldLine("===========================================================") - .boldLine(" Update Summary") - .boldLine("===========================================================") - .line(); + detailOutput.line(); + detailOutput.divider("=", 60); + detailOutput.header("Update Summary"); + detailOutput.line(); + + // Create summary table + var summaryRows = []; + arrayAppend(summaryRows, { "Status" = "Total plugins checked", "Count" = "#arrayLen(plugins)#" }); + arrayAppend(summaryRows, { "Status" = "Up to date", "Count" = "#arrayLen(upToDate)#" }); + arrayAppend(summaryRows, { "Status" = "Updated successfully", "Count" = "#successCount#" }); + if (failCount > 0) { + arrayAppend(summaryRows, { "Status" = "Update failed", "Count" = "#failCount#" }); + } + if (arrayLen(errors) > 0) { + arrayAppend(summaryRows, { "Status" = "Check errors", "Count" = "#arrayLen(errors)#" }); + } + + print.table(summaryRows); + detailOutput.line(); if (successCount > 0) { - print.greenBoldText("[OK] ") - .greenLine("#successCount# plugin#successCount != 1 ? 's' : ''# updated successfully"); + detailOutput.statusSuccess("#successCount# plugin(s) updated successfully!"); + detailOutput.line(); + detailOutput.statusInfo("Remember to run 'wheels reload' for changes to take effect"); } if (failCount > 0) { - print.redBoldText("[ERROR] ") - .redLine("#failCount# plugin#failCount != 1 ? 's' : ''# failed to update"); + detailOutput.statusFailed("#failCount# plugin(s) failed to update"); } if (arrayLen(errors) > 0) { - print.yellowBoldText("[!] ") - .yellowLine("#arrayLen(errors)# plugin#arrayLen(errors) != 1 ? 's' : ''# could not be checked"); + detailOutput.statusWarning("#arrayLen(errors)# plugin(s) could not be checked"); } - print.line() - .line("To see all installed plugins:") - .cyanLine(" wheels plugin list"); + detailOutput.line(); + detailOutput.subHeader("Next Steps"); + detailOutput.output("- Run 'wheels plugin list' to see all installed plugins", true); + detailOutput.output("- Run 'wheels reload' to reload application with new versions", true); + detailOutput.output("- Run 'wheels plugin outdated' to check for updates again", true); + detailOutput.line(); } catch (any e) { - error("Error updating plugins: #e.message#"); - } - } - - /** - * Resolve a file path - */ - private function resolvePath(path) { - if (left(arguments.path, 1) == "/" || mid(arguments.path, 2, 1) == ":") { - return arguments.path; + detailOutput.error("Error updating plugins: #e.message#"); } - return expandPath(".") & "/" & arguments.path; } -} +} \ No newline at end of file diff --git a/cli/src/commands/wheels/reload.cfc b/cli/src/commands/wheels/reload.cfc index cfd5bd584f..69c645297e 100644 --- a/cli/src/commands/wheels/reload.cfc +++ b/cli/src/commands/wheels/reload.cfc @@ -17,6 +17,9 @@ component aliases='wheels r' extends="base" { * @mode.options development,testing,maintenance,production * @password The reload password **/ + + property name="detailOutput" inject="DetailOutputService@wheels-cli"; + function run(string mode="development", string password="") { arguments=reconstructArgs(arguments); var serverDetails = $getServerInfo(); @@ -24,7 +27,7 @@ component aliases='wheels r' extends="base" { getURL = serverDetails.serverURL & "/index.cfm?reload=#mode#&password=#password#"; var loc = new Http( url=getURL ).send().getPrefix(); - print.line("Reload Request sent"); + detailOutput.statusSuccess("Reload Request sent"); } } diff --git a/cli/src/commands/wheels/test/coverage.cfc b/cli/src/commands/wheels/test/coverage.cfc index e5f3622413..958eae8b18 100644 --- a/cli/src/commands/wheels/test/coverage.cfc +++ b/cli/src/commands/wheels/test/coverage.cfc @@ -10,6 +10,8 @@ */ component aliases='wheels test:coverage' extends="../base" { + property name="detailOutput" inject="DetailOutputService@wheels-cli"; + /** * @type.hint Type of tests to run: (app, core, plugin) * @directory.hint Test directory to run (default: tests/specs) @@ -62,9 +64,9 @@ component aliases='wheels test:coverage' extends="../base" { if (!directoryExists(fullOutputPath)) { try { directoryCreate(fullOutputPath, true, true); - print.line("Created output directory: #fullOutputPath#"); + detailOutput.create("output directory: #fullOutputPath#"); } catch (any e) { - print.redLine("Failed to create output directory: #e.message#"); + detailOutput.error("Failed to create output directory: #e.message#"); outputPath = ""; // Use current directory } } @@ -137,12 +139,12 @@ component aliases='wheels test:coverage' extends="../base" { // Execute TestBox command command('testbox run').params(argumentCollection=params).run(); - print.line(); - print.greenLine("[SUCCESS] Tests completed successfully!"); + detailOutput.line(); + detailOutput.statusSuccess("Tests completed successfully!"); } catch (any e) { - print.redLine("[ERROR] Test execution failed: #e.message#"); - testsPassed = false; + detailOutput.statusFailed("Test execution failed: #e.message#"); + testsPassed = false; } } diff --git a/cli/src/commands/wheels/test/integration.cfc b/cli/src/commands/wheels/test/integration.cfc index 98379f04c8..1e7dd4cfe6 100644 --- a/cli/src/commands/wheels/test/integration.cfc +++ b/cli/src/commands/wheels/test/integration.cfc @@ -10,6 +10,8 @@ */ component aliases='wheels test:integration' extends="../base" { + property name="detailOutput" inject="DetailOutputService@wheels-cli"; + /** * @type.hint Type of tests to run: (app, core, plugin) * @format.hint Output format (txt, json, junit, html) @@ -48,6 +50,7 @@ component aliases='wheels test:integration' extends="../base" { if (!directoryExists(integrationTestPath)) { directoryCreate(integrationTestPath, true, true); createSampleIntegrationTest(integrationTestPath); + detailOutput.create("integration test directory: #arguments.directory#"); } // Build the test URL diff --git a/cli/src/commands/wheels/test/run.cfc b/cli/src/commands/wheels/test/run.cfc index f93760e73d..a7c40c1a41 100644 --- a/cli/src/commands/wheels/test/run.cfc +++ b/cli/src/commands/wheels/test/run.cfc @@ -9,6 +9,8 @@ */ component extends="../base" { + property name="detailOutput" inject="DetailOutputService@wheels-cli"; + /** * @type.hint Type of tests to run: (app, core) * @recurse.hint Recurse into subdirectories @@ -113,21 +115,23 @@ component extends="../base" { // Update the runner URL in params params.runner = testUrl; - // Display test type - print.greenBoldLine("================ #ucase(arguments.type)# Tests =======================").toConsole(); + // Display test header + detailOutput.header("#ucase(arguments.type)# Tests"); // Display additional info if verbose if (arguments.verbose) { - print.line("Test URL: #testUrl#").toConsole(); + detailOutput.subHeader("Test Configuration"); + detailOutput.metric("Test URL", testUrl); if (len(arguments.filter)) { - print.line("Filter: #arguments.filter#").toConsole(); + detailOutput.metric("Filter", arguments.filter); } if (len(arguments.lables)) { - print.line("lables: #arguments.lables#").toConsole(); + detailOutput.metric("Labels", arguments.lables); } if (arguments.coverage) { - print.line("Coverage: Enabled").toConsole(); + detailOutput.metric("Coverage", "Enabled"); } + detailOutput.line(); } try { @@ -141,7 +145,7 @@ component extends="../base" { // If it's just an exit code error, ignore it and continue // The actual test output should have been displayed already if (findNoCase("failing exit code", commandError.message)) { - print.yellowLine("TestBox completed (exit code indicates test results)").toConsole(); + detailOutput.statusWarning("TestBox completed (exit code indicates test results)"); } else { // Re-throw if it's a genuine error rethrow; @@ -149,10 +153,12 @@ component extends="../base" { } } catch (any e) { - print.redLine("Error executing TestBox command: #e.message#").toConsole(); + detailOutput.error("Error executing TestBox command: #e.message#"); + return; } - print.greenBoldLine("============ #ucase(arguments.type)# Tests Completed ==================").toConsole(); + detailOutput.line(); + detailOutput.statusSuccess("#ucase(arguments.type)# Tests Completed"); } } \ No newline at end of file diff --git a/cli/src/commands/wheels/test/unit.cfc b/cli/src/commands/wheels/test/unit.cfc index 600a68a486..7497e77f13 100644 --- a/cli/src/commands/wheels/test/unit.cfc +++ b/cli/src/commands/wheels/test/unit.cfc @@ -10,6 +10,8 @@ */ component aliases='wheels test:unit' extends="../base" { + property name="detailOutput" inject="DetailOutputService@wheels-cli"; + /** * @type.hint Type of tests to run: (app, core, plugin) * @format.hint Output format (txt, json, junit, html) @@ -48,6 +50,7 @@ component aliases='wheels test:unit' extends="../base" { if (!directoryExists(unitTestPath)) { directoryCreate(unitTestPath, true, true); createSampleUnitTest(unitTestPath); + detailOutput.create("unit test directory: #arguments.directory#"); } // Build the test URL using arguments diff --git a/cli/src/commands/wheels/test/watch.cfc b/cli/src/commands/wheels/test/watch.cfc index 310aa567ec..db063ab59d 100644 --- a/cli/src/commands/wheels/test/watch.cfc +++ b/cli/src/commands/wheels/test/watch.cfc @@ -10,6 +10,8 @@ */ component aliases='wheels test:watch' extends="../base" { + property name="detailOutput" inject="DetailOutputService@wheels-cli"; + /** * @type.hint Type of tests to run: (app, core, plugin) * @directory.hint Test directory to watch (default: tests/specs) @@ -48,13 +50,12 @@ component aliases='wheels test:watch' extends="../base" { ); arguments.directory = resolveTestDirectory(arguments.type, arguments.directory); - print.line("========================================"); - print.boldLine("Starting Test Watcher"); - print.line("========================================"); - print.line(); - print.yellowLine("Watching for file changes..."); - print.line("Press Ctrl+C to stop watching"); - print.line(); + detailOutput.header("Starting Test Watcher"); + detailOutput.divider("=", 40); + detailOutput.line(); + detailOutput.statusInfo("Watching for file changes..."); + detailOutput.output("Press Ctrl+C to stop watching"); + detailOutput.line(); // Build the test URL var testUrl = buildTestUrl( @@ -94,23 +95,23 @@ component aliases='wheels test:watch' extends="../base" { } // Show watching configuration - print.line("Configuration:"); - print.line(" Type: #arguments.type# tests"); - print.line(" Directory: #arguments.directory#"); - print.line(" Format: #arguments.format#"); - print.line(" Delay: #arguments.delay#ms"); + detailOutput.subHeader("Configuration"); + detailOutput.metric("Type", "#arguments.type# tests"); + detailOutput.metric("Directory", arguments.directory); + detailOutput.metric("Format", arguments.format); + detailOutput.metric("Delay", "#arguments.delay#ms"); if (len(arguments.filter)) { - print.line(" Filter: #arguments.filter#"); + detailOutput.metric("Filter", arguments.filter); } if (len(arguments.labels)) { - print.line(" Labels: #arguments.labels#"); + detailOutput.metric("Labels", arguments.labels); } - print.line(); - print.line("Executing: testbox watch"); - print.line(); + detailOutput.line(); + detailOutput.output("Executing: testbox watch"); + detailOutput.line(); try { // Execute TestBox watch command @@ -118,10 +119,10 @@ component aliases='wheels test:watch' extends="../base" { } catch (any e) { // Handle interruption gracefully if (findNoCase("interrupted", e.message) || findNoCase("ctrl", e.message)) { - print.line(); - print.yellowLine("Watch mode stopped by user"); + detailOutput.line(); + detailOutput.statusInfo("Watch mode stopped by user"); } else { - print.redLine("Error in watch mode: #e.message#"); + detailOutput.error("Error in watch mode: #e.message#"); } } } diff --git a/cli/src/models/DetailOutputService.cfc b/cli/src/models/DetailOutputService.cfc index ed5560a02c..c3a6e28648 100644 --- a/cli/src/models/DetailOutputService.cfc +++ b/cli/src/models/DetailOutputService.cfc @@ -254,14 +254,6 @@ component { return this; } - /** - * Output a separator line - */ - function separator() { - print.line().toConsole(); - return this; - } - /** * Output a code block * @code The code to display diff --git a/cli/src/models/EnvironmentService.cfc b/cli/src/models/EnvironmentService.cfc index 4d6d895582..91735c408d 100644 --- a/cli/src/models/EnvironmentService.cfc +++ b/cli/src/models/EnvironmentService.cfc @@ -2,6 +2,7 @@ component { property name="serverService" inject="ServerService"; property name="templateService" inject="TemplateService@wheels-cli"; + property name="detailOutput" inject="DetailOutputService@wheels-cli"; /** * Setup a Wheels development environment @@ -173,7 +174,7 @@ component { TYPE: determineEnvironmentType(envName), TEMPLATE: structKeyExists(config, "WHEELS_TEMPLATE") ? config.WHEELS_TEMPLATE : "local", DBTYPE: structKeyExists(config, "DB_TYPE") ? config.DB_TYPE : "unknown", - DATABASE: structKeyExists(config, "DB_NAME") ? config.DB_NAME : "unknown", + DATABASE: structKeyExists(config, "DB_DATABASE") ? config.DB_DATABASE : "unknown", DATASOURCE: structKeyExists(config, "DB_DATASOURCE") ? config.DB_DATASOURCE : "wheels_#envName#", CREATED: getFileInfo("#projectRoot#/#file#").lastModified, SOURCE: "file", @@ -225,7 +226,7 @@ component { TYPE: determineEnvironmentType(envName), TEMPLATE: "server.json", DBTYPE: structKeyExists(envConfig, "DB_TYPE") ? envConfig.DB_TYPE : "configured", - DATABASE: structKeyExists(envConfig, "DB_NAME") ? envConfig.DB_NAME : "configured", + DATABASE: structKeyExists(envConfig, "DB_DATABASE") ? envConfig.DB_DATABASE : "configured", DATASOURCE: structKeyExists(envConfig, "DB_DATASOURCE") ? envConfig.DB_DATASOURCE : "configured", CREATED: getFileInfo(serverJsonPath).lastModified, SOURCE: "server.json", @@ -259,9 +260,7 @@ component { // Apply sorting environments = sortEnvironments(environments, arguments.sort); - - // Format output based on requested format - return formatEnvironmentOutput(environments, arguments.format, arguments.verbose, currentEnv); + return environments; } /** @@ -1033,7 +1032,7 @@ component { if (len(trim(ds.port))) { serverJson.env[arguments.environment]["DB_PORT"] = ds.port; } - serverJson.env[arguments.environment]["DB_NAME"] = ds.database; + serverJson.env[arguments.environment]["DB_DATABASE"] = ds.database; serverJson.env[arguments.environment]["DB_USER"] = ds.username; serverJson.env[arguments.environment]["DB_PASSWORD"] = ds.password; } @@ -1062,7 +1061,7 @@ services: - DB_TYPE=#arguments.dbtype# - DB_HOST=db - DB_PORT=#getDatabasePort(arguments.dbtype)# - - DB_NAME=#databaseName# + - DB_DATABASE=#databaseName# - DB_USER=wheels - DB_PASSWORD=wheels_password volumes: @@ -1426,9 +1425,9 @@ sudo -u postgres psql -c ""GRANT ALL PRIVILEGES ON DATABASE #arguments.databaseN var databaseName = ""; if (len(trim(arguments.database))) { databaseName = arguments.database; - } else if (structKeyExists(arguments.envConfig, "DB_NAME")) { + } else if (structKeyExists(arguments.envConfig, "DB_DATABASE")) { // Modify the base database name for the new environment - var baseName = arguments.envConfig["DB_NAME"]; + var baseName = arguments.envConfig["DB_DATABASE"]; // Replace any existing environment references with new environment if (find("_", baseName)) { databaseName = "wheels_" & arguments.environment; @@ -1589,164 +1588,10 @@ sudo -u postgres psql -c ""GRANT ALL PRIVILEGES ON DATABASE #arguments.databaseN return sorted; } - /** - * Format environment output - */ - private function formatEnvironmentOutput(environments, format, verbose, currentEnv) { - switch(lCase(arguments.format)) { - case "json": - return formatAsJSON(arguments.environments, arguments.currentEnv); - - case "yaml": - return formatAsYAML(arguments.environments, arguments.currentEnv); - - case "table": - default: - return formatAsTable(arguments.environments, arguments.verbose, arguments.currentEnv); - } - } - /** - * Format as JSON - */ - private function formatAsJSON(environments, currentEnv) { - var output = { - environments: [], - current: arguments.currentEnv, - total: arrayLen(arguments.environments) - }; - - for (var env in arguments.environments) { - var envData = { - name: env.NAME, - type: env.TYPE, - active: env.ACTIVE, - database: env.DATABASE, - datasource: env.DATASOURCE, - template: env.TEMPLATE, - dbtype: env.DBTYPE, - lastModified: dateTimeFormat(env.CREATED, "yyyy-mm-dd'T'HH:nn:ss'Z'"), - status: env.STATUS, - source: env.SOURCE - }; - - if (structKeyExists(env, "DEBUG")) { - envData.debug = env.DEBUG; - } - if (structKeyExists(env, "CACHE")) { - envData.cache = env.CACHE; - } - if (structKeyExists(env, "CONFIGPATH")) { - envData.configPath = env.CONFIGPATH; - } - if (structKeyExists(env, "VALIDATIONERRORS") && arrayLen(env.VALIDATIONERRORS)) { - envData.errors = env.VALIDATIONERRORS; - } - - arrayAppend(output.environments, envData); - } - - return serializeJSON(output); - } - /** - * Format as YAML - */ - private function formatAsYAML(environments, currentEnv) { - var yaml = []; - arrayAppend(yaml, "environments:"); - - for (var env in arguments.environments) { - arrayAppend(yaml, " - name: #env.NAME#"); - arrayAppend(yaml, " type: #env.TYPE#"); - arrayAppend(yaml, " active: #env.ACTIVE#"); - arrayAppend(yaml, " template: #env.TEMPLATE#"); - arrayAppend(yaml, " database: #env.DATABASE#"); - arrayAppend(yaml, " dbtype: #env.DBTYPE#"); - arrayAppend(yaml, " created: #dateTimeFormat(env.CREATED, 'yyyy-mm-dd HH:nn:ss')#"); - arrayAppend(yaml, " source: #env.SOURCE#"); - arrayAppend(yaml, " status: #env.STATUS#"); - - if (structKeyExists(env, "VALIDATIONERRORS") && arrayLen(env.VALIDATIONERRORS)) { - arrayAppend(yaml, " errors:"); - for (var error in env.VALIDATIONERRORS) { - arrayAppend(yaml, " - #error#"); - } - } - } - - arrayAppend(yaml, ""); - arrayAppend(yaml, "current: #arguments.currentEnv#"); - arrayAppend(yaml, "total: #arrayLen(arguments.environments)#"); - - return arrayToList(yaml, chr(10)); - } - /** - * Format as table - */ - private function formatAsTable(environments, verbose, currentEnv) { - var output = []; - - // Title - arrayAppend(output, "Available Environments"); - arrayAppend(output, "====================="); - arrayAppend(output, ""); - - if (arrayLen(arguments.environments) == 0) { - arrayAppend(output, "No environments configured"); - arrayAppend(output, "Create an environment with: wheels env setup "); - return arrayToList(output, chr(10)); - } - - if (arguments.verbose) { - // Verbose format - for (var env in arguments.environments) { - var marker = env.ACTIVE ? " * " : ""; - var status = env.ACTIVE ? "[Active]" : ""; - - arrayAppend(output, "#env.NAME##marker##status#"); - arrayAppend(output, " Type: #env.TYPE#"); - arrayAppend(output, " Database: #env.DATABASE#"); - arrayAppend(output, " Datasource: #env.DATASOURCE#"); - if (structKeyExists(env, "DEBUG")) { - arrayAppend(output, " Debug: #env.DEBUG == 'true' ? 'Enabled' : 'Disabled'#"); - } - if (structKeyExists(env, "CACHE")) { - arrayAppend(output, " Cache: #env.CACHE == 'true' ? 'Enabled' : 'Disabled'#"); - } - if (structKeyExists(env, "CONFIGPATH")) { - arrayAppend(output, " Config: #env.CONFIGPATH#"); - } - arrayAppend(output, " Modified: #dateTimeFormat(env.CREATED, 'yyyy-mm-dd HH:nn:ss')#"); - if (structKeyExists(env, "VALIDATIONERRORS") && arrayLen(env.VALIDATIONERRORS)) { - arrayAppend(output, " Issues: #arrayToList(env.VALIDATIONERRORS, ', ')#"); - } - arrayAppend(output, ""); - } - } else { - // Table format - arrayAppend(output, " NAME TYPE DATABASE STATUS"); - for (var env in arguments.environments) { - var name = env.NAME; - var marker = env.ACTIVE ? " *" : ""; - var statusIcon = env.STATUS == "valid" ? "OK" : "WARN"; - var statusText = env.ACTIVE ? "#statusIcon# Active" : "#statusIcon# #uCase(left(env.STATUS, 1))##right(env.STATUS, len(env.STATUS) - 1)#"; - - // Pad columns for alignment - name = left(name & repeatString(" ", 14), 14); - var type = left(env.TYPE & repeatString(" ", 13), 13); - var database = left(env.DATABASE & repeatString(" ", 19), 19); - - arrayAppend(output, " #name##type##database##statusText#"); - } - } - - arrayAppend(output, ""); - arrayAppend(output, "* = Current environment"); - - return arrayToList(output, chr(10)); - } + /** * Validate environment configuration @@ -1756,7 +1601,7 @@ sudo -u postgres psql -c ""GRANT ALL PRIVILEGES ON DATABASE #arguments.databaseN var isValid = true; // Check required fields - var requiredFields = ["DB_TYPE", "DB_NAME"]; + var requiredFields = ["DB_TYPE", "DB_DATABASE"]; for (var field in requiredFields) { if (!structKeyExists(arguments.config, field) || !len(trim(arguments.config[field]))) { arrayAppend(errors, "Missing required field: #field#"); @@ -1855,7 +1700,7 @@ sudo -u postgres psql -c ""GRANT ALL PRIVILEGES ON DATABASE #arguments.databaseN * @projectRoot The root directory of the CFWheels project * @return String The current environment name, or empty string if not found */ - function getCurrentEnvironment(projectRoot) { + public function getCurrentEnvironment(projectRoot) { var currentEnv = ""; // Use current directory if projectRoot not provided @@ -1870,8 +1715,8 @@ sudo -u postgres psql -c ""GRANT ALL PRIVILEGES ON DATABASE #arguments.databaseN var matches = reMatchNoCase("WHEELS_ENV\s*=\s*([^\r\n]+)", envContent); if (arrayLen(matches)) { currentEnv = trim(matches[1]); - // Remove quotes if present - currentEnv = reReplace(currentEnv, "^[""']|[""']$", "", "all"); + // Remove quotes if present and remove key name "wheels_env=" + currentEnv = reReplace(currentEnv, "^([""']|wheels_env=)|([""'])$", "", "all"); } } diff --git a/docs/src/command-line-tools/commands/analysis/analyze-code.md b/docs/src/command-line-tools/commands/analysis/analyze-code.md index b945e7e794..40290aa1bb 100644 --- a/docs/src/command-line-tools/commands/analysis/analyze-code.md +++ b/docs/src/command-line-tools/commands/analysis/analyze-code.md @@ -132,31 +132,34 @@ wheels analyze code --path=app/models --fix --report --verbose ### Console Output (Default) ``` +Analyzing code quality... + +Scanning for files... Found 51 files to analyze +Analyzing: [==================================================] 100% Complete! +Detecting duplicate code... Found 0 duplicate blocks + + ================================================== - CODE QUALITY REPORT + CODE QUALITY REPORT ================================================== - Grade: B (85/100) - Good code quality with minor issues + Excellent code quality ================================================== + Code Metrics -------------------------------------------------- -Files Analyzed: 42 -Total Lines: 3,567 -Functions: 156 -Avg Complexity: 4 -Duplicate Blocks: 3 -Code Smells: 7 -Deprecated Calls: 2 - -Issue Summary --------------------------------------------------- -Errors: 2 (Critical issues requiring immediate attention) -Warnings: 12 (Issues that should be addressed) -Info: 28 (Suggestions for improvement) +Files Analyzed: 51 +Total Lines: 1184 +Functions: 51 +Avg Complexity: 0 +Duplicate Blocks: 0 +Code Smells: 0 +Deprecated Calls: 0 + -[Additional details for each file...] + Grade: A (100/100) +Excellent! No issues found. Your code is pristine! ``` ### JSON Output @@ -248,11 +251,6 @@ stage('Code Analysis') { - **Large projects** (500+ files): Several minutes, use `--verbose` to track progress - HTML report generation adds 5-30 seconds depending on project size -## Exit Codes - -- `0`: Success, no errors found -- `1`: Analysis completed with errors found -- `2`: Analysis failed (invalid path, configuration error) ## Tips diff --git a/docs/src/command-line-tools/commands/config/config-check.md b/docs/src/command-line-tools/commands/config/config-check.md index fcb57e9490..8f321662be 100644 --- a/docs/src/command-line-tools/commands/config/config-check.md +++ b/docs/src/command-line-tools/commands/config/config-check.md @@ -94,24 +94,34 @@ The command performs validation across multiple configuration areas: The command provides real-time status as it performs checks: ``` -======================================== -Configuration Validation -Environment: development -======================================== -Checking configuration files... [OK] -Checking required settings... [OK] -Checking security configuration... [WARNING] -Checking database configuration... [OK] -Checking environment-specific settings... [WARNING] -Checking .env file configuration... [FAILED] +================================================== +Configuration Validation - development Environment +================================================== -======================================== + +[SUCCESS]: Files Configuration +[SUCCESS]: Required Settings +[SUCCESS]: Security Configuration +[SUCCESS]: Database Configuration +[SUCCESS]: Environment-Specific Settings +[FAILED]: .env File Configuration + +-------------------------------------------------- + +[ERRORS] (1): + - .env file not in .gitignore + +-------------------------------------------------- +[FAILED] Configuration check failed + Found: 1 error + + Tip: Run with --verbose flag for detailed fix suggestions ``` ### Status Indicators -- **[OK]** - Check passed successfully +- **[SUCCESS]** - Check passed successfully - **[WARNING]** - Non-critical issues found - **[FAILED]** - Critical errors detected - **[FIXED]** - Issue was automatically fixed @@ -142,7 +152,7 @@ Checking .env file configuration... [FAILED] ### Summary Output ``` -[PASSED] Configuration validation successful! +[SUCCESS] Configuration validation successful! All checks completed successfully. ``` diff --git a/tools/docker/boxlang/Dockerfile b/tools/docker/boxlang/Dockerfile index 2d55b6e8bd..d38b6f214a 100644 --- a/tools/docker/boxlang/Dockerfile +++ b/tools/docker/boxlang/Dockerfile @@ -1,4 +1,4 @@ -FROM ortussolutions/commandbox:latest +FROM ortussolutions/commandbox:boxlang LABEL maintainer "Wheels Core Team"