diff --git a/source/src/commands/install.cpp b/source/src/commands/install.cpp index 7ce9e0a..93662fd 100644 --- a/source/src/commands/install.cpp +++ b/source/src/commands/install.cpp @@ -123,6 +123,13 @@ namespace dropshell return false; } + // Check shell script syntax before proceeding + if (!template_manager::check_template_shell_scripts_syntax(tinfo.local_template_path().string())) + { + error << "Template shell scripts have syntax errors. Run 'ds validate " << service_info.template_name << "' for details." << std::endl; + return false; + } + // Create service directory std::string mkdir_cmd = "mkdir -p " + quote(remote_service_path); if (!execute_ssh_command(server_env.get_SSH_INFO(user), sCommand("", mkdir_cmd, {}), cMode::Silent)) diff --git a/source/src/commands/validate.cpp b/source/src/commands/validate.cpp new file mode 100644 index 0000000..b908110 --- /dev/null +++ b/source/src/commands/validate.cpp @@ -0,0 +1,394 @@ +#include "command_registry.hpp" +#include "config.hpp" +#include "templates.hpp" +#include "utils/utils.hpp" +#include "utils/directories.hpp" +#include "utils/execute.hpp" +#include "shared_commands.hpp" + +#include +#include +#include +#include +#include +#include + +namespace dropshell { + +void validate_autocomplete(const CommandContext& ctx); +int validate_handler(const CommandContext& ctx); + +static std::vector validate_name_list = {"validate", "lint", "check-template"}; + +// Static registration +struct ValidateCommandRegister { + ValidateCommandRegister() { + CommandRegistry::instance().register_command({ + validate_name_list, + validate_handler, + validate_autocomplete, + false, // hidden + true, // requires_config + true, // requires_install + 1, // min_args (template name required) + 1, // max_args + "validate TEMPLATE", + "Validate a template's structure, syntax, and common issues.", + R"( +Validates a dropshell template by checking: + 1. Required files exist (install.sh, uninstall.sh, config files) + 2. Shell script syntax (bash -n) + 3. Common issues: + - Scripts source common.sh + - Error handling patterns (_die usage) + - Proper quoting of variables + +Example: + ds validate my-template + )" + }); + } +} validate_command_register; + + +void validate_autocomplete(const CommandContext& ctx) { + if (ctx.args.size() == 0) { + // list all templates + std::set templates = gTemplateManager().get_template_list(); + for (const auto& t : templates) { + rawout << t << std::endl; + } + } +} + +// ---------------------------------------------------------------------------- +// Validation helper functions +// ---------------------------------------------------------------------------- + +struct ValidationResult { + bool passed; + std::string message; + std::string details; +}; + +// Check shell script syntax using bash -n +ValidationResult check_bash_syntax(const std::filesystem::path& script_path) { + ValidationResult result; + result.passed = true; + + std::string command = "bash -n " + script_path.string() + " 2>&1"; + std::string output; + + ordered_env_vars empty_env; + bool success = execute_local_command(".", command, empty_env, &output, cMode::Silent); + + if (!success || !output.empty()) { + result.passed = false; + result.message = "Syntax error in " + script_path.filename().string(); + result.details = output; + } else { + result.message = "Syntax OK: " + script_path.filename().string(); + } + + return result; +} + +// Check if script sources common.sh +ValidationResult check_common_sh_sourced(const std::filesystem::path& script_path) { + ValidationResult result; + result.passed = false; + + std::ifstream file(script_path); + if (!file.is_open()) { + result.message = "Could not open " + script_path.filename().string(); + return result; + } + + std::string line; + std::regex source_pattern(R"(source\s+[\"']?\$\{?AGENT_PATH\}?[/\\]+_?common\.sh)"); + std::regex dot_pattern(R"(\.\s+[\"']?\$\{?AGENT_PATH\}?[/\\]+_?common\.sh)"); + + while (std::getline(file, line)) { + if (std::regex_search(line, source_pattern) || std::regex_search(line, dot_pattern)) { + result.passed = true; + result.message = "Sources common.sh: " + script_path.filename().string(); + return result; + } + } + + result.message = "Missing 'source \"${AGENT_PATH}/common.sh\"' in " + script_path.filename().string(); + result.details = "Scripts should source common.sh to use _die, _check_docker_installed, etc."; + return result; +} + +// Check for risky commands without error handling +ValidationResult check_error_handling(const std::filesystem::path& script_path) { + ValidationResult result; + result.passed = true; + + std::ifstream file(script_path); + if (!file.is_open()) { + result.message = "Could not open " + script_path.filename().string(); + result.passed = false; + return result; + } + + // Commands that should have error handling + std::vector risky_commands = { + "docker compose", + "docker run", + "mkdir ", + "cp ", + "mv ", + "rm ", + "curl ", + "wget ", + "pip install", + "pip3 install", + "apt-get", + "apt ", + "git clone", + "git pull" + }; + + std::vector issues; + std::string line; + int line_num = 0; + + while (std::getline(file, line)) { + line_num++; + + // Skip comments and empty lines + std::string trimmed = trim(line); + if (trimmed.empty() || trimmed[0] == '#') continue; + + // Skip lines that already have error handling + if (line.find("|| _die") != std::string::npos || + line.find("|| exit") != std::string::npos || + line.find("|| return") != std::string::npos || + line.find("|| true") != std::string::npos || + line.find("|| echo") != std::string::npos || + line.find("if ") == 0 || line.find("if !") != std::string::npos || + line.find("&&") != std::string::npos) { + continue; + } + + for (const auto& cmd : risky_commands) { + if (line.find(cmd) != std::string::npos) { + // Check if it's inside a conditional or has error handling + issues.push_back("Line " + std::to_string(line_num) + ": '" + cmd + "' without error handling"); + break; + } + } + } + + if (!issues.empty()) { + result.passed = false; + result.message = "Missing error handling in " + script_path.filename().string(); + for (const auto& issue : issues) { + result.details += " " + issue + "\n"; + } + result.details += "Consider adding '|| _die \"error message\"' after commands that can fail"; + } else { + result.message = "Error handling OK: " + script_path.filename().string(); + } + + return result; +} + +// Check for unquoted variables in critical places +ValidationResult check_variable_quoting(const std::filesystem::path& script_path) { + ValidationResult result; + result.passed = true; + + std::ifstream file(script_path); + if (!file.is_open()) { + result.message = "Could not open " + script_path.filename().string(); + result.passed = false; + return result; + } + + std::vector issues; + std::string line; + int line_num = 0; + + // Pattern for unquoted variables in risky contexts (paths, arguments) + std::regex unquoted_path_var(R"((cd|mkdir|rm|cp|mv|cat|ls)\s+\$[A-Za-z_][A-Za-z0-9_]*[^\"'\s])"); + std::regex unquoted_in_test(R"(\[\s+(-[a-z]\s+)?\$[A-Za-z_][A-Za-z0-9_]*\s+)"); + + while (std::getline(file, line)) { + line_num++; + + // Skip comments + std::string trimmed = trim(line); + if (trimmed.empty() || trimmed[0] == '#') continue; + + // Check for unquoted variables in file operations + if (std::regex_search(line, unquoted_path_var)) { + issues.push_back("Line " + std::to_string(line_num) + ": Potentially unquoted variable in path operation"); + } + + // Check for unquoted variables in test expressions + if (std::regex_search(line, unquoted_in_test)) { + issues.push_back("Line " + std::to_string(line_num) + ": Potentially unquoted variable in test expression"); + } + } + + if (!issues.empty()) { + result.passed = false; + result.message = "Potential quoting issues in " + script_path.filename().string(); + for (const auto& issue : issues) { + result.details += " " + issue + "\n"; + } + result.details += "Use \"$VAR\" instead of $VAR to handle paths with spaces"; + } else { + result.message = "Variable quoting OK: " + script_path.filename().string(); + } + + return result; +} + +// ---------------------------------------------------------------------------- +// Main validation handler +// ---------------------------------------------------------------------------- + +int validate_handler(const CommandContext& ctx) { + std::string template_name = safearg(ctx.args, 0); + + if (template_name.empty()) { + error << "Template name is required" << std::endl; + return 1; + } + + // Get template info + template_info tinfo = gTemplateManager().get_template_info(template_name); + if (!tinfo.is_set()) { + error << "Template not found: " << template_name << std::endl; + return 1; + } + + std::filesystem::path template_path = tinfo.local_template_path(); + + maketitle("Validating template: " + template_name); + info << "Path: " << template_path.string() << std::endl; + std::cout << std::endl; + + int errors = 0; + int warnings = 0; + + // Step 1: Run existing template validation + info << "=== Structure Validation ===" << std::endl; + if (template_manager::test_template(template_path.string())) { + info << " ✓ Template structure is valid" << std::endl; + } else { + error << " ✗ Template structure validation failed" << std::endl; + errors++; + } + std::cout << std::endl; + + // Step 2: Find all .sh files + std::vector shell_scripts; + for (const auto& entry : std::filesystem::recursive_directory_iterator(template_path)) { + if (entry.is_regular_file() && entry.path().extension() == ".sh") { + shell_scripts.push_back(entry.path()); + } + } + + // Step 3: Syntax validation + info << "=== Syntax Validation (bash -n) ===" << std::endl; + for (const auto& script : shell_scripts) { + ValidationResult result = check_bash_syntax(script); + if (result.passed) { + info << " ✓ " << result.message << std::endl; + } else { + error << " ✗ " << result.message << std::endl; + if (!result.details.empty()) { + error << " " << result.details << std::endl; + } + errors++; + } + } + std::cout << std::endl; + + // Step 4: Linting - check common.sh sourcing (only for main scripts) + info << "=== Linting: common.sh sourcing ===" << std::endl; + std::vector main_scripts = {"install.sh", "uninstall.sh", "start.sh", "stop.sh", "status.sh", "backup.sh", "restore.sh"}; + for (const auto& script_name : main_scripts) { + std::filesystem::path script_path = template_path / script_name; + if (std::filesystem::exists(script_path)) { + ValidationResult result = check_common_sh_sourced(script_path); + if (result.passed) { + info << " ✓ " << result.message << std::endl; + } else { + warning << " ⚠ " << result.message << std::endl; + if (!result.details.empty()) { + warning << " " << result.details << std::endl; + } + warnings++; + } + } + } + std::cout << std::endl; + + // Step 5: Linting - error handling + info << "=== Linting: Error Handling ===" << std::endl; + for (const auto& script_name : main_scripts) { + std::filesystem::path script_path = template_path / script_name; + if (std::filesystem::exists(script_path)) { + ValidationResult result = check_error_handling(script_path); + if (result.passed) { + info << " ✓ " << result.message << std::endl; + } else { + warning << " ⚠ " << result.message << std::endl; + if (!result.details.empty()) { + // Print each line of details + std::istringstream iss(result.details); + std::string detail_line; + while (std::getline(iss, detail_line)) { + warning << " " << detail_line << std::endl; + } + } + warnings++; + } + } + } + std::cout << std::endl; + + // Step 6: Linting - variable quoting + info << "=== Linting: Variable Quoting ===" << std::endl; + for (const auto& script_name : main_scripts) { + std::filesystem::path script_path = template_path / script_name; + if (std::filesystem::exists(script_path)) { + ValidationResult result = check_variable_quoting(script_path); + if (result.passed) { + info << " ✓ " << result.message << std::endl; + } else { + warning << " ⚠ " << result.message << std::endl; + if (!result.details.empty()) { + std::istringstream iss(result.details); + std::string detail_line; + while (std::getline(iss, detail_line)) { + warning << " " << detail_line << std::endl; + } + } + warnings++; + } + } + } + std::cout << std::endl; + + // Summary + maketitle("Validation Summary"); + if (errors == 0 && warnings == 0) { + info << "✓ Template '" << template_name << "' passed all checks!" << std::endl; + return 0; + } else if (errors == 0) { + warning << "Template '" << template_name << "' has " << warnings << " warning(s)" << std::endl; + return 0; // Warnings don't fail validation + } else { + error << "Template '" << template_name << "' has " << errors << " error(s) and " << warnings << " warning(s)" << std::endl; + return 1; + } +} + +} // namespace dropshell diff --git a/source/src/templates.cpp b/source/src/templates.cpp index 12553ad..cf54210 100644 --- a/source/src/templates.cpp +++ b/source/src/templates.cpp @@ -701,6 +701,44 @@ return true; } + bool template_manager::check_template_shell_scripts_syntax(const std::string &template_path) + { + if (template_path.empty() || !std::filesystem::exists(template_path)) + return false; + + bool all_passed = true; + + // Find all .sh files + for (const auto& entry : std::filesystem::recursive_directory_iterator(template_path)) { + if (entry.is_regular_file() && entry.path().extension() == ".sh") { + std::string script_path = entry.path().string(); + std::string command = "bash -n " + script_path + " 2>&1"; + + FILE* pipe = popen(command.c_str(), "r"); + if (!pipe) { + error << "Failed to run bash -n on " << entry.path().filename() << std::endl; + all_passed = false; + continue; + } + + char buffer[256]; + std::string output; + while (fgets(buffer, sizeof(buffer), pipe) != nullptr) { + output += buffer; + } + + int result = pclose(pipe); + if (result != 0 || !output.empty()) { + error << "Syntax error in " << entry.path().filename() << ":" << std::endl; + error << output << std::endl; + all_passed = false; + } + } + } + + return all_passed; + } + template_manager & gTemplateManager() { static template_manager instance; diff --git a/source/src/templates.hpp b/source/src/templates.hpp index f4a18f7..8cd1c4e 100644 --- a/source/src/templates.hpp +++ b/source/src/templates.hpp @@ -94,6 +94,7 @@ class template_manager { bool template_command_exists(const std::string& template_name,const std::string& command) const; bool create_template(const std::string& template_name) const; static bool test_template(const std::string& template_path); + static bool check_template_shell_scripts_syntax(const std::string& template_path); void list_templates() const;