diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index dfb2dd8..70ab805 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -20,7 +20,9 @@ jobs: - name: Compile tux-dock run: | - g++ -std=c++17 main.cpp -o tux-dock + cmake -S . -B build + cmake --build build -j + cp build/tux-dock ./tux-dock chmod +x tux-dock echo "🎉 tux-dock compiled and marked executable!" @@ -42,4 +44,3 @@ jobs: name: tux-dock-linux-x86_64-${{ env.BRANCH }}-${{ env.COMMIT }} path: tux-dock-linux-x86_64-${{ env.BRANCH }}-*.tar.gz - diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..2b8b1ec --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,30 @@ +cmake_minimum_required(VERSION 3.16) + +project(tux-dock LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +include(FetchContent) + +set(FTXUI_BUILD_DOCS OFF CACHE BOOL "" FORCE) +set(FTXUI_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(FTXUI_BUILD_TESTS OFF CACHE BOOL "" FORCE) + +FetchContent_Declare( + ftxui + GIT_REPOSITORY https://github.com/ArthurSonzogni/FTXUI.git + GIT_TAG v5.0.0 +) + +FetchContent_MakeAvailable(ftxui) + +add_executable(tux-dock main.cpp) + +target_link_libraries(tux-dock + PRIVATE + ftxui::screen + ftxui::dom + ftxui::component +) diff --git a/README.md b/README.md index da37e7c..62050b2 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ It offers a clean, interactive menu for common Docker operations like pulling im - **C++17 or newer** compiler (e.g., `g++`, `clang++`) +- **CMake 3.16+** - **Docker Engine** installed and running (tested on Debian 12/13, Alpine Linux, and Arch Linux) @@ -36,12 +37,13 @@ It offers a clean, interactive menu for common Docker operations like pulling im git clone https://mentalnet.xyz/forgejo/markmental/tuxdock.git cd tuxdock -# Build the binary -g++ -std=c++17 main.cpp -o tux-dock +# Configure & build (FTXUI is fetched automatically) +cmake -S . -B build +cmake --build build -j # Run it (requires Docker permissions) -sudo ./tux-dock -```` +sudo ./build/tux-dock +``` Prefer a prebuilt binary? The CI runner publishes build artifacts for the latest commits at: https://mentalnet.xyz/forgejo/markmental/tuxdock/actions diff --git a/compile.sh b/compile.sh index ec09bb9..f5a6b58 100755 --- a/compile.sh +++ b/compile.sh @@ -1,3 +1,3 @@ #!/bin/sh -g++ -std=c++17 main.cpp -o tux-dock && echo "tux-dock successfully compiled!" +cmake -S . -B build && cmake --build build -j && echo "tux-dock successfully compiled!" diff --git a/main.cpp b/main.cpp index 6f8bea1..6d4bd81 100644 --- a/main.cpp +++ b/main.cpp @@ -1,358 +1,301 @@ -#include -#include -#include -#include -#include #include -#include +#include +#include +#include #include -#include +#include +#include +#include +#include +#include +#include +#include +#include -using namespace std; +#include +#include +#include class DockerManager { public: - void pullImage(); - void runContainerInteractive(); - void listContainers() const; - void listImages() const; - void startInteractive(); - void startDetached(); - void deleteImage(); - void stopContainer(); - void removeContainer(); - void execShell(); - void execDetachedCommand(); - void createDockerfile(); - void spinUpMySQL(); - void showContainerIP(); + struct ContainerInfo { + std::string id; + std::string name; + std::string status; + std::string ports; + bool running = false; + }; + + std::vector getContainerList() const; + std::vector> getImageList() const; + + bool pullImage(const std::string& image, std::string& message) const; + bool runContainerInteractive(const std::string& image, + const std::vector& ports, + std::string& message) const; + bool startInteractive(const std::string& containerId, std::string& message) const; + bool startDetached(const std::string& containerId, std::string& message) const; + bool deleteImage(const std::string& imageId, std::string& message) const; + bool stopContainer(const std::string& containerId, std::string& message) const; + bool removeContainer(const std::string& containerId, std::string& message) const; + bool execShell(const std::string& containerId, std::string& message) const; + bool execDetachedCommand(const std::string& containerId, + const std::string& command, + std::string& message) const; + bool spinUpMySQL(const std::string& port, + const std::string& password, + const std::string& version, + std::string& message) const; + bool showContainerIP(const std::string& containerId, std::string& message) const; + bool createDockerfile(const std::string& baseImage, + const std::string& bashScriptPath, + std::string outputFile, + const std::string& imageName, + std::string& message) const; private: - static void runCommand(const string& cmd); - vector> getContainerList() const; - string selectContainer(const string& prompt); - /* NEW helper – retrieves all images */ - vector> getImageList() const; + static bool runCommand(const std::string& cmd, bool quiet = true); + static std::string shellEscape(const std::string& value); }; -// ---------------- Core Utility ---------------- - -void DockerManager::runCommand(const string& cmd) { - system(cmd.c_str()); +bool DockerManager::runCommand(const std::string& cmd, bool quiet) { + std::string command = cmd; + if (quiet) { + command += " > /dev/null 2>&1"; + } + const int code = std::system(command.c_str()); + return code == 0; } -vector> DockerManager::getImageList() const { - vector> images; - array buffer{}; - string result; +std::string DockerManager::shellEscape(const std::string& value) { + std::string escaped = "'"; + for (char c : value) { + if (c == '\'') { + escaped += "'\\''"; + } else { + escaped += c; + } + } + escaped += "'"; + return escaped; +} + +std::vector> DockerManager::getImageList() const { + std::vector> images; + std::array buffer{}; + std::string result; FILE* pipe = popen("docker images --format '{{.ID}} {{.Repository}}:{{.Tag}}'", "r"); - if (!pipe) return images; - while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) { + if (!pipe) { + return images; + } + while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { result = buffer.data(); - stringstream ss(result); - string id, repoTag; + std::stringstream ss(result); + std::string id; + std::string repoTag; ss >> id >> repoTag; - if (!id.empty() && !repoTag.empty()) + if (!id.empty() && !repoTag.empty()) { images.emplace_back(id, repoTag); + } } pclose(pipe); return images; } -vector> DockerManager::getContainerList() const { - vector> containers; - array buffer{}; - string result; +std::vector DockerManager::getContainerList() const { + std::vector containers; + std::array buffer{}; + std::string result; - FILE* pipe = popen("docker ps -a --format '{{.ID}} {{.Names}}'", "r"); - if (!pipe) return containers; + FILE* pipe = popen("docker ps -a --format '{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}'", "r"); + if (!pipe) { + return containers; + } - while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) { + while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { result = buffer.data(); - stringstream ss(result); - string id, name; - ss >> id >> name; - if (!id.empty() && !name.empty()) - containers.emplace_back(id, name); + while (!result.empty() && (result.back() == '\n' || result.back() == '\r')) { + result.pop_back(); + } + + std::stringstream ss(result); + ContainerInfo info; + std::getline(ss, info.id, '\t'); + std::getline(ss, info.name, '\t'); + std::getline(ss, info.status, '\t'); + std::getline(ss, info.ports); + + if (!info.id.empty() && !info.name.empty()) { + info.running = info.status.rfind("Up", 0) == 0; + containers.push_back(info); + } } pclose(pipe); return containers; } -string DockerManager::selectContainer(const string& prompt) { - auto containers = getContainerList(); - if (containers.empty()) { - cout << "No containers available.\n"; - return ""; +bool DockerManager::pullImage(const std::string& image, std::string& message) const { + if (image.empty()) { + message = "Please provide an image name."; + return false; } - - cout << "\nAvailable Containers:\n"; - int i = 1; - for (const auto& c : containers) - cout << i++ << ". " << c.second << " (" << c.first.substr(0, 12) << ")\n"; - - int choice; - cout << prompt << " (1-" << containers.size() << "): "; - cin >> choice; - - if (choice < 1 || choice > static_cast(containers.size())) { - cout << "Invalid selection.\n"; - return ""; - } - - return containers[choice - 1].first; + const bool ok = runCommand("docker pull " + shellEscape(image)); + message = ok ? "Image pulled successfully." : "Could not pull that image."; + return ok; } -// ---------------- Docker Actions ---------------- - -void DockerManager::pullImage() { - const vector images = {"debian:stable", "ubuntu:noble", "rockylinux:9.3", "alpine:latest"}; - - cout << "\nSelect Docker image to pull:\n"; - for (size_t i = 0; i < images.size(); ++i) cout << i + 1 << ". " << images[i] << "\n"; - cout << images.size() + 1 << ". Enter custom image\n"; - cout << "Choose an option (1-" << images.size() + 1 << "): "; - - int choice; - cin >> choice; - - string image; - if (choice >= 1 && choice <= static_cast(images.size())) { - image = images[choice - 1]; - } else if (choice == static_cast(images.size()) + 1) { - cout << "Enter custom Docker image name: "; - cin >> image; - } else { - cout << "Invalid selection. Aborting.\n"; - return; - } - - cout << "Pulling image: " << image << "\n"; - runCommand("docker pull " + image); -} - -void DockerManager::runContainerInteractive() { - // Get list of available images - auto images = getImageList(); - string image; - - if (images.empty()) { - cout << "No Docker images found. Please pull an image first.\n"; - return; - } - - // Display available images - cout << "\nAvailable Docker Images:\n"; - int idx = 1; - for (const auto& img : images) { - cout << idx++ << ". " << img.second << " (" << img.first.substr(0, 12) << ")\n"; - } - - // Prompt user to select or enter custom image - cout << idx << ". Enter custom Docker image name\n"; - cout << "Choose an option (1-" << images.size() << " or " << idx << "): "; - - int choice; - cin >> choice; - - if (choice >= 1 && choice <= static_cast(images.size())) { - // User selected an existing image - image = images[choice - 1].second; - } else if (choice == idx) { - // User wants to enter custom image name - cout << "Enter custom Docker image name: "; - cin >> image; - } else { - cout << "Invalid selection. Aborting.\n"; - return; - } - - // Continue with port mapping - int portCount; - cout << "How many port mappings? "; - cin >> portCount; - vector ports; - for (int i = 0; i < portCount; ++i) { - string port; - cout << "Enter mapping #" << (i + 1) - << " (format host:container, e.g., 8080:80): "; - cin >> port; - ports.push_back("-p " + port); - } - string portArgs; - for (const auto& p : ports) portArgs += p + " "; - cout << "\nPort Forwarding Explanation:\n" - << " '-p hostPort:containerPort' exposes the container’s port to the host.\n" - << " Example: '-p 8080:80' allows access via http://localhost:8080\n\n"; - runCommand("docker run -it " + portArgs + image + " /bin/sh"); -} - -void DockerManager::listContainers() const { runCommand("docker ps -a"); } -void DockerManager::listImages() const { runCommand("docker images"); } - -void DockerManager::startInteractive() { - string id = selectContainer("Select container to start interactively"); - if (!id.empty()) runCommand("docker start -ai " + id); -} - -void DockerManager::startDetached() { - string id = selectContainer("Select container to start detached"); - if (!id.empty()) runCommand("docker start " + id); -} - -void DockerManager::deleteImage() { - auto images = getImageList(); - if (images.empty()) { - cout << "No Docker images found.\n"; - return; +bool DockerManager::runContainerInteractive(const std::string& image, + const std::vector& ports, + std::string& message) const { + if (image.empty()) { + message = "Please choose an image first."; + return false; } - cout << "\nAvailable Images:\n"; - int idx = 1; - for (const auto& img : images) { - cout << idx++ << ". " << img.second << " (" << img.first.substr(0, 12) << ")\n"; + std::string cmd = "docker run -it "; + for (const auto& port : ports) { + cmd += "-p " + shellEscape(port) + " "; } + cmd += shellEscape(image) + " /bin/sh"; - int choice; - cout << "Select image to delete (1-" << images.size() << "): "; - cin >> choice; - - if (choice < 1 || choice > static_cast(images.size())) { - cout << "Invalid selection.\n"; - return; - } - - const string& id = images[choice - 1].first; - cout << "Deleting image: " << images[choice - 1].second - << " (ID: " << id << ") ...\n"; - runCommand("docker rmi " + id); + const bool ok = runCommand(cmd, false); + message = ok ? "Interactive container session finished." : "Could not start that container session."; + return ok; } -void DockerManager::stopContainer() { - string id = selectContainer("Select container to stop"); - if (!id.empty()) runCommand("docker stop " + id); +bool DockerManager::startInteractive(const std::string& containerId, std::string& message) const { + const bool ok = runCommand("docker start -ai " + shellEscape(containerId), false); + message = ok ? "Interactive container session finished." : "Could not start that container interactively."; + return ok; } -void DockerManager::removeContainer() { - string id = selectContainer("Select container to remove"); - if (!id.empty()) runCommand("docker rm " + id); +bool DockerManager::startDetached(const std::string& containerId, std::string& message) const { + const bool ok = runCommand("docker start " + shellEscape(containerId)); + message = ok ? "Container started in detached mode." : "Could not start that container."; + return ok; } -void DockerManager::execShell() { - string id = selectContainer("Select running container for shell access"); - if (!id.empty()) runCommand("docker exec -it " + id + " /bin/sh"); +bool DockerManager::deleteImage(const std::string& imageId, std::string& message) const { + const bool ok = runCommand("docker rmi " + shellEscape(imageId)); + message = ok ? "Image deleted." : "Could not delete that image. It may still be in use."; + return ok; } -void DockerManager::execDetachedCommand() { - string id = selectContainer("Select container to run command in (detached)"); - if (id.empty()) return; +bool DockerManager::stopContainer(const std::string& containerId, std::string& message) const { + const bool ok = runCommand("docker stop " + shellEscape(containerId)); + message = ok ? "Container stopped." : "Could not stop that container."; + return ok; +} - // Flush any leftover newline before using getline - cin.ignore(numeric_limits::max(), '\n'); +bool DockerManager::removeContainer(const std::string& containerId, std::string& message) const { + const bool ok = runCommand("docker rm " + shellEscape(containerId)); + message = ok ? "Container removed." : "Could not remove that container."; + return ok; +} - string command; - cout << "Enter command to execute inside the container: "; - getline(cin, command); +bool DockerManager::execShell(const std::string& containerId, std::string& message) const { + const bool ok = runCommand("docker exec -it " + shellEscape(containerId) + " /bin/sh", false); + message = ok ? "Shell session finished." : "Could not open a shell in that container."; + return ok; +} +bool DockerManager::execDetachedCommand(const std::string& containerId, + const std::string& command, + std::string& message) const { if (command.empty()) { - cout << "No command entered. Aborting.\n"; - return; + message = "Please provide a command to run."; + return false; } - string escapedCommand; - escapedCommand.reserve(command.size() * 2); - for (char c : command) { - if (c == '"' || c == '\\') - escapedCommand += '\\'; - escapedCommand += c; - } - - cout << "Executing command in detached mode...\n"; - runCommand("docker exec -d " + id + " /bin/sh -c \"" + escapedCommand + "\""); - cout << "Command dispatched.\n"; + const std::string cmd = "docker exec -d " + shellEscape(containerId) + " /bin/sh -c " + + shellEscape(command); + const bool ok = runCommand(cmd); + message = ok ? "Command dispatched in detached mode." : "Could not run that command."; + return ok; } -void DockerManager::spinUpMySQL() { - string port, password, version; - cout << "Enter port mapping (e.g., 3306:3306): "; - cin >> port; - cout << "Enter MySQL root password: "; - cin >> password; - cout << "Enter MySQL version tag (e.g., 8): "; - cin >> version; - - cout << "\nLaunching MySQL container (accessible via localhost:" - << port.substr(0, port.find(':')) << ")\n"; - runCommand("docker run -p " + port + - " --name mysql-container -e MYSQL_ROOT_PASSWORD=" + password + - " -d mysql:" + version); +bool DockerManager::spinUpMySQL(const std::string& port, + const std::string& password, + const std::string& version, + std::string& message) const { + const std::string cmd = "docker run -p " + shellEscape(port) + + " --name mysql-container -e MYSQL_ROOT_PASSWORD=" + + shellEscape(password) + " -d " + shellEscape("mysql:" + version); + const bool ok = runCommand(cmd); + message = ok ? "MySQL container launched." : "Could not launch MySQL. Check the version and port mapping."; + return ok; } -// ---------------- Parsed IP Feature ---------------- +bool DockerManager::showContainerIP(const std::string& containerId, std::string& message) const { + const std::string command = "docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' " + + shellEscape(containerId); -void DockerManager::showContainerIP() { - string id = selectContainer("Select container to view IP"); - if (id.empty()) return; - - string command = "docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' " + id; - - array buffer{}; - string ip; + std::array buffer{}; + std::string ip; FILE* pipe = popen(command.c_str(), "r"); if (!pipe) { - cout << "Failed to inspect container.\n"; - return; + message = "Could not inspect that container."; + return false; } - if (fgets(buffer.data(), buffer.size(), pipe) != nullptr) { + if (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { ip = buffer.data(); } pclose(pipe); - if (ip.empty() || ip == "\n") - cout << "No IP address found (container may be stopped or not attached to a network).\n"; - else - cout << "Container IP Address: " << ip; + while (!ip.empty() && (ip.back() == '\n' || ip.back() == '\r' || ip.back() == ' ' || ip.back() == '\t')) { + ip.pop_back(); + } + + if (ip.empty()) { + message = "No IP address found. The container may be stopped."; + return false; + } + + message = "Container IP address: " + ip; + return true; } -void DockerManager::createDockerfile() { - string baseImage, bashScriptPath, outputFile, imageName; - cout << "Enter base Docker image (e.g., ubuntu:22.04): "; - cin >> baseImage; - - cout << "Enter path to bash script to convert (e.g., setup.sh): "; - cin >> bashScriptPath; - - if (!filesystem::exists(bashScriptPath)) { - cout << "Error: Bash script not found.\n"; - return; +bool DockerManager::createDockerfile(const std::string& baseImage, + const std::string& bashScriptPath, + std::string outputFile, + const std::string& imageName, + std::string& message) const { + if (baseImage.empty() || bashScriptPath.empty()) { + message = "Base image and script path are required."; + return false; } - cout << "Enter output Dockerfile name (e.g., Dockerfile or Dockerfile_app): "; - cin >> outputFile; + if (!std::filesystem::exists(bashScriptPath)) { + message = "Could not find that script file."; + return false; + } - if (outputFile.empty()) outputFile = "Dockerfile"; + if (outputFile.empty()) { + outputFile = "Dockerfile"; + } - ifstream scriptFile(bashScriptPath); - ofstream dockerfile(outputFile); + std::ifstream scriptFile(bashScriptPath); + std::ofstream dockerfile(outputFile); if (!scriptFile.is_open() || !dockerfile.is_open()) { - cout << "Error: Unable to open file(s).\n"; - return; + message = "Could not open one of the files."; + return false; } - // Write Dockerfile header dockerfile << "FROM " << baseImage << "\n"; dockerfile << "WORKDIR /app\n\n"; dockerfile << "# Auto-generated by Tux-Dock\n"; - string line; - while (getline(scriptFile, line)) { - if (line.empty()) continue; - - // Skip comments or shebang - if (line.rfind("#", 0) == 0 || line.rfind("#!", 0) == 0) + std::string line; + while (std::getline(scriptFile, line)) { + if (line.empty()) { continue; + } + + if (line.rfind("#", 0) == 0 || line.rfind("#!", 0) == 0) { + continue; + } dockerfile << "RUN " << line << "\n"; } @@ -361,70 +304,953 @@ void DockerManager::createDockerfile() { dockerfile.close(); scriptFile.close(); - cout << "Dockerfile created successfully: " << outputFile << "\n"; - cout << "Enter image name to build from this Dockerfile (e.g., myimage): "; - cin >> imageName; - if (imageName.empty()) { - cout << "No image name provided. Skipping build.\n"; + message = "Dockerfile created. Build skipped because no image name was provided."; + return true; + } + + const bool ok = runCommand("docker build -t " + shellEscape(imageName) + " -f " + + shellEscape(outputFile) + " ."); + message = ok ? "Dockerfile created and image build completed." : "Dockerfile created, but the image build failed."; + return ok; +} + +class TuxDockApp { +public: + void Run(); + +private: + enum class ModalMode { None, Input, Confirm, Select, Message }; + + struct RunContainerContext { + std::string image; + int port_count = 0; + std::vector ports; + }; + + struct MySqlContext { + std::string port; + std::string password; + std::string version; + }; + + struct DockerfileContext { + std::string base_image; + std::string script_path; + std::string output_file; + std::string image_name; + }; + + DockerManager docker_; + + std::vector menu_entries_ = { + "Pull Docker Image", + "Run/Create Interactive Container", + "List All Containers", + "List All Images", + "Start Container Interactively (boot new session)", + "Start Detached Container Session", + "Delete Docker Image", + "Stop Container", + "Remove Container", + "Attach Shell to Running Container", + "Run Detached Command in Container", + "Spin Up MySQL Container", + "Get Container IP Address", + "Create Dockerfile & Build Image from Bash Script", + "Exit", + }; + + int menu_selected_ = 0; + std::string status_ = "Ready. Select an action and press Enter."; + + ModalMode modal_mode_ = ModalMode::None; + std::string modal_title_; + std::string modal_text_; + std::string modal_input_; + std::vector modal_select_entries_; + int modal_select_index_ = 0; + + ftxui::Component menu_component_ = ftxui::Menu(&menu_entries_, &menu_selected_); + ftxui::Component input_component_ = ftxui::Input(&modal_input_, "Type here"); + ftxui::Component select_component_ = ftxui::Menu(&modal_select_entries_, &modal_select_index_); + + std::function input_callback_; + std::function confirm_callback_; + std::function select_callback_; + + ftxui::ScreenInteractive* screen_ = nullptr; + + static bool IsDigits(const std::string& value); + static std::string ShortId(const std::string& id); + static bool IsValidPortMapping(const std::string& mapping); + + std::string FormatContainerList(const std::vector& containers) const; + std::string FormatImageList(const std::vector>& images) const; + + void SetStatus(const std::string& message); + void OpenInput(const std::string& title, + const std::string& text, + std::function callback, + bool secret = false); + void OpenConfirm(const std::string& title, const std::string& text, std::function callback); + void OpenSelect(const std::string& title, + const std::string& text, + std::vector options, + std::function callback); + void OpenMessage(const std::string& title, const std::string& text); + void ResolveInput(bool confirmed); + void ResolveConfirm(bool confirmed); + void ResolveSelect(bool confirmed); + void CloseMessage(); + + void ExecuteSelectedAction(); + void ActionPullImage(); + void ActionRunContainer(); + void ActionListContainers(); + void ActionListImages(); + void ActionStartInteractive(); + void ActionStartDetached(); + void ActionDeleteImage(); + void ActionStopContainer(); + void ActionRemoveContainer(); + void ActionExecShell(); + void ActionExecDetachedCommand(); + void ActionSpinUpMySQL(); + void ActionShowContainerIP(); + void ActionCreateDockerfile(); + void PromptPortCountAndRun(const std::shared_ptr& context); + void PromptNextPort(const std::shared_ptr& context, int index); + + void PromptContainerSelection(const std::string& title, + std::function callback); + void PromptImageSelection(const std::string& title, + std::function callback); + static void ClearTerminal(); + void RunWithRestoredIO(const std::function& action, + bool clear_before = false, + bool clear_after = false); + + bool OnEvent(ftxui::Event event); + ftxui::Element Render() const; + ftxui::Element RenderModal() const; +}; + +bool TuxDockApp::IsDigits(const std::string& value) { + if (value.empty()) { + return false; + } + for (unsigned char c : value) { + if (!std::isdigit(c)) { + return false; + } + } + return true; +} + +std::string TuxDockApp::ShortId(const std::string& id) { + if (id.size() <= 12) { + return id; + } + return id.substr(0, 12); +} + +bool TuxDockApp::IsValidPortMapping(const std::string& mapping) { + const std::size_t separator = mapping.find(':'); + if (separator == std::string::npos) { + return false; + } + const std::string host = mapping.substr(0, separator); + const std::string container = mapping.substr(separator + 1); + return IsDigits(host) && IsDigits(container); +} + +std::string TuxDockApp::FormatContainerList( + const std::vector& containers) const { + std::stringstream ss; + for (std::size_t i = 0; i < containers.size(); ++i) { + const auto& container = containers[i]; + const std::string state = container.running ? "running" : "stopped"; + const std::string ports = container.ports.empty() ? "none" : container.ports; + ss << i + 1 << ". " << container.name << " (" << ShortId(container.id) << ")" + << " [" << state << "]" + << " ports: " << ports; + if (i + 1 < containers.size()) { + ss << "\n"; + } + } + return ss.str(); +} + +std::string TuxDockApp::FormatImageList(const std::vector>& images) const { + std::stringstream ss; + for (std::size_t i = 0; i < images.size(); ++i) { + ss << i + 1 << ". " << images[i].second << " (" << ShortId(images[i].first) << ")"; + if (i + 1 < images.size()) { + ss << "\n"; + } + } + return ss.str(); +} + +void TuxDockApp::SetStatus(const std::string& message) { + status_ = message; +} + +void TuxDockApp::OpenInput(const std::string& title, + const std::string& text, + std::function callback, + bool secret) { + modal_mode_ = ModalMode::Input; + modal_title_ = title; + modal_text_ = text; + modal_input_.clear(); + ftxui::InputOption input_option; + input_option.password = secret; + input_component_ = ftxui::Input(&modal_input_, "Type here", input_option); + input_callback_ = std::move(callback); +} + +void TuxDockApp::OpenConfirm(const std::string& title, + const std::string& text, + std::function callback) { + modal_mode_ = ModalMode::Confirm; + modal_title_ = title; + modal_text_ = text; + confirm_callback_ = std::move(callback); +} + +void TuxDockApp::OpenSelect(const std::string& title, + const std::string& text, + std::vector options, + std::function callback) { + modal_mode_ = ModalMode::Select; + modal_title_ = title; + modal_text_ = text; + modal_select_entries_ = std::move(options); + modal_select_index_ = 0; + select_component_ = ftxui::Menu(&modal_select_entries_, &modal_select_index_); + select_callback_ = std::move(callback); +} + +void TuxDockApp::OpenMessage(const std::string& title, const std::string& text) { + modal_mode_ = ModalMode::Message; + modal_title_ = title; + modal_text_ = text; +} + +void TuxDockApp::ResolveInput(bool confirmed) { + const std::string value = modal_input_; + auto callback = std::move(input_callback_); + + modal_mode_ = ModalMode::None; + modal_title_.clear(); + modal_text_.clear(); + modal_input_.clear(); + input_component_ = ftxui::Input(&modal_input_, "Type here"); + input_callback_ = {}; + + if (callback) { + callback(confirmed, value); + } +} + +void TuxDockApp::ResolveConfirm(bool confirmed) { + auto callback = std::move(confirm_callback_); + + modal_mode_ = ModalMode::None; + modal_title_.clear(); + modal_text_.clear(); + confirm_callback_ = {}; + + if (callback) { + callback(confirmed); + } +} + +void TuxDockApp::ResolveSelect(bool confirmed) { + const int selected = modal_select_index_; + auto callback = std::move(select_callback_); + + modal_mode_ = ModalMode::None; + modal_title_.clear(); + modal_text_.clear(); + modal_select_entries_.clear(); + modal_select_index_ = 0; + select_component_ = ftxui::Menu(&modal_select_entries_, &modal_select_index_); + select_callback_ = {}; + + if (callback) { + callback(confirmed, selected); + } +} + +void TuxDockApp::CloseMessage() { + modal_mode_ = ModalMode::None; + modal_title_.clear(); + modal_text_.clear(); +} + +void TuxDockApp::PromptContainerSelection( + const std::string& title, + std::function callback) { + auto containers = docker_.getContainerList(); + if (containers.empty()) { + SetStatus("No containers available."); return; } - cout << "Building Docker image '" << imageName << "'...\n"; - runCommand("docker build -t " + imageName + " -f " + outputFile + " ."); - cout << "Docker build command executed.\n"; + std::vector options; + options.reserve(containers.size()); + for (const auto& container : containers) { + const std::string state = container.running ? "running" : "stopped"; + const std::string ports = container.ports.empty() ? "none" : container.ports; + options.push_back(container.name + " (" + ShortId(container.id) + ") [" + state + "] ports: " + ports); + } + + OpenSelect(title, + "Select a container with arrows and press Enter.", + std::move(options), + [this, containers, callback = std::move(callback)](bool confirmed, int selected) mutable { + if (!confirmed) { + SetStatus("Action cancelled."); + return; + } + if (selected < 0 || selected >= static_cast(containers.size())) { + SetStatus("Please choose a valid container."); + return; + } + const auto& chosen = containers[static_cast(selected)]; + callback(chosen.id, chosen.name); + }); } -// ---------------- Menu ---------------- +void TuxDockApp::PromptImageSelection(const std::string& title, + std::function callback) { + auto images = docker_.getImageList(); + if (images.empty()) { + SetStatus("No images found."); + return; + } -int main() { - DockerManager docker; - int option; + std::vector options; + options.reserve(images.size()); + for (const auto& image : images) { + options.push_back(image.second + " (" + ShortId(image.first) + ")"); + } - while (true) { - cout << "\nTux-Dock: Docker Management Menu\n" - << "----------------------------------\n" - << "1. Pull Docker Image\n" - << "2. Run/Create Interactive Container\n" - << "3. List All Containers\n" - << "4. List All Images\n" - << "5. Start Container Interactively (boot new session)\n" - << "6. Start Detached Container Session\n" - << "7. Delete Docker Image\n" - << "8. Stop Container\n" - << "9. Remove Container\n" - << "10. Attach Shell to Running Container\n" - << "11. Run Detached Command in Container\n" - << "12. Spin Up MySQL Container\n" - << "13. Get Container IP Address\n" - << "14. Create Dockerfile & Build Image from Bash Script\n" - << "15. Exit\n" - << "----------------------------------\n" - << "Choose an option: "; + OpenSelect(title, + "Select an image with arrows and press Enter.", + std::move(options), + [this, images, callback = std::move(callback)](bool confirmed, int selected) mutable { + if (!confirmed) { + SetStatus("Action cancelled."); + return; + } + if (selected < 0 || selected >= static_cast(images.size())) { + SetStatus("Please choose a valid image."); + return; + } + const auto& chosen = images[static_cast(selected)]; + callback(chosen.first, chosen.second); + }); +} - cin >> option; +void TuxDockApp::ClearTerminal() { + std::cout << "\x1b[2J\x1b[H" << std::flush; +} - switch (option) { - case 1: docker.pullImage(); break; - case 2: docker.runContainerInteractive(); break; - case 3: docker.listContainers(); break; - case 4: docker.listImages(); break; - case 5: docker.startInteractive(); break; - case 6: docker.startDetached(); break; - case 7: docker.deleteImage(); break; - case 8: docker.stopContainer(); break; - case 9: docker.removeContainer(); break; - case 10: docker.execShell(); break; - case 11: docker.execDetachedCommand(); break; - case 12: docker.spinUpMySQL(); break; - case 13: docker.showContainerIP(); break; - case 14: docker.createDockerfile(); break; - case 15: - cout << "Exiting Tux-Dock.\n"; - return 0; - default: - cout << "Invalid option.\n"; - } +void TuxDockApp::RunWithRestoredIO(const std::function& action, + bool clear_before, + bool clear_after) { + if (screen_ != nullptr) { + screen_->WithRestoredIO([&] { + if (clear_before) { + ClearTerminal(); + } + action(); + if (clear_after) { + ClearTerminal(); + } + })(); + return; + } + if (clear_before) { + ClearTerminal(); + } + action(); + if (clear_after) { + ClearTerminal(); } } +void TuxDockApp::ActionPullImage() { + const std::vector quick_images = { + "debian:stable", + "ubuntu:noble", + "rockylinux:9.3", + "alpine:latest", + }; + + std::vector options = quick_images; + options.push_back("Custom image..."); + + OpenSelect("Pull Docker Image", + "Select an image with arrows, then press Enter.", + std::move(options), + [this, quick_images](bool confirmed, int selected) { + if (!confirmed) { + SetStatus("Image pull cancelled."); + return; + } + + if (selected < 0 || selected > static_cast(quick_images.size())) { + SetStatus("Please select a valid image option."); + return; + } + + if (selected < static_cast(quick_images.size())) { + std::string message; + SetStatus("Pulling image..."); + RunWithRestoredIO([this, &message, &quick_images, selected] { + docker_.pullImage(quick_images[static_cast(selected)], message); + }); + SetStatus(message); + return; + } + + OpenInput("Pull Docker Image", + "Enter custom Docker image name:", + [this](bool custom_confirmed, const std::string& image) { + if (!custom_confirmed) { + SetStatus("Image pull cancelled."); + return; + } + if (image.empty()) { + SetStatus("Image name cannot be empty."); + ActionPullImage(); + return; + } + std::string message; + SetStatus("Pulling image..."); + RunWithRestoredIO([this, &message, &image] { docker_.pullImage(image, message); }); + SetStatus(message); + }); + }); +} + +void TuxDockApp::ActionRunContainer() { + auto images = docker_.getImageList(); + std::vector options; + options.reserve(images.size() + 1); + for (const auto& image : images) { + options.push_back(image.second + " (" + ShortId(image.first) + ")"); + } + options.push_back("Custom image..."); + + OpenSelect("Run Interactive Container", + "Select an image with arrows, then press Enter.", + std::move(options), + [this, images](bool confirmed, int selected) { + if (!confirmed) { + SetStatus("Container run cancelled."); + return; + } + + if (selected < 0 || selected > static_cast(images.size())) { + SetStatus("Please select a valid image option."); + return; + } + + auto context = std::make_shared(); + if (selected == static_cast(images.size())) { + OpenInput("Run Interactive Container", + "Enter custom image name:", + [this, context](bool custom_confirmed, const std::string& image_name) { + if (!custom_confirmed) { + SetStatus("Container run cancelled."); + return; + } + if (image_name.empty()) { + SetStatus("Image name cannot be empty."); + ActionRunContainer(); + return; + } + context->image = image_name; + PromptPortCountAndRun(context); + }); + return; + } + + context->image = images[static_cast(selected)].second; + PromptPortCountAndRun(context); + }); +} + +void TuxDockApp::PromptPortCountAndRun(const std::shared_ptr& context) { + OpenInput("Port Mappings", + "How many port mappings? (0 for none)", + [this, context](bool confirmed, const std::string& count_value) { + if (!confirmed) { + SetStatus("Container run cancelled."); + return; + } + if (!IsDigits(count_value)) { + SetStatus("Please enter a valid number."); + PromptPortCountAndRun(context); + return; + } + + context->port_count = std::stoi(count_value); + context->ports.clear(); + if (context->port_count < 0) { + SetStatus("Port mappings cannot be negative."); + PromptPortCountAndRun(context); + return; + } + + PromptNextPort(context, 0); + }); +} + +void TuxDockApp::PromptNextPort(const std::shared_ptr& context, int index) { + if (index >= context->port_count) { + std::string message; + SetStatus("Starting interactive container..."); + RunWithRestoredIO([this, context, &message] { + docker_.runContainerInteractive(context->image, context->ports, message); + }, true, true); + SetStatus(message); + return; + } + + OpenInput("Port Mapping", + "Enter mapping #" + std::to_string(index + 1) + " (example: 8080:80)", + [this, context, index](bool confirmed, const std::string& mapping) { + if (!confirmed) { + SetStatus("Container run cancelled."); + return; + } + if (!IsValidPortMapping(mapping)) { + SetStatus("Use host:container format, for example 8080:80."); + PromptNextPort(context, index); + return; + } + + context->ports.push_back(mapping); + PromptNextPort(context, index + 1); + }); +} + +void TuxDockApp::ActionListContainers() { + auto containers = docker_.getContainerList(); + if (containers.empty()) { + SetStatus("No containers available."); + return; + } + OpenMessage("Containers", FormatContainerList(containers)); + SetStatus("Container list opened."); +} + +void TuxDockApp::ActionListImages() { + auto images = docker_.getImageList(); + if (images.empty()) { + SetStatus("No images found."); + return; + } + OpenMessage("Images", FormatImageList(images)); + SetStatus("Image list opened."); +} + +void TuxDockApp::ActionStartInteractive() { + PromptContainerSelection("Start Interactively", [this](const std::string& id, const std::string&) { + std::string message; + SetStatus("Starting interactive session..."); + RunWithRestoredIO([this, &id, &message] { docker_.startInteractive(id, message); }, true, true); + SetStatus(message); + }); +} + +void TuxDockApp::ActionStartDetached() { + PromptContainerSelection("Start Detached", [this](const std::string& id, const std::string&) { + std::string message; + SetStatus("Starting container in detached mode..."); + RunWithRestoredIO([this, &id, &message] { docker_.startDetached(id, message); }); + SetStatus(message); + }); +} + +void TuxDockApp::ActionDeleteImage() { + PromptImageSelection("Delete Image", [this](const std::string& id, const std::string& tag) { + OpenConfirm("Delete Image", + "Delete image " + tag + "?", + [this, id](bool confirmed) { + if (!confirmed) { + SetStatus("Image deletion cancelled."); + return; + } + std::string message; + SetStatus("Deleting image..."); + RunWithRestoredIO([this, &id, &message] { docker_.deleteImage(id, message); }); + SetStatus(message); + }); + }); +} + +void TuxDockApp::ActionStopContainer() { + PromptContainerSelection("Stop Container", [this](const std::string& id, const std::string&) { + std::string message; + SetStatus("Stopping container..."); + RunWithRestoredIO([this, &id, &message] { docker_.stopContainer(id, message); }); + SetStatus(message); + }); +} + +void TuxDockApp::ActionRemoveContainer() { + PromptContainerSelection("Remove Container", [this](const std::string& id, const std::string& name) { + OpenConfirm("Remove Container", + "Remove container " + name + "?", + [this, id](bool confirmed) { + if (!confirmed) { + SetStatus("Container removal cancelled."); + return; + } + std::string message; + SetStatus("Removing container..."); + RunWithRestoredIO([this, &id, &message] { docker_.removeContainer(id, message); }); + SetStatus(message); + }); + }); +} + +void TuxDockApp::ActionExecShell() { + PromptContainerSelection("Open Shell", [this](const std::string& id, const std::string&) { + std::string message; + SetStatus("Opening shell session..."); + RunWithRestoredIO([this, &id, &message] { docker_.execShell(id, message); }, true, true); + SetStatus(message); + }); +} + +void TuxDockApp::ActionExecDetachedCommand() { + PromptContainerSelection("Run Detached Command", + [this](const std::string& id, const std::string& name) { + OpenInput("Detached Command", + "Enter command to run in " + name + ":", + [this, id](bool confirmed, const std::string& command) { + if (!confirmed) { + SetStatus("Detached command cancelled."); + return; + } + std::string message; + SetStatus("Running detached command..."); + RunWithRestoredIO([this, &id, &command, &message] { + docker_.execDetachedCommand(id, command, message); + }); + SetStatus(message); + }); + }); +} + +void TuxDockApp::ActionSpinUpMySQL() { + auto context = std::make_shared(); + + OpenInput("MySQL Setup", + "Enter port mapping (example: 3306:3306)", + [this, context](bool confirmed, const std::string& port) { + if (!confirmed) { + SetStatus("MySQL setup cancelled."); + return; + } + if (!IsValidPortMapping(port)) { + SetStatus("Use host:container format, for example 3306:3306."); + return; + } + context->port = port; + + OpenInput("MySQL Setup", + "Enter MySQL root password:", + [this, context](bool pwd_confirmed, const std::string& password) { + if (!pwd_confirmed) { + SetStatus("MySQL setup cancelled."); + return; + } + if (password.empty()) { + SetStatus("Password cannot be empty."); + return; + } + context->password = password; + + OpenInput("MySQL Setup", + "Enter MySQL version tag (example: 8)", + [this, context](bool ver_confirmed, const std::string& version) { + if (!ver_confirmed) { + SetStatus("MySQL setup cancelled."); + return; + } + if (version.empty()) { + SetStatus("Version tag cannot be empty."); + return; + } + context->version = version; + + std::string message; + SetStatus("Launching MySQL container..."); + RunWithRestoredIO([this, context, &message] { + docker_.spinUpMySQL(context->port, + context->password, + context->version, + message); + }); + SetStatus(message); + }); + }, + true); + }); +} + +void TuxDockApp::ActionShowContainerIP() { + PromptContainerSelection("Container IP", [this](const std::string& id, const std::string&) { + std::string message; + SetStatus("Looking up container IP..."); + RunWithRestoredIO([this, &id, &message] { docker_.showContainerIP(id, message); }); + SetStatus(message); + }); +} + +void TuxDockApp::ActionCreateDockerfile() { + auto context = std::make_shared(); + + OpenInput("Dockerfile Builder", + "Enter base image (example: ubuntu:22.04)", + [this, context](bool base_confirmed, const std::string& base) { + if (!base_confirmed) { + SetStatus("Dockerfile workflow cancelled."); + return; + } + if (base.empty()) { + SetStatus("Base image cannot be empty."); + return; + } + context->base_image = base; + + OpenInput("Dockerfile Builder", + "Enter bash script path:", + [this, context](bool script_confirmed, const std::string& script_path) { + if (!script_confirmed) { + SetStatus("Dockerfile workflow cancelled."); + return; + } + if (script_path.empty()) { + SetStatus("Script path cannot be empty."); + return; + } + context->script_path = script_path; + + OpenInput("Dockerfile Builder", + "Enter output Dockerfile name (leave empty for Dockerfile)", + [this, context](bool output_confirmed, const std::string& output_file) { + if (!output_confirmed) { + SetStatus("Dockerfile workflow cancelled."); + return; + } + context->output_file = output_file; + + OpenInput("Dockerfile Builder", + "Enter image name to build (leave empty to skip build)", + [this, context](bool image_confirmed, + const std::string& image_name) { + if (!image_confirmed) { + SetStatus("Dockerfile workflow cancelled."); + return; + } + context->image_name = image_name; + + std::string message; + SetStatus("Creating Dockerfile and running build..."); + RunWithRestoredIO([this, context, &message] { + docker_.createDockerfile(context->base_image, + context->script_path, + context->output_file, + context->image_name, + message); + }); + SetStatus(message); + }); + }); + }); + }); +} + +void TuxDockApp::ExecuteSelectedAction() { + switch (menu_selected_) { + case 0: + ActionPullImage(); + break; + case 1: + ActionRunContainer(); + break; + case 2: + ActionListContainers(); + break; + case 3: + ActionListImages(); + break; + case 4: + ActionStartInteractive(); + break; + case 5: + ActionStartDetached(); + break; + case 6: + ActionDeleteImage(); + break; + case 7: + ActionStopContainer(); + break; + case 8: + ActionRemoveContainer(); + break; + case 9: + ActionExecShell(); + break; + case 10: + ActionExecDetachedCommand(); + break; + case 11: + ActionSpinUpMySQL(); + break; + case 12: + ActionShowContainerIP(); + break; + case 13: + ActionCreateDockerfile(); + break; + case 14: + SetStatus("Exiting Tux-Dock."); + if (screen_ != nullptr) { + screen_->ExitLoopClosure()(); + } + break; + default: + SetStatus("Please choose a valid menu option."); + break; + } +} + +bool TuxDockApp::OnEvent(ftxui::Event event) { + if (modal_mode_ == ModalMode::Input) { + if (event == ftxui::Event::Return) { + ResolveInput(true); + return true; + } + if (event == ftxui::Event::Escape) { + ResolveInput(false); + return true; + } + return input_component_->OnEvent(event); + } + + if (modal_mode_ == ModalMode::Confirm) { + if (event == ftxui::Event::Character("y") || event == ftxui::Event::Character("Y") || + event == ftxui::Event::Return) { + ResolveConfirm(true); + return true; + } + if (event == ftxui::Event::Character("n") || event == ftxui::Event::Character("N") || + event == ftxui::Event::Escape) { + ResolveConfirm(false); + return true; + } + return true; + } + + if (modal_mode_ == ModalMode::Select) { + if (event == ftxui::Event::Return) { + ResolveSelect(true); + return true; + } + if (event == ftxui::Event::Escape) { + ResolveSelect(false); + return true; + } + return select_component_->OnEvent(event); + } + + if (modal_mode_ == ModalMode::Message) { + if (event == ftxui::Event::Return || event == ftxui::Event::Escape) { + CloseMessage(); + return true; + } + return true; + } + + if (event == ftxui::Event::Return) { + ExecuteSelectedAction(); + return true; + } + + return false; +} + +ftxui::Element TuxDockApp::RenderModal() const { + using namespace ftxui; + + Element body; + Element footer; + + if (modal_mode_ == ModalMode::Input) { + body = vbox(ftxui::Elements{paragraph(modal_text_), separator(), input_component_->Render() | border}); + footer = text("Enter: confirm Esc: cancel") | dim; + } else if (modal_mode_ == ModalMode::Confirm) { + body = paragraph(modal_text_); + footer = text("Y/Enter: confirm N/Esc: cancel") | dim; + } else if (modal_mode_ == ModalMode::Select) { + body = vbox(ftxui::Elements{paragraph(modal_text_), separator(), select_component_->Render() | frame | vscroll_indicator}); + footer = text("Up/Down: choose Enter: confirm Esc: cancel") | dim; + } else { + body = paragraph(modal_text_) | vscroll_indicator | frame | size(HEIGHT, LESS_THAN, 14); + footer = text("Enter/Esc: close") | dim; + } + + return window(text(modal_title_), vbox(ftxui::Elements{body, separator(), footer})) | + size(WIDTH, GREATER_THAN, 70) | size(HEIGHT, GREATER_THAN, 12) | center; +} + +ftxui::Element TuxDockApp::Render() const { + using namespace ftxui; + + auto actions_panel = window(text("Actions"), + vbox(ftxui::Elements{menu_component_->Render() | frame | vscroll_indicator, + separator(), + text("Up/Down: navigate Enter: select") | dim})) | + size(WIDTH, GREATER_THAN, 48) | flex; + + auto status_panel = window(text("Status"), vbox(ftxui::Elements{paragraph(status_) | yflex, separator(), + text("High-level updates only") | dim})) | + size(WIDTH, GREATER_THAN, 48) | flex; + + Element base = hbox(ftxui::Elements{actions_panel, separator(), status_panel}) | border; + + if (modal_mode_ == ModalMode::None) { + return base; + } + + return dbox({base, RenderModal() | clear_under | center}); +} + +void TuxDockApp::Run() { + auto root = ftxui::Renderer(menu_component_, [this] { return Render(); }); + auto app = ftxui::CatchEvent(root, [this](ftxui::Event event) { return OnEvent(event); }); + + auto screen = ftxui::ScreenInteractive::TerminalOutput(); + screen_ = &screen; + screen.Loop(app); + screen_ = nullptr; +} + +int main() { + TuxDockApp app; + app.Run(); + return 0; +}