lint!
All checks were successful
Build-Test-Publish / build (linux/amd64) (push) Successful in 31s
Build-Test-Publish / build (linux/arm64) (push) Successful in 2m27s

This commit is contained in:
j
2025-12-27 23:29:50 +13:00
parent 0257a9c75c
commit 8021e3f57b
4 changed files with 440 additions and 0 deletions

View File

@@ -123,6 +123,13 @@ namespace dropshell
return false; 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 // Create service directory
std::string mkdir_cmd = "mkdir -p " + quote(remote_service_path); 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)) if (!execute_ssh_command(server_env.get_SSH_INFO(user), sCommand("", mkdir_cmd, {}), cMode::Silent))

View File

@@ -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 <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", "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<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;
};
// 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<std::string> 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<std::string> 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<std::string> 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<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: 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<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;
// 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

View File

@@ -701,6 +701,44 @@
return true; 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() template_manager & gTemplateManager()
{ {
static template_manager instance; static template_manager instance;

View File

@@ -94,6 +94,7 @@ class template_manager {
bool template_command_exists(const std::string& template_name,const std::string& command) const; bool template_command_exists(const std::string& template_name,const std::string& command) const;
bool create_template(const std::string& template_name) const; bool create_template(const std::string& template_name) const;
static bool test_template(const std::string& template_path); 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; void list_templates() const;