#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-template", "validate", "lint"}; // 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 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 linting via shellcheck (run in container) 3. Dropshell-specific checks: - Scripts source common.sh 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; }; // Run shellcheck on all shell scripts in a directory using Docker // Returns: number of issues found (0 = all passed) int run_shellcheck(const std::filesystem::path& template_path, std::vector& scripts) { if (scripts.empty()) { return 0; } int total_issues = 0; for (const auto& script : scripts) { // Get relative path from template_path std::string rel_path = std::filesystem::relative(script, template_path).string(); // Run shellcheck in container // -f gcc gives us parseable output: file:line:col: severity: message // -s bash specifies bash dialect // -e SC1091 excludes "not following sourced file" warnings (common.sh is external) std::string command = "docker run --rm -v " + template_path.string() + ":/mnt:ro koalaman/shellcheck:stable " "-f gcc -s bash -e SC1091 /mnt/" + rel_path + " 2>&1"; FILE* pipe = popen(command.c_str(), "r"); if (!pipe) { error << "Failed to run shellcheck on " << script.filename() << std::endl; total_issues++; continue; } char buffer[512]; std::string output; while (fgets(buffer, sizeof(buffer), pipe) != nullptr) { output += buffer; } int result = pclose(pipe); if (result != 0 && !output.empty()) { // Parse and display shellcheck output // Format: /mnt/file.sh:line:col: severity: message std::istringstream iss(output); std::string line; int script_issues = 0; while (std::getline(iss, line)) { if (line.empty()) continue; // Replace /mnt/ with actual filename for clarity size_t mnt_pos = line.find("/mnt/"); if (mnt_pos != std::string::npos) { line = line.substr(mnt_pos + 5); // Skip "/mnt/" } // Color based on severity if (line.find(": error:") != std::string::npos) { error << " " << line << std::endl; script_issues++; } else if (line.find(": warning:") != std::string::npos) { warning << " " << line << std::endl; script_issues++; } else if (line.find(": note:") != std::string::npos) { info << " " << line << std::endl; } else { warning << " " << line << std::endl; script_issues++; } } if (script_issues > 0) { total_issues += script_issues; } } else { info << " ✓ " << script.filename().string() << std::endl; } } return total_issues; } // Check if script sources common.sh (dropshell-specific check) 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; } // ---------------------------------------------------------------------------- // 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: Shellcheck validation (run in container) info << "=== Shellcheck Linting ===" << std::endl; int shellcheck_issues = run_shellcheck(template_path, shell_scripts); if (shellcheck_issues > 0) { warnings += shellcheck_issues; } std::cout << std::endl; // Step 4: Dropshell-specific checks - common.sh sourcing (only for main scripts) info << "=== Dropshell: 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; // Summary maketitle("Validation Summary"); if (errors == 0 && warnings == 0) { info << "✓ Template '" << template_name << "' passed all checks!" << std::endl; return 0; } else if (errors == 0) { error << "Template '" << template_name << "' has " << warnings << " warning(s)" << std::endl; return 1; // Warnings fail validation } else { error << "Template '" << template_name << "' has " << errors << " error(s) and " << warnings << " warning(s)" << std::endl; return 1; } } } // namespace dropshell