Jelajahi Sumber

feat: add CLI admin tool (smartbotic-db-cli)

Interactive and scriptable CLI for managing a running database instance.

Commands: collections, info, find, get, upsert, remove, count
Interactive mode with quote-aware tokenizer for JSON arguments.
fszontagh 4 bulan lalu
induk
melakukan
7e1e872e42
3 mengubah file dengan 279 tambahan dan 0 penghapusan
  1. 6 0
      CMakeLists.txt
  2. 2 0
      cli/CMakeLists.txt
  3. 271 0
      cli/main.cpp

+ 6 - 0
CMakeLists.txt

@@ -87,6 +87,12 @@ if(SMARTBOTIC_DB_BUILD_SERVICE)
     add_subdirectory(service)
 endif()
 
+# CLI admin tool (standalone build only)
+option(SMARTBOTIC_DB_BUILD_CLI "Build CLI admin tool" ${SMARTBOTIC_DB_STANDALONE})
+if(SMARTBOTIC_DB_BUILD_CLI)
+    add_subdirectory(cli)
+endif()
+
 # Tests (standalone build only)
 option(SMARTBOTIC_DB_BUILD_TESTS "Build unit/integration tests" ${SMARTBOTIC_DB_STANDALONE})
 if(SMARTBOTIC_DB_BUILD_TESTS)

+ 2 - 0
cli/CMakeLists.txt

@@ -0,0 +1,2 @@
+add_executable(smartbotic-db-cli main.cpp)
+target_link_libraries(smartbotic-db-cli PRIVATE smartbotic-db-client)

+ 271 - 0
cli/main.cpp

@@ -0,0 +1,271 @@
+#include <smartbotic/database/client.hpp>
+#include <nlohmann/json.hpp>
+
+#include <iostream>
+#include <sstream>
+#include <string>
+#include <vector>
+
+using json = nlohmann::json;
+
+namespace {
+
+struct Args {
+    std::string address = "localhost:9004";
+    std::string command;
+    std::vector<std::string> params;
+};
+
+// ANSI colors
+constexpr auto C_RESET = "\033[0m";
+constexpr auto C_BOLD  = "\033[1m";
+constexpr auto C_DIM   = "\033[2m";
+constexpr auto C_CYAN  = "\033[36m";
+constexpr auto C_GREEN = "\033[32m";
+constexpr auto C_RED   = "\033[31m";
+constexpr auto C_YELLOW = "\033[33m";
+
+void printJson(const json& j) {
+    std::cout << j.dump(2) << "\n";
+}
+
+void printError(const std::string& msg) {
+    std::cerr << C_RED << "error: " << C_RESET << msg << "\n";
+}
+
+void printUsage() {
+    std::cout << C_BOLD << "smartbotic-db-cli" << C_RESET << " — admin tool for smartbotic-database\n\n"
+              << C_BOLD << "Usage:" << C_RESET << "\n"
+              << "  smartbotic-db-cli [--address HOST:PORT] <command> [args...]\n"
+              << "  smartbotic-db-cli [--address HOST:PORT]   " << C_DIM << "# interactive mode" << C_RESET << "\n\n"
+              << C_BOLD << "Commands:" << C_RESET << "\n"
+              << "  " << C_CYAN << "collections" << C_RESET << "                          List all collections\n"
+              << "  " << C_CYAN << "info" << C_RESET << " <collection>                    Collection info\n"
+              << "  " << C_CYAN << "find" << C_RESET << " <collection>                    List documents\n"
+              << "  " << C_CYAN << "get" << C_RESET << " <collection> <id>                Get a document\n"
+              << "  " << C_CYAN << "upsert" << C_RESET << " <collection> <id> '<json>'    Insert or update\n"
+              << "  " << C_CYAN << "remove" << C_RESET << " <collection> <id>             Delete a document\n"
+              << "  " << C_CYAN << "count" << C_RESET << " <collection>                   Count documents\n"
+              << "  " << C_CYAN << "help" << C_RESET << "                                 Show this help\n\n"
+              << C_BOLD << "Options:" << C_RESET << "\n"
+              << "  --address HOST:PORT    Database address (default: localhost:9004)\n";
+}
+
+bool execCommand(smartbotic::database::Client& client,
+                 const std::string& cmd, const std::vector<std::string>& params) {
+    try {
+        if (cmd == "collections") {
+            auto collections = client.listCollections();
+            std::cout << C_BOLD << "Collections:" << C_RESET << "\n";
+            for (const auto& c : collections) {
+                auto info = client.getCollectionInfo(c);
+                int64_t count = 0;
+                if (info) count = info->documentCount;
+                std::cout << "  " << C_CYAN << c << C_RESET
+                          << C_DIM << " (" << count << " docs)" << C_RESET << "\n";
+            }
+            return true;
+        }
+
+        if (cmd == "info") {
+            if (params.empty()) { printError("usage: info <collection>"); return false; }
+            auto info = client.getCollectionInfo(params[0]);
+            if (!info) { printError("collection not found: " + params[0]); return false; }
+            std::cout << C_BOLD << params[0] << C_RESET << ":\n"
+                      << "  documents: " << info->documentCount << "\n"
+                      << "  size: " << info->sizeBytes << " bytes\n"
+                      << "  encrypted: " << (info->encrypted ? "yes" : "no") << "\n"
+                      << "  max_versions: " << info->maxVersions << "\n";
+            if (info->defaultTtlSeconds > 0)
+                std::cout << "  ttl: " << info->defaultTtlSeconds << "s\n";
+            return true;
+        }
+
+        if (cmd == "find") {
+            if (params.empty()) { printError("usage: find <collection>"); return false; }
+            auto docs = client.find(params[0]);
+            std::cout << C_DIM << "(" << docs.size() << " documents)" << C_RESET << "\n";
+            for (const auto& doc : docs) {
+                auto id = doc.value("_id", "");
+                // Print compact summary line
+                std::cout << C_GREEN << id << C_RESET;
+                // Show a few key fields
+                for (const auto& [k, v] : doc.items()) {
+                    if (k == "_id" || k == "_created_at" || k == "_updated_at") continue;
+                    auto val = v.dump();
+                    if (val.size() > 60) val = val.substr(0, 57) + "...";
+                    std::cout << " " << C_DIM << k << "=" << C_RESET << val;
+                    // Limit to 3 fields per line
+                    static int field_count = 0;
+                    if (++field_count >= 3) { field_count = 0; break; }
+                }
+                std::cout << "\n";
+            }
+            return true;
+        }
+
+        if (cmd == "get") {
+            if (params.size() < 2) { printError("usage: get <collection> <id>"); return false; }
+            auto doc = client.get(params[0], params[1]);
+            if (!doc) { printError("not found: " + params[0] + "/" + params[1]); return false; }
+            printJson(*doc);
+            return true;
+        }
+
+        if (cmd == "upsert") {
+            if (params.size() < 3) { printError("usage: upsert <collection> <id> '<json>'"); return false; }
+            auto data = json::parse(params[2], nullptr, false);
+            if (!data.is_object()) { printError("invalid JSON: " + params[2]); return false; }
+            client.upsert(params[0], data, params[1]);
+            std::cout << C_GREEN << "ok" << C_RESET << " " << params[0] << "/" << params[1] << "\n";
+            return true;
+        }
+
+        if (cmd == "remove" || cmd == "delete") {
+            if (params.size() < 2) { printError("usage: remove <collection> <id>"); return false; }
+            client.remove(params[0], params[1]);
+            std::cout << C_GREEN << "ok" << C_RESET << " removed " << params[0] << "/" << params[1] << "\n";
+            return true;
+        }
+
+        if (cmd == "count") {
+            if (params.empty()) { printError("usage: count <collection>"); return false; }
+            auto info = client.getCollectionInfo(params[0]);
+            if (!info) { printError("collection not found: " + params[0]); return false; }
+            std::cout << info->documentCount << "\n";
+            return true;
+        }
+
+        if (cmd == "help" || cmd == "?") {
+            printUsage();
+            return true;
+        }
+
+        printError("unknown command: " + cmd + " (try 'help')");
+        return false;
+
+    } catch (const std::exception& e) {
+        printError(e.what());
+        return false;
+    }
+}
+
+// Tokenize a line, respecting single/double quotes and JSON braces
+std::vector<std::string> tokenize(const std::string& line) {
+    std::vector<std::string> tokens;
+    std::string current;
+    int brace_depth = 0;
+    char in_quote = 0;
+
+    for (size_t i = 0; i < line.size(); ++i) {
+        char c = line[i];
+
+        if (in_quote) {
+            current += c;
+            if (c == in_quote && (i == 0 || line[i-1] != '\\')) {
+                in_quote = 0;
+                // Strip surrounding quotes for simple string tokens
+                if (brace_depth == 0 && current.size() >= 2
+                    && (current.front() == '\'' || current.front() == '"')
+                    && current.front() == current.back()) {
+                    current = current.substr(1, current.size() - 2);
+                }
+            }
+            continue;
+        }
+
+        if (c == '\'' || c == '"') {
+            in_quote = c;
+            current += c;
+            continue;
+        }
+
+        if (c == '{') { brace_depth++; current += c; continue; }
+        if (c == '}') {
+            brace_depth--;
+            current += c;
+            if (brace_depth <= 0) {
+                brace_depth = 0;
+                tokens.push_back(current);
+                current.clear();
+            }
+            continue;
+        }
+
+        if (brace_depth > 0) { current += c; continue; }
+
+        if (c == ' ' || c == '\t') {
+            if (!current.empty()) {
+                tokens.push_back(current);
+                current.clear();
+            }
+            continue;
+        }
+
+        current += c;
+    }
+    if (!current.empty()) tokens.push_back(current);
+    return tokens;
+}
+
+} // anonymous namespace
+
+int main(int argc, char* argv[]) {
+    Args args;
+
+    // Parse flags
+    std::vector<std::string> positional;
+    for (int i = 1; i < argc; ++i) {
+        std::string arg = argv[i];
+        if (arg == "--address" && i + 1 < argc) {
+            args.address = argv[++i];
+        } else if (arg == "--help" || arg == "-h") {
+            printUsage();
+            return 0;
+        } else {
+            positional.push_back(arg);
+        }
+    }
+
+    if (!positional.empty()) {
+        args.command = positional[0];
+        args.params.assign(positional.begin() + 1, positional.end());
+    }
+
+    // Connect
+    smartbotic::database::Client client({.address = args.address});
+    client.connect();
+
+    // Scriptable mode: single command
+    if (!args.command.empty()) {
+        return execCommand(client, args.command, args.params) ? 0 : 1;
+    }
+
+    // Interactive mode
+    std::cout << C_BOLD << "smartbotic-db-cli" << C_RESET
+              << " connected to " << C_CYAN << args.address << C_RESET << "\n"
+              << C_DIM << "Type 'help' for commands, 'exit' to quit." << C_RESET << "\n";
+
+    std::string line;
+    while (true) {
+        std::cout << C_YELLOW << "> " << C_RESET;
+        if (!std::getline(std::cin, line)) break;
+
+        // Trim
+        auto start = line.find_first_not_of(" \t");
+        if (start == std::string::npos) continue;
+        line = line.substr(start);
+
+        if (line == "exit" || line == "quit" || line == "q") break;
+        if (line.empty()) continue;
+
+        auto tokens = tokenize(line);
+        if (tokens.empty()) continue;
+
+        auto cmd = tokens[0];
+        std::vector<std::string> params(tokens.begin() + 1, tokens.end());
+        execCommand(client, cmd, params);
+    }
+
+    return 0;
+}