Files
dropshell/source/src/commands/validate.cpp
j 7763807445
All checks were successful
Build-Test-Publish / build (linux/amd64) (push) Successful in 30s
Build-Test-Publish / build (linux/arm64) (push) Successful in 1m13s
feat: Update 2 files
2025-12-28 11:32:09 +13:00

266 lines
9.2 KiB
C++

#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 <iostream>
#include <sstream>
#include <filesystem>
#include <fstream>
#include <regex>
#include <set>
namespace dropshell {
void validate_autocomplete(const CommandContext& ctx);
int validate_handler(const CommandContext& ctx);
static std::vector<std::string> 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<std::string> 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<std::filesystem::path>& 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<std::filesystem::path> 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<std::string> 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