commit 1893c7c36b73c8aac4302e8e2865a68d89b7935b Author: David J. Allen Date: Thu Dec 30 12:56:37 2021 -0600 Initial commit First commit with most of the main features implemented. May still need some bug fixes here and there. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d51a9ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +build/** +tests +*/tmp/** +vgcore.* +.cache +.vscode +config.json +*.txt \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..f1efbf2 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,57 @@ +cmake_minimum_required(VERSION 3.12) +project(gdpm LANGUAGES CXX VERSION 0.1.0) + + +# ---- Include guards ---- + +if(PROJECT_SOURCE_DIR STREQUAL PROJECT_BINARY_DIR) + message( + FATAL_ERROR + "In-source builds not allowed. Please make a new directory (called a build directory) and run CMake from there." + ) +endif() + +# Get source files except for main.cpp +file(GLOB SRC CONFIG_DEPENDS "src/[!main]*.cpp") + +# Find all the packages required to build +find_package(Threads REQUIRED) +find_package(RapidJSON CONFIG REQUIRED) +find_package(fmt CONFIG REQUIRED) +find_package(Catch2 CONFIG REQUIRED) +find_package(cxxopts CONFIG REQUIRED) +find_package(Poco CONFIG REQUIRED COMPONENTS Net JSON Util) + +set(CMAKE_CXX_COMPILER "clang++") +set(CMAKE_CXX_FLAGS + "${CMAKE_CXX_FLAGS} -std=c++20 -Ofast -fPIC -fPIE -fpermissive -Wall -Wno-switch -Wno-unused-variable -Wno-sign-conversion -pedantic-errors" +) +set(INCLUDE_DIRS + "include" + ${RAPIDJSON_INCLUDE_DIRS} +) +set(LINK_LIBS + fmt::fmt + Threads::Threads + Catch2::Catch2 + cxxopts::cxxopts + -lcurlpp +) + +# Set library and executable targets +add_library(${PROJECT_NAME}-shared SHARED "${SRC}") +add_library(${PROJECT_NAME}-static STATIC "${SRC}") +add_executable(${PROJECT_NAME} "src/main.cpp") + +# Set include directories for targets +target_include_directories(${PROJECT_NAME} PRIVATE ${INCLUDE_DIRS}) +target_include_directories(${PROJECT_NAME}-shared PRIVATE ${INCLUDE_DIRS}) +target_include_directories(${PROJECT_NAME}-static PRIVATE ${INCLUDE_DIRS}) + +# Set link libraries for targets +target_link_libraries(${PROJECT_NAME} PRIVATE ${PROJECT_NAME}-shared ${LINK_LIBS}) +target_link_libraries(${PROJECT_NAME}-shared PRIVATE ${LINK_LIBS}) +target_link_libraries(${PROJECT_NAME}-static PRIVATE ${LINK_LIBS}) + +# Add project unit tests +add_custom_target("${PROJECT_NAME}-tests" SOURCE ${TESTS}) diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..3955f55 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,8 @@ +The MIT License (MIT) +Copyright © 2021 David J. Allen + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..197e9d6 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# Godot Package Manager (GDPM) + +A front-end, package manager designed to handle assets from the Godot Game Engine's official asset library. It is written in C++ to be lightwight and fast with a few common dependencies found in most Linux distributions and can be used completely independent of Godot. It is designed to add more functionality not included with the AssetLib with the ability to automate builds on different systems. So far, the package manager is capable of searching, downloading, installing and removing packages and makes managing Godot assets across multiple projects much easier. GDPM has not been tested on Windows yet and there are no currently plans to support macOS. + +## Why not just use the Asset Library? + +The AssetLib is a bit limited for my use case. Personally, I wanted a way to quickly download and install all the assets I'd need for a project once and only once. Then, when I'd need that same asset for another project, I could just reuse the ones installed for the previous project. This is where the issues begin. The AssetLib doesn't have a way to reuse asset across multiple projects without having to either redownload the asset again from the AssetLib or copy the assets from somewhere locally. Likewise, this would force game developers to have to copy their assets manually from one project to another and keep track of their assets themselves. + +Some assets are more common such as the "First Person Controller" are likely to be reused with little to no modification. Therefore, it made sense to provide some method to download and install the assets once and then provide a symbolic link back to the stored packages. GDPM attempts to fix this by storing the assets in a single location and creating symlinks/shortcuts to each project. By have a separate, simple binary executable, the package manager can be used in shell scripts to automate tasks and download assets quickly. + +Currently, there is no dependency management as it is not needed. There are future plans to implement a system to handle dependencies, but it is not a priority. + +## Features + +- Download and install packages from Godot's official AssetLib or from custom repositories. (Note: Multithread download support planned.) + +- Stores downloads in single location to use across multiple Godot projects. + +- Copy packages into a Godot project to make modifications when needed. + +- Create package groups to copy a set of packages into a Godot project. + +- Login and register for access to repositories. (Note: Planned. This feature is not officially supported by Godot's official AssetLib.) + +- Handle dependencies between multiple assets. (Note: Planned. This feature is not supported by Godot's official AssetLib.) + +- Support for paid assets. (Note: Planned. This feature is not supported by Godot's official AssetLib repository.) + +## Building from source + +The project uses the CMake or Meson build system and has been tested with GCC and Clang on Arch/Manjaro Linux. Meson is preferred, but a CMakeLists.txt is provided as well. Building on Windows has not been tested yet so it's not guaranteed to work. Compiling with CMake will build an executable, shared, and archive library. Compiling with Meson only builds an executable and shared library. + +### Prerequisites + +- Meson or CMake (version 3.12 or later) + +- C++20 compiler (GCC/Clang/MSVC??) + +- libcurl (or curlpp) + +- libzip + +- RapidJson + +- fmt (may be remove later in favor of C++20 std::format) + +- Git (optional for cloning repository) + +- Catch2 (optional for tests, but still WIP) + +- cxxopts + +- SQLite 3 + +After installing all necessary dependencies, open a terminal and paste the following commands. + +```bash +git clone $(repo_name) +cd $(repo_name) + +# ...if using Meson on Linux +meson build +meson configure build +meson compile -C build -j$(nproc) + +# ... build using Clang++ +CXX=clang++ meson compile -C build -j$(npoc) + +# ... if using CMake on Linux +cd build +cmake .. +make -j$(nproc) + +# ... build using MinGW on Windows ??? +# ... build using MSVC on Windows ??? +``` + +## Usage Examples + +GDPM takes a single command such as install, remove, search, or list followed by a list of package names as the following argument. The command specified is ran then the program exits reporting an errors that may occur. Each command can be modified by specifying additional optional parameters such as '--file' to specify a file to use or '--max-results' to set the number of max results to return from a search. + +```bash +gdpm [COMMAND] [OPTIONS...] +``` + +To see more help information: + +```bash +gdpm help +``` + +Packages can be installed using the "install" command and providing a list of comma-delimited package names with no spaces or by providing a one-package-per-line file using the '--file' option. Using the '-y' option will bypass the user prompt. The '--no-sync' option with bypass syncing the local package database with the AssetLib API and use locally stored information only. See the '--search' command to find a list of available packages. + +```bash +gdpm install "Flappy Godot" "GodotNetworking" -y +gdpm install -f packages.txt --config config.json --no-sync -y +``` + +Packages can be removed similiarly to installing. + +```bash +gdpm remove "Flappy Godot" "GodotNetworking" -y +gdpm remove -f packages.txt +``` + +Packages can be updated like installing or removing packages. However, if no argument is passed, GDPM will prompt the user to update ALL packages to latest instead. + +```bash +gdpm update "GodotNetworking" +gdpm update -f packages.txt +gdpm update # Updates all packages +``` + +To list installed packages, use the '--list' option. This also provides some other extra information like the Godot version and license. + +```bash +gdpm list +``` + +Packages can be linked or copied into a project using the 'link' and 'clone' commands. + +```bash +gdpm link "GodotNetworking" --path tests/test-godot-project +gdpm link -f packages.txt --path tests/test-godot-project +gdpm clone -f packages.txt --path tests/tests-godot-project/addons +``` + +Temporary files downloaded from remote repositories can be cleaned by using the clean command or the '--clean' flag after installing or removing packages. This is recommended if space is limited, but also reduces the number of remote requests needed to rebuild the local package database. + +```bash +gdpm install -f packages.txt --clean -y +gdpm remove -f packages.txt --clean -y +gdpm clean "GodotNetworking" +gdpm clean +``` + +Planned: Add a custom remote AssetLib repository using [this](https://github.com/godotengine/godot-asset-library) API. You can set the priority for each remote repo with the '--set-priority' option or through the 'config.json' file. + +```bash +gdpm add-remote https://custom-assetlib/asset-library/api --set-priority 0 +``` + +Search for available packages from all added repositories. The results can be tweaked using a variety of options like '--sort' or '--support'. See the '--help' command for more details. + +```bash +gdpm search "GodotNetworking" \ + --sort updated \ + --type project \ + --max-results 100 \ + --godot-version 3.4 \ + --verbose \ + --user towk \ + --support official +``` + +To see more logging information, set the '--verbose' flag using an integer value between 0-5. + +```bash +gdpm list --verbose +``` + +## License + +See the LICENSE.md file. diff --git a/bin/gdpm-compile.sh b/bin/gdpm-compile.sh new file mode 100755 index 0000000..6c1d02f --- /dev/null +++ b/bin/gdpm-compile.sh @@ -0,0 +1,3 @@ +# Run this script at project root +meson configure build +CXX=clang++ meson compile -C build -j$(proc) \ No newline at end of file diff --git a/bin/gdpm-lines.sh b/bin/gdpm-lines.sh new file mode 100755 index 0000000..55b4368 --- /dev/null +++ b/bin/gdpm-lines.sh @@ -0,0 +1,2 @@ +# Call this from project root +wc -l include/*.hpp src/*.cpp \ No newline at end of file diff --git a/bin/gdpm-test.sh b/bin/gdpm-test.sh new file mode 100755 index 0000000..c7416bb --- /dev/null +++ b/bin/gdpm-test.sh @@ -0,0 +1,30 @@ +command=build/gdpm +# Install packages using install command and specifying each package name or file +${command} install "ResolutionManagerPlugin" "godot-hmac" +${command} install -f packages.txt --clean --godot-version 3.4 + +# Remove packages using remove command and specifying each package name or file +${command} remove "ResolutionManagerPlugin" "godot-hmac" -y +${command} remove -f packages.txt -y + +# Manually synchronize the local database with remote servers +${command} sync + +# Search for packages containing "Godot" in package name +${command} search "Godot" --config config.json +${command} search "Godot" --config config.json --no-sync + +# List all currently installed packages +${command} list + +# Create a symlink of packages to specified path +${command} link "ResolutionManagerPlugin" "godot-hmac" --path tests/tests-godot-project +${command} link -f packages.txt --path tests/test-godot-project + +# Similarly, make a copy of packages to a project +${command} clone "ResolutionManagerPlugin" "godot-hmac" --path tests/tests/godot-project +${command} clone -f packages.txt --path tests/tests-godot-project/addons + +# Clean temporary downloaded files if they're taking up too much space. If no package names are provided, all temporary files will be removed. +${command} clean "ResolutionManagerPlugin" +${command} clean \ No newline at end of file diff --git a/include/cache.hpp b/include/cache.hpp new file mode 100644 index 0000000..6e55e75 --- /dev/null +++ b/include/cache.hpp @@ -0,0 +1,25 @@ + +#include +#include +#include + +namespace gdpm::package_manager{ + struct package_info; +} + +namespace gdpm::cache{ + using namespace package_manager; + int create_package_database(); + int insert_package_info(const std::vector& package); + std::vector get_package_info_by_id(const std::vector& package_ids); + std::vector get_package_info_by_title(const std::vector& package_titles); + std::vector get_installed_packages(); + int update_package_info(const std::vector& packages); + int update_sync_info(const std::vector& download_urls); + int delete_packages(const std::vector& package_titles); + int delete_packages(const std::vector& package_ids); + int drop_package_database(); + + std::string to_values(const package_info& package); + std::string to_values(const std::vector& packages); +} \ No newline at end of file diff --git a/include/colors.hpp b/include/colors.hpp new file mode 100644 index 0000000..22b3998 --- /dev/null +++ b/include/colors.hpp @@ -0,0 +1,45 @@ + +#if GDPM_ENABLE_COLORS == 1 + #define GDPM_COLOR_BLACK "\033[0;30m" + #define GDPM_COLOR_BLUE "\033[0;34m" + #define GDPM_COLOR_GREEN "\033[0;32m" + #define GDPM_COLOR_CYAN "\033[0;36m" + #define GDPM_COLOR_RED "\033[0;31m" + #define GDPM_COLOR_PURPLE "\033[0;35m" + #define GDPM_COLOR_BROWN "\033[0;33m" + #define GDPM_COLOR_GRAY "\033[0;37m" + #define GDPM_COLOR_DARK_GRAY "\033[0;30m" + #define GDPM_COLOR_LIGHT_BLUE "\033[0;34m" + #define GDPM_COLOR_LIGHT_GREEN "\033[0;32m" + #define GDPM_COLOR_LIGHT_CYAN "\033[0;36m" + #define GDPM_COLOR_LIGHT_RED "\033[0;31m" + #define GDPM_COLOR_LIGHT_PURPLE "\033[0;35m" + #define GDPM_COLOR_YELLOW "\033[0;33m" + #define GDPM_COLOR_WHITE "\033[0;37m" + +#else + #define GDPM_COLOR_BLACK + #define GDPM_COLOR_BLUE + #define GDPM_COLOR_GREEN + #define GDPM_COLOR_CYAN + #define GDPM_COLOR_RED + #define GDPM_COLOR_PURPLE + #define GDPM_COLOR_BROWN + #define GDPM_COLOR_GRAY + #define GDPM_COLOR_DARK_GRAY + #define GDPM_COLOR_LIGHT_BLUE + #define GDPM_COLOR_LIGHT_GREEN + #define GDPM_COLOR_LIGHT_CYAN + #define GDPM_COLOR_LIGHT_RED + #define GDPM_COLOR_LIGHT_PURPLE + #define GDPM_COLOR_YELLOW + #define GDPM_COLOR_WHITE + + +#endif + +#define GDPM_COLOR_LOG_RESET GDPM_COLOR_WHITE +#define GDPM_COLOR_LOG_INFO GDPM_COLOR_LOG_RESET +#define GDPM_COLOR_LOG_ERROR GDPM_COLOR_RED +#define GDPM_COLOR_LOG_DEBUG GDPM_COLOR_YELLOW +#define GDPM_COLOR_LOG_WARNING GDPM_COLOR_YELLOW \ No newline at end of file diff --git a/include/config.hpp b/include/config.hpp new file mode 100644 index 0000000..0024b8a --- /dev/null +++ b/include/config.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "constants.hpp" + +#include +#include +#include + + +namespace gdpm::config{ + struct config_context{ + std::string username; + std::string password; + std::string path; + std::string token; + std::string godot_version; + std::string packages_dir; + std::string tmp_dir; + std::vector remote_sources; + size_t threads; + size_t timeout; + bool enable_sync; + bool enable_file_logging; + int verbose; + }; + std::string to_json(const config_context& params); + config_context load(std::filesystem::path path, int verbose = 0); + int save(const config_context& config, int verbose = 0); + config_context make_config(const std::string& username = GDPM_CONFIG_USERNAME, const std::string& password = GDPM_CONFIG_PASSWORD, const std::string& path = GDPM_CONFIG_PATH, const std::string& token = GDPM_CONFIG_TOKEN, const std::string& godot_version = GDPM_CONFIG_GODOT_VERSION, const std::string& packages_dir = GDPM_CONFIG_LOCAL_PACKAGES_DIR, const std::string& tmp_dir = GDPM_CONFIG_LOCAL_TMP_DIR, const std::vector& remote_sources = {GDPM_CONFIG_REMOTE_SOURCES}, size_t threads = GDPM_CONFIG_THREADS, size_t timeout = 0, bool enable_sync = GDPM_CONFIG_ENABLE_SYNC, bool enable_file_logging = GDPM_CONFIG_ENABLE_FILE_LOGGING, int verbose = GDPM_CONFIG_VERBOSE); + + extern config_context config; +} \ No newline at end of file diff --git a/include/constants.hpp b/include/constants.hpp new file mode 100644 index 0000000..5981eec --- /dev/null +++ b/include/constants.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include + +namespace gdpm::constants{ + constexpr const char *ConfigPath = "config.ini"; + constexpr const char *LocalPackagesDir = "$HOME/.config/gdpm"; + constexpr const char *UserAgent = "libcurl-agent/1.0"; + constexpr const char *AssetRepo = "https://godotengine.org/asset-library/api/asset"; + constexpr const char *HostUrl = "https://godotengine.org/asset-library/api"; + constexpr const char *LockfilePath = "$HOME/.config/gdpm/gdpm.lck"; + constexpr const char *TmpPath = "$HOME/.config/gdpm/tmp"; +} + +/* Defines to set when building with -DGPM_* */ +#define GDPM_CONFIG_USERNAME "" +#define GDPM_CONFIG_PASSWORD "" +#define GDPM_CONFIG_PATH "config.json" +#define GDPM_CONFIG_TOKEN "" +#define GDPM_CONFIG_GODOT_VERSION "3.4" +#define GDPM_CONFIG_LOCAL_PACKAGES_DIR "tests/gdpm/packages" +#define GDPM_CONFIG_LOCAL_TMP_DIR "tests/gdpm/.tmp" +#define GDPM_CONFIG_REMOTE_SOURCES constants::HostUrl +#define GDPM_CONFIG_THREADS 1 +#define GDPM_CONFIG_TIMEOUT_MS 30000 +#define GDPM_CONFIG_ENABLE_SYNC 1 +#define GDPM_CONFIG_ENABLE_FILE_LOGGING 0 +#define GDPM_CONFIG_VERBOSE 0 + +/* Defines package cache for local storage */ +#define GDPM_PACKAGE_CACHE_ENABLE 1 +#define GDPM_PACKAGE_CACHE_PATH "tests/gdpm/packages.db" +#define GDPM_PACKAGE_CACHE_TABLENAME "cache" +#define GDPM_PACKAGE_CACHE_COLNAMES "asset_id, type, title, author, author_id, version, godot_version, cost, description, modify_date, support_level, category, remote_source, download_url, download_hash, is_installed, install_path" + +/* Defines to set default assets API params */ +#define GDPM_DEFAULT_ASSET_TYPE any +#define GDPM_DEFAULT_ASSET_CATEGORY 0 +#define GDPM_DEFAULT_ASSET_SUPPORT all +#define GDPM_DEFAULT_ASSET_FILTER "" +#define GDPM_DEFAULT_ASSET_USER "" +#define GDPM_DEFAULT_ASSET_GODOT_VERSION "" +#define GDPM_DEFAULT_ASSET_MAX_RESULTS 500 +#define GDPM_DEFAULT_ASSET_PAGE 0 +#define GDPM_DEFAULT_ASSET_SORT none +#define GDPM_DEFAULT_ASSET_REVERSE false +#define GDPM_DEFAULT_ASSET_VERBOSE 0 + +/* Define misc. macros */ +#if defined(_WIN32) + #define GDPM_DLL_EXPORT __declspec(dllexport) + #define GDPM_DLL_IMPORT __declspec(dllimport) +#else + #define GDPM_DLL_EXPORT + #define GDPM_DLL_IMPORT +#endif +#define GDPM_READFILE_IMPL 1 +#define GDPM_DELAY_HTTP_REQUESTS 1 + +#ifndef GDPM_REQUEST_DELAY +#define GDPM_REQUEST_DELAY 200ms +#endif + +#ifndef GDPM_ENABLE_COLORS +#define GDPM_ENABLE_COLORS 1 +#endif + +#ifndef GDPM_LOG_LEVEL +#define GDPM_LOG_LEVEL 1 +#endif + +#ifndef GDPM_ENABLE_TIMESTAMPS +#define GDPM_ENABLE_TIMESTAMPS 1 +#endif \ No newline at end of file diff --git a/include/http.hpp b/include/http.hpp new file mode 100644 index 0000000..bdb6732 --- /dev/null +++ b/include/http.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "constants.hpp" + +#include + +namespace gdpm::http{ + struct response{ + long code = 0; + std::string body{}; + std::unordered_map headers{}; + }; + + response request_get(const std::string& url, size_t timeout = GDPM_CONFIG_TIMEOUT_MS); + response request_post(const std::string& url, const char *post_fields="", size_t timeout = GDPM_CONFIG_TIMEOUT_MS); + response download_file(const std::string& url, const std::string& storage_path, size_t timeout = GDPM_CONFIG_TIMEOUT_MS); + +} \ No newline at end of file diff --git a/include/log.hpp b/include/log.hpp new file mode 100644 index 0000000..7ee38b8 --- /dev/null +++ b/include/log.hpp @@ -0,0 +1,81 @@ + +#pragma once + +#include "utils.hpp" +#include "colors.hpp" +#include + +#if __cplusplus > 201703L + // #include +#else +#endif +#include +#include + +namespace gdpm::log +{ + template concept RequireMinArgs = requires (std::size_t min){ sizeof...(Args) > min; }; + + static void vlog(fmt::string_view format, fmt::format_args args){ + fmt::vprint(format, args); + } + + static void vlog(FILE *fp, fmt::string_view format, fmt::format_args args){ + fmt::vprint(fp, format, args); + } + + template + static constexpr void info(const S& format, Args&&...args){ +#if GDPM_LOG_LEVEL > 0 + vlog( + fmt::format(GDPM_COLOR_LOG_INFO "[INFO {}] {}\n" GDPM_COLOR_LOG_RESET, utils::timestamp(), format), + fmt::make_args_checked(format, args...) + ); +#endif + } + + template + static constexpr void info_n(const S& format, Args&&...args){ + vlog( + fmt::format(GDPM_COLOR_LOG_INFO "[INFO {}] {}" GDPM_COLOR_LOG_RESET, utils::timestamp(), format), + fmt::make_args_checked(format, args...) + ); + } + + template + static constexpr void error(const S& format, Args&&...args){ +#if GDPM_LOG_LEVEL > 1 + vlog( + fmt::format(GDPM_COLOR_LOG_ERROR "[ERROR {}] {}\n" GDPM_COLOR_LOG_RESET, utils::timestamp(), format), + fmt::make_args_checked(format, args...) + ); +#endif + } + + template + static constexpr void debug(const S& format, Args&&...args){ +#if GDPM_LOG_LEVEL > 1 + vlog( + fmt::format(GDPM_COLOR_LOG_DEBUG "[DEBUG {}] {}\n" GDPM_COLOR_LOG_RESET, utils::timestamp(), format), + fmt::make_args_checked(format, args...) + ); +#endif + } + + template + static constexpr void print(const S& format, Args&&...args){ + vlog( + fmt::format("{}", format), + fmt::make_args_checked(format, args...) + ); + } + + template + static constexpr void println(const S& format, Args&&...args){ + vlog( + fmt::format("{}\n", format), + fmt::make_args_checked(format, args...) + ); + } + +} \ No newline at end of file diff --git a/include/package_manager.hpp b/include/package_manager.hpp new file mode 100644 index 0000000..862fc7d --- /dev/null +++ b/include/package_manager.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include "config.hpp" +#include +#include +#include +#include +#include + +#include +#include + +namespace gdpm::package_manager{ + extern std::vector repo_sources; + extern CURL *curl; + extern CURLcode res; + extern config::config_context config; + + struct package_info{ + size_t asset_id; + std::string type; + std::string title; + std::string author; + size_t author_id; + std::string version; + std::string godot_version; + std::string cost; + std::string description; + std::string modify_date; + std::string support_level; + std::string category; + std::string remote_source; + std::string download_url; + std::string download_hash; + bool is_installed; + std::string install_path; + }; + + struct cxxargs{ + cxxopts::ParseResult result; + cxxopts::Options options; + }; + + enum command_e{ + install, + remove, + update, + search, + list, + link, + clone, + clean, + sync, + add_remote, + delete_remote, + help, + none + }; + + GDPM_DLL_EXPORT int initialize(int argc, char **argv); + GDPM_DLL_EXPORT int execute(); + GDPM_DLL_EXPORT void finalize(); + GDPM_DLL_EXPORT void install_packages(const std::vector& package_titles); + GDPM_DLL_EXPORT void remove_packages(const std::vector& package_titles); + GDPM_DLL_EXPORT void update_packages(const std::vector& package_titles); + GDPM_DLL_EXPORT void search_for_packages(const std::vector& package_titles); + GDPM_DLL_EXPORT void list_installed_packages(); + GDPM_DLL_EXPORT void read_package_contents(const std::string& package_title); + GDPM_DLL_EXPORT void clean_temporary(const std::vector& package_titles); + GDPM_DLL_EXPORT void link_packages(const std::vector& package_titles, const std::vector& paths); + GDPM_DLL_EXPORT void clone_packages(const std::vector& package_titles, const std::vector& paths); + GDPM_DLL_EXPORT void add_remote_repository(const std::string& repository, ssize_t offset = -1); + GDPM_DLL_EXPORT void delete_remote_repository(const std::string& repository); + GDPM_DLL_EXPORT void delete_remote_repository(ssize_t index); + + GDPM_DLL_EXPORT cxxargs parse_arguments(int argc, char **argv); + GDPM_DLL_EXPORT void handle_arguments(const cxxargs& args); + GDPM_DLL_EXPORT void run_command(command_e command, const std::vector& package_titles, const std::vector& opts); + GDPM_DLL_EXPORT void print_package_list(const rapidjson::Document& json); + GDPM_DLL_EXPORT void print_package_list(const std::vector& packages); + + GDPM_DLL_EXPORT std::vector synchronize_database(const std::vector& package_titles); +} \ No newline at end of file diff --git a/include/plugin.hpp b/include/plugin.hpp new file mode 100644 index 0000000..b57d56a --- /dev/null +++ b/include/plugin.hpp @@ -0,0 +1,16 @@ + + +#include + +namespace towk::plugin{ + struct info{ + std::string name; + std::string description; + std::string version; + }; + extern int init(int argc, char **argv); + extern int set_name(const char *name); + extern int set_description(const char *description); + extern int set_version(const char *version); + extern int finalize(); +} \ No newline at end of file diff --git a/include/progress_bar.hpp b/include/progress_bar.hpp new file mode 100644 index 0000000..0212d65 --- /dev/null +++ b/include/progress_bar.hpp @@ -0,0 +1,59 @@ +#include +#include +#include +#include + +class progress_bar +{ + static const auto overhead = sizeof " [100%]"; + + std::ostream& os; + const std::size_t bar_width; + std::string message; + const std::string full_bar; + + public: + progress_bar(std::ostream& os, std::size_t line_width, + std::string message_, const char symbol = '.') + : os{os}, + bar_width{line_width - overhead}, + message{std::move(message_)}, + full_bar{std::string(bar_width, symbol) + std::string(bar_width, ' ')} + { + if (message.size()+1 >= bar_width || message.find('\n') != message.npos) { + os << message << '\n'; + message.clear(); + } else { + message += ' '; + } + write(0.0); + } + + // not copyable + progress_bar(const progress_bar&) = delete; + progress_bar& operator=(const progress_bar&) = delete; + + ~progress_bar() + { + write(1.0); + os << '\n'; + } + + void write(double fraction); +}; + +void progress_bar::write(double fraction) +{ + // clamp fraction to valid range [0,1] + if (fraction < 0) + fraction = 0; + else if (fraction > 1) + fraction = 1; + + auto width = bar_width - message.size(); + auto offset = bar_width - static_cast(width * fraction); + + os << '\r' << message; + os.write(full_bar.data() + offset, width); + os << " [" << std::setw(3) << static_cast(100*fraction) << "%] " << std::flush; +} \ No newline at end of file diff --git a/include/rest_api.hpp b/include/rest_api.hpp new file mode 100644 index 0000000..6db1bda --- /dev/null +++ b/include/rest_api.hpp @@ -0,0 +1,88 @@ + +#include "constants.hpp" +#include +#include +#include +#include + +namespace gdpm::rest_api{ + // See GitHub reference: https://github.com/godotengine/godot-asset-library/blob/master/API.md + namespace endpoints{ + constexpr const char *POST_Register = "/register"; + constexpr const char *POST_Login = "/login"; + constexpr const char *POST_Logout = "/logout"; + constexpr const char *POST_ChangePassword = "/change_password"; + constexpr const char *GET_Configure = "/configure"; + constexpr const char *GET_Asset_NoParams = "/asset"; + constexpr const char *GET_Asset = "/asset?"; + constexpr const char *GET_AssetId = "/asset/{id}"; // ...find_replace + constexpr const char *POST_AssetIdDelete = "/asset/{id}/delete"; + constexpr const char *POST_AssetIdUndelete = "/asset/{id}/delete"; + constexpr const char *POST_AssetSupportLevel = "/asset/{id}/support_level"; + constexpr const char *POST_Asset = "/asset"; + constexpr const char *POST_AssetId = "/asset/{id}"; + constexpr const char *POST_AssetEditId = "/asset/edit/{id}"; + } + + bool register_account(const std::string& username, const std::string& password, const std::string& email); + bool login(const std::string& username, const std::string& password); + bool logout(); + // bool change_password() + + enum type_e { any, addon, project }; + enum support_e { all, official, community, testing }; + enum sort_e { none, rating, cost, name, updated }; + + struct asset_list_context{ + type_e type; + int category; + support_e support; + std::string filter; + std::string user; + std::string godot_version; + int max_results; + int page; + sort_e sort; + bool reverse; + int verbose; + }; + + asset_list_context make_context(type_e type = GDPM_DEFAULT_ASSET_TYPE, int category = GDPM_DEFAULT_ASSET_CATEGORY, support_e support = GDPM_DEFAULT_ASSET_SUPPORT, const std::string& filter = GDPM_DEFAULT_ASSET_FILTER, const std::string& user = GDPM_DEFAULT_ASSET_USER, const std::string& godot_version = GDPM_DEFAULT_ASSET_GODOT_VERSION, int max_results = GDPM_DEFAULT_ASSET_MAX_RESULTS, int page = GDPM_DEFAULT_ASSET_PAGE, sort_e sort = GDPM_DEFAULT_ASSET_SORT, bool reverse = GDPM_DEFAULT_ASSET_REVERSE, int verbose = GDPM_DEFAULT_ASSET_VERBOSE); + + rapidjson::Document _parse_json(const std::string& r, int verbose = 0); + std::string _get_type_string(type_e type); + std::string _get_support_string(support_e support); + std::string _get_sort_string(sort_e sort); + void _print_params(const asset_list_context& params); + + bool register_account(const std::string& username, const std::string& password, const std::string& email); + bool login(const std::string& username, const std::string& password); + bool logout(); + + rapidjson::Document configure(const std::string& url = constants::HostUrl, type_e type = any, int verbose = 0); + rapidjson::Document get_assets_list(const std::string& url = constants::HostUrl, type_e type = GDPM_DEFAULT_ASSET_TYPE, int category = GDPM_DEFAULT_ASSET_CATEGORY, support_e support = GDPM_DEFAULT_ASSET_SUPPORT, const std::string& filter = GDPM_DEFAULT_ASSET_FILTER, const std::string& user = GDPM_DEFAULT_ASSET_USER, const std::string& godot_version = GDPM_DEFAULT_ASSET_GODOT_VERSION, int max_results = GDPM_DEFAULT_ASSET_MAX_RESULTS, int page = GDPM_DEFAULT_ASSET_PAGE, sort_e sort = GDPM_DEFAULT_ASSET_SORT, bool reverse = GDPM_DEFAULT_ASSET_REVERSE, int verbose = GDPM_DEFAULT_ASSET_VERBOSE); + rapidjson::Document get_assets_list(const std::string& url, const asset_list_context& params); + rapidjson::Document get_asset(const std::string& url, int asset_id, int verbose = GDPM_DEFAULT_ASSET_VERBOSE); + bool delete_asset(int asset_id); // ...for moderators + bool undelete_asset(int asset_id); // ...for moderators + bool set_support_level(int asset_id); // ...for moderators + + /* + POST /asset + POST /asset/{id} + POST /asset/edit/{id} + */ + void edit_asset(/* need input parameters... */); + + /* GET /asset/{id} */ + void get_asset_edit(int asset_id); + + /* POST /asset/edit/{id}/review */ + std::string review_asset_edit(int asset_id); + + /* POST /asset/edit/{id}/accept */ + std::string accept_asset_edit(int asset_id); // ...for moderators + + /* POST /asset/edit/{id}/reject */ + std::string reject_asset_edit(int asset_id); +} \ No newline at end of file diff --git a/include/utils.hpp b/include/utils.hpp new file mode 100644 index 0000000..301ee7e --- /dev/null +++ b/include/utils.hpp @@ -0,0 +1,104 @@ +#pragma once + +#include "constants.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace gdpm::utils{ + + using namespace std::chrono_literals; + + struct memory_buffer{ + char *addr = nullptr; + size_t size = 0; + }; + + static memory_buffer make_buffer(){ + return memory_buffer{ + .addr = (char*)malloc(1), /* ...will grow as needed in curl_write_to_stream */ + .size = 0 + }; + } + + static void free_buffer(memory_buffer& buf){ + free(buf.addr); + } + + static size_t curl_write_to_buffer(char *contents, size_t size, size_t nmemb, void *userdata){ + + size_t realsize = size * nmemb; + struct memory_buffer *m = (struct memory_buffer*)userdata; + + m->addr = (char*)realloc(m->addr, m->size + realsize + 1); + if(m->addr == nullptr){ + /* Out of memory */ + fprintf(stderr, "Not enough memory (realloc returned NULL)\n"); + return 0; + } + + memcpy(&(m->addr[m->size]), contents, realsize); + m->size += realsize; + m->addr[m->size] = 0; + + return realsize; + } + + static size_t curl_write_to_stream(char *ptr, size_t size, size_t nmemb, void *userdata){ + if(nmemb == 0) + return 0; + return fwrite(ptr, size, nmemb, (FILE*)userdata); + } + + static inline auto timestamp(const std::string& format = ":%I:%M:%S %p; %Y-%m-%d"){ + time_t t = std::time(nullptr); +#if GDPM_ENABLE_TIMESTAMPS == 1 + return fmt::format(fmt::runtime("{"+format+"}"), fmt::localtime(t)); +#else + return ""; +#endif + // return fmt::format(format, std::chrono::system_clock::now()); + } + + template + void push_back(std::vector& v, Args&&...args) + { + static_assert((std::is_constructible_v&& ...)); + (v.emplace_back(std::forward(args)), ...); + } + + // A make_tuple wrapper for enforcing certain requirements + template + auto range(Args...args) + { + // Limit number of args to only 2 + static_assert(sizeof...(Args) != 1, "Ranges requires only 2 arguments"); + return std::make_tuple(std::forward(args)...); + } + template + void move_if_not(std::vector& from, std::vector& to, F pred){ + auto part = std::partition(from.begin(), from.end(), pred); + std::move(part, from.end(), std::back_inserter(from)); + from.erase(part); + } + + std::string readfile(const std::string& path); + void to_lower(std::string& s); + std::vector parse_lines(const std::string& s); + std::string replace_first(std::string& s, const std::string& from, const std::string& to); + std::string replace_all(std::string& s, const std::string& from, const std::string& to); + int extract_zip(const char *archive, const char *dest, int verbose = 0); + std::string prompt_user(const char *message); + bool prompt_user_yn(const char *message); + void delay(std::chrono::milliseconds milliseconds = GDPM_REQUEST_DELAY); +} \ No newline at end of file diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..9c2142c --- /dev/null +++ b/meson.build @@ -0,0 +1,66 @@ + +project( + 'gdpm', + 'cpp', + default_options: ['cpp_std=c++20', 'build.cpp_std=c++20'] +) +deps = [ + dependency('Threads'), + dependency('RapidJSON'), + dependency('fmt'), + dependency('Catch2'), + dependency('cxxopts'), + # dependency('curl'), + dependency('curlpp'), + dependency('libzip'), + dependency('sqlite3') +] +includes = include_directories('include') +src = [ + 'src/config.cpp', + 'src/package_manager.cpp', + 'src/rest_api.cpp', + 'src/utils.cpp', + 'src/http.cpp', + 'src/cache.cpp' +] +# cpp = meson.get_compiler('cpp') +# cpp.find_library() +cpp_args = [ + '-Ofast', + # '-fPIC', + # '-fPIE', + # '-fconcepts', + # '-fsanitize=address', + '-fpermissive', + '-Wall', + '-Wno-switch', + '-Wno-unused-variable', + '-Wno-sign-conversion', + '-Wno-unused-function', + '-pedantic-errors', + # '-lcurl', + # '-lzip' + '-DGDPM_LOG_LEVEL=2', + '-DGDPM_REQUEST_DELAY=200ms', + '-DGDPM_ENABLE_COLORS=1', + '-DGDPM_ENABLE_TIMESTAMPS=1', +] +lib = shared_library( + meson.project_name(), + src, + dependencies: deps, + version: '1.0', + soversion: '0', + include_directories: includes, + cpp_args: cpp_args +) +exe = executable( + meson.project_name(), + 'src/main.cpp', + dependencies: deps, + include_directories: includes, + link_with: lib, + cpp_args: cpp_args +) +test('unittests', exe) \ No newline at end of file diff --git a/src/cache.cpp b/src/cache.cpp new file mode 100644 index 0000000..712ecbc --- /dev/null +++ b/src/cache.cpp @@ -0,0 +1,416 @@ + +#include "cache.hpp" +#include "log.hpp" +#include "constants.hpp" +#include "package_manager.hpp" +#include "utils.hpp" +#include + + +namespace gdpm::cache{ + int create_package_database(){ + sqlite3 *db; + sqlite3_stmt *res; + char *errmsg; + + int rc = sqlite3_open(GDPM_PACKAGE_CACHE_PATH, &db); + if(rc != SQLITE_OK){ + log::error("create_package_database.sqlite3_open(): {}", sqlite3_errmsg(db)); + sqlite3_close(db); + return rc; + } + + constexpr const char *sql = "CREATE TABLE IF NOT EXISTS " + GDPM_PACKAGE_CACHE_TABLENAME "(" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "asset_id INT NOT NULL," + "type INT NOT NULL," + "title TEXT NOT NULL," + "author TEXT NOT NULL," + "author_id INT NOT NULL," + "version TEXT NOT NULL," + "godot_version TEXT NOT NULL," + "cost TEXT NOT NULL," + "description TEXT NOT NULL," + "modify_date TEXT NOT NULL," + "support_level TEXT NOT NULL," + "category TEXT NOT NULL," + "remote_source TEXT NOT NULL," + "download_url TEXT NOT NULL," + "download_hash TEXT NOT NULL," + "is_installed TEXT NOT NULL," + "install_path TEXT NOT NULL);"; + + // rc = sqlite3_prepare_v2(db, "SELECT", -1, &res, 0); + rc = sqlite3_exec(db, sql, nullptr, nullptr, &errmsg); + if(rc != SQLITE_OK){ + // log::error("Failed to fetch data: {}\n", sqlite3_errmsg(db)); + log::error("create_package_database.sqlite3_exec(): {}", errmsg); + sqlite3_free(errmsg); + sqlite3_close(db); + return rc; + } + sqlite3_close(db); + return 0; + } + + + int insert_package_info(const std::vector& packages){ + sqlite3 *db; + sqlite3_stmt *res; + char *errmsg = nullptr; + + /* Prepare values to use in sql statement */ + std::string sql{"BEGIN TRANSACTION; "}; + for(const auto& p : packages){ + sql += "INSERT INTO " GDPM_PACKAGE_CACHE_TABLENAME " (" GDPM_PACKAGE_CACHE_COLNAMES ") "; + sql += "VALUES (" + to_values(p) + "); "; + } + sql += "COMMIT;"; + // log::println("{}", sql); + int rc = sqlite3_open(GDPM_PACKAGE_CACHE_PATH, &db); + if(rc != SQLITE_OK){ + log::error("insert_package_info.sqlite3_open(): {}", sqlite3_errmsg(db)); + sqlite3_close(db); + return rc; + } + rc = sqlite3_exec(db, sql.c_str(), nullptr, nullptr, &errmsg); + if(rc != SQLITE_OK){ + log::error("insert_package_info.sqlite3_exec(): {}", errmsg); + sqlite3_free(errmsg); + sqlite3_close(db); + return rc; + } + sqlite3_close(db); + return 0; + } + + + std::vector get_package_info_by_id(const std::vector& package_ids){ + sqlite3 *db; + sqlite3_stmt *res; + char *errmsg = nullptr; + size_t p_size = 0; + std::vector p_vector; + std::string sql{"BEGIN TRANSACTION;\n"}; + + auto callback = [](void *data, int argc, char **argv, char **colnames){ + // log::error("{}", (const char*)data); + // p_data *_data = (p_data*)data; + std::vector *_p_vector = (std::vector*) data; + package_info p{ + .asset_id = std::stoul(argv[1]), + .type = argv[2], + .title = argv[3], + .author = argv[4], + .author_id = std::stoul(argv[5]), + .version = argv[6], + .godot_version = argv[7], + .cost = argv[8], + .description = argv[9], + .modify_date = argv[10], + .support_level = argv[11], + .category = argv[12], + .remote_source = argv[13], + .download_url = argv[14], + .download_hash = argv[15], + .is_installed = static_cast(std::stoi(argv[16])), + .install_path = argv[17] + }; + _p_vector->emplace_back(p); + return 0; + }; + + int rc = sqlite3_open(GDPM_PACKAGE_CACHE_PATH, &db); + if(rc != SQLITE_OK){ + log::error("get_package_info_by_id.sqlite3_open(): {}", sqlite3_errmsg(db)); + sqlite3_close(db); + return {}; + } + + for(const auto& p_id : package_ids){ + sql += "SELECT * FROM " GDPM_PACKAGE_CACHE_TABLENAME " WHERE asset_id=" + fmt::to_string(p_id)+ ";\n"; + } + sql += "COMMIT;\n"; + rc = sqlite3_exec(db, sql.c_str(), callback, (void*)&p_vector, &errmsg); + if(rc != SQLITE_OK){ + log::error("get_package_info_by_id.sqlite3_exec(): {}", errmsg); + sqlite3_free(errmsg); + sqlite3_close(db); + return {}; + } + sqlite3_close(db); + return p_vector; + } + + + std::vector get_package_info_by_title(const std::vector& package_titles){ + sqlite3 *db; + sqlite3_stmt *res; + char *errmsg = nullptr; + std::vector p_vector; + + auto callback = [](void *data, int argc, char **argv, char **colnames){ + if(argc <= 0) + return 1; + std::vector *_p_vector = (std::vector*)data; + // log::println("get_package_info_by_title.callback.argv: \n\t{}\n\t{}\n\t{}\n\t{}\n\t{}", argv[0], argv[1], argv[2],argv[3], argv[4]); + package_info p{ + .asset_id = std::stoul(argv[1]), + .type = argv[2], + .title = argv[3], + .author = argv[4], + .author_id = std::stoul(argv[5]), + .version = argv[6], + .godot_version = argv[7], + .cost = argv[8], + .description = argv[9], + .modify_date = argv[10], + .support_level = argv[11], + .category = argv[12], + .remote_source = argv[13], + .download_url = argv[14], + .download_hash = argv[15], + .is_installed = static_cast(std::stoi(argv[16])), + .install_path = argv[17] + }; + _p_vector->emplace_back(p); + return 0; + }; + + int rc = sqlite3_open(GDPM_PACKAGE_CACHE_PATH, &db); + if(rc != SQLITE_OK){ + log::error("get_package_info_by_title.sqlite3_open(): {}", sqlite3_errmsg(db)); + sqlite3_close(db); + return {}; + } + + std::string sql{"BEGIN TRANSACTION;"}; + for(const auto& p_title : package_titles){ + sql += "SELECT * FROM " GDPM_PACKAGE_CACHE_TABLENAME " WHERE title='" + p_title + "';"; + } + sql += "COMMIT;"; + // log::println(sql); + rc = sqlite3_exec(db, sql.c_str(), callback, (void*)&p_vector, &errmsg); + if(rc != SQLITE_OK){ + log::error("get_package_info_by_title.sqlite3_exec(): {}", errmsg); + sqlite3_free(errmsg); + sqlite3_close(db); + return {}; + } + sqlite3_close(db); + return p_vector; + } + + + std::vector get_installed_packages(){ + sqlite3 *db; + sqlite3_stmt *res; + char *errmsg = nullptr; + std::vector p_vector; + std::string sql{"BEGIN TRANSACTION;"}; + + auto callback = [](void *data, int argc, char **argv, char **colnames){ + std::vector *_p_vector = (std::vector*) data; + package_info p{ + .asset_id = std::stoul(argv[1]), + .type = argv[2], + .title = argv[3], + .author = argv[4], + .author_id = std::stoul(argv[5]), + .version = argv[6], + .godot_version = argv[7], + .cost = argv[8], + .description = argv[9], + .modify_date = argv[10], + .support_level = argv[11], + .category = argv[12], + .remote_source = argv[13], + .download_url = argv[14], + .download_hash = argv[15], + .is_installed = static_cast(std::stoi(argv[16])), + .install_path = argv[17] + }; + _p_vector->emplace_back(p); + return 0; + }; + + int rc = sqlite3_open(GDPM_PACKAGE_CACHE_PATH, &db); + if(rc != SQLITE_OK){ + log::error("get_installed_packages.sqlite3_open(): {}", sqlite3_errmsg(db)); + sqlite3_close(db); + return {}; + } + + sql += "SELECT * FROM " GDPM_PACKAGE_CACHE_TABLENAME " WHERE is_installed=1; COMMIT;"; + rc = sqlite3_exec(db, sql.c_str(), callback, (void*)&p_vector, &errmsg); + if(rc != SQLITE_OK){ + log::error("get_installed_packages.sqlite3_exec(): {}", errmsg); + sqlite3_free(errmsg); + sqlite3_close(db); + return {}; + } + sqlite3_close(db); + return p_vector; + } + + + int update_package_info(const std::vector& packages){ + sqlite3 *db; + sqlite3_stmt *res; + char *errmsg = nullptr; + + int rc = sqlite3_open(GDPM_PACKAGE_CACHE_PATH, &db); + if(rc != SQLITE_OK){ + log::error("update_package_info.sqlite3_open(): {}", sqlite3_errmsg(db)); + sqlite3_close(db); + return rc; + } + + std::string sql; + for(const auto& p : packages){ + sql += "UPDATE " GDPM_PACKAGE_CACHE_TABLENAME " SET " + " asset_id=" + fmt::to_string(p.asset_id) + ", " + " type='" + p.type + "', " + " title='" + p.title + "', " + " author='" + p.author + "', " + + " author_id=" + fmt::to_string(p.author_id) + ", " + " version='" + p.version + "', " + + " godot_version='" + p.godot_version + "', " + + " cost='" + p.cost + "', " + + " description='" + p.description + "', " + + " modify_date='" + p.modify_date + "', " + + " support_level='" + p.support_level + "', " + + " category='" + p.category + "', " + + " remote_source='" + p.remote_source + "', " + + " download_url='" + p.download_url + "', " + + " download_hash='" + p.download_hash + "', " + + " is_installed=" + fmt::to_string(p.is_installed) + ", " + " install_path='" + p.install_path + "'" + " WHERE title='" + p.title + "' AND asset_id=" + fmt::to_string(p.asset_id) + + ";\n"; + } + rc = sqlite3_exec(db, sql.c_str(), nullptr, nullptr, &errmsg); + if(rc != SQLITE_OK){ + log::error("update_package_info.sqlite3_exec(): {}", errmsg); + sqlite3_free(errmsg); + sqlite3_close(db); + return rc; + } + sqlite3_close(db); + return 0; + } + + + int delete_packages(const std::vector& package_titles){ + sqlite3 *db; + sqlite3_stmt *res; + char *errmsg = nullptr; + std::string sql; + + int rc = sqlite3_open(GDPM_PACKAGE_CACHE_PATH, &db); + if(rc != SQLITE_OK){ + log::error("delete_packages.sqlite3_open(): {}", sqlite3_errmsg(db)); + sqlite3_close(db); + return rc; + } + + for(const auto& p_title : package_titles){ + sql += "DELETE FROM " GDPM_PACKAGE_CACHE_PATH " WHERE title='" + + p_title + "';\n"; + } + rc = sqlite3_exec(db, sql.c_str(), nullptr, nullptr, &errmsg); + if(rc != SQLITE_OK){ + log::error("delete_packages.sqlite3_exec(): {}", errmsg); + sqlite3_free(errmsg); + sqlite3_close(db); + return rc; + } + sqlite3_close(db); + return 0; + } + + + int delete_packages(const std::vector& package_ids){ + sqlite3 *db; + sqlite3_stmt *res; + char *errmsg = nullptr; + std::string sql; + + int rc = sqlite3_open(GDPM_PACKAGE_CACHE_PATH, &db); + if(rc != SQLITE_OK){ + log::error("delete_packages.sqlite3_open(): {}", errmsg); + sqlite3_close(db); + return rc; + } + + for(const auto& p_id : package_ids){ + sql += "DELETE FROM " GDPM_PACKAGE_CACHE_PATH " WHERE asset_id=" + + fmt::to_string(p_id) + ";\n"; + } + rc = sqlite3_exec(db, sql.c_str(), nullptr, nullptr, &errmsg); + if(rc != SQLITE_OK){ + log::error("delete_packages.sqlite3_exec(): {}", errmsg); + sqlite3_free(errmsg); + sqlite3_close(db); + return rc; + } + sqlite3_close(db); + return 0; + } + + + int drop_package_database(){ + sqlite3 *db; + sqlite3_stmt *res; + char *errmsg = nullptr; + std::string sql{"DROP TABLE IF EXISTS " GDPM_PACKAGE_CACHE_TABLENAME ";\n"}; + + int rc = sqlite3_open(GDPM_PACKAGE_CACHE_PATH, &db); + if(rc != SQLITE_OK){ + log::error("drop_package_database.sqlite3_open(): {}", sqlite3_errmsg(db)); + sqlite3_close(db); + return rc; + } + + rc = sqlite3_exec(db, sql.c_str(), nullptr, nullptr, &errmsg); + if(rc != SQLITE_OK){ + log::error("drop_package_database.sqlite3_exec(): {}", errmsg); + sqlite3_free(errmsg); + sqlite3_close(db); + return rc; + } + + return 0; + } + + std::string to_values(const package_info& p){ + std::string p_values{}; + std::string p_title = p.title; /* need copy for utils::replace_all() */ + p_values += fmt::to_string(p.asset_id) + ", "; + p_values += "'" + p.type + "', "; + p_values += "'" + utils::replace_all(p_title, "'", "''") + "', "; + p_values += "'" + p.author + "', "; + p_values += fmt::to_string(p.author_id) + ", "; + p_values += "'" + p.version + "', "; + p_values += "'" + p.godot_version + "', "; + p_values += "'" + p.cost + "', "; + p_values += "'" + p.description + "', "; + p_values += "'" + p.modify_date + "', "; + p_values += "'" + p.support_level + "', "; + p_values += "'" + p.category + "', "; + p_values += "'" + p.remote_source + "', "; + p_values += "'" + p.download_url + "', "; + p_values += "'" + p.download_hash + "', "; + p_values += fmt::to_string(p.is_installed) + ", "; + p_values += "'" + p.install_path + "'"; + return p_values; + } + + std::string to_values(const std::vector& packages){ + std::string o; + for(const auto& p : packages) + o += to_values(p); + return o; + } +} \ No newline at end of file diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..21b2311 --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,179 @@ +#include "config.hpp" +#include "log.hpp" +#include "utils.hpp" +#include "constants.hpp" + +// RapidJSON +#include +#include +#include +#include +#include +#include +#include + + +// fmt +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +namespace gdpm::config{ + config_context config; + std::string to_json(const config_context& params){ + auto _build_json_array = [](std::vector a){ + std::string o{"["}; + for(const std::string& src : a) + o += "\"" + src + "\","; + if(o.back() == ',') + o.pop_back(); + o += "]"; + return o; + }; + + /* Build a JSON string to pass to document */ + std::string json{ + "{\"username\":\"" + params.username + "\"," + + "\"password\":\"" + params.password + "\"," + + "\"path\":\"" + params.path + "\"," + + "\"token\":\"" + params.token + "\"," + + "\"godot_version\":\"" + params.godot_version + "\"," + + "\"packages_dir\":\"" + params.packages_dir + "\"," + + "\"tmp_dir\":\"" + params.tmp_dir + "\"," + + "\"remote_sources\":" + _build_json_array(params.remote_sources) + "," + + "\"threads\":" + fmt::to_string(params.threads) + "," + + "\"timeout\":" + fmt::to_string(params.timeout) + "," + + "\"enable_sync\":" + fmt::to_string(params.enable_sync) + "," + + "\"enable_file_logging\":" + fmt::to_string(params.enable_file_logging) + + "}" + }; + return json; + } + + config_context load(std::filesystem::path path, int verbose){ + std::fstream file; + file.open(path, std::ios::in); + if(!file){ + log::error("Could not open file"); + return config; + } + else if(file.is_open()){ + /* + * See RapidJson docs: + * + * https://rapidjson.org/md_doc_tutorial.html + */ + using namespace rapidjson; + + /* Read JSON fro config, parse, and check document. Must make sure that program does not crash here and use default config instead! */ + std::string contents, line; + while(std::getline(file, line)) + contents += line + "\n"; + + if(verbose > 0) + log::info("Load config...\n{}", contents.c_str()); + + Document doc; + ParseErrorCode status = doc.Parse(contents.c_str()).GetParseError(); + + if(!doc.IsObject()){ + log::error("Could not load config file."); + return config; + } + + assert(doc.IsObject()); + assert(doc.HasMember("remote_sources")); + assert(doc["remote_sources"].IsArray()); + + /* Make sure contents were read correctly. */ + // if(!status){ + // log::error("config::load: Could not parse contents of file (Error: {}/{}).", GetParseError_En(status), doc.GetErrorOffset()); + + // return config_context(); + // } + + /* Must check if keys exists first, then populate _config_params. */ + if(doc.HasMember("remote_sources")){ + if(doc["remote_sources"].IsArray()){ + const Value& srcs = doc["remote_sources"]; + for(auto& src : srcs.GetArray()){ + config.remote_sources.emplace_back(src.GetString()); + } + } else{ + log::error("Malformed sources found."); + } + } + auto _get_value_string = [](Document& doc, const char *property){ + if(doc.HasMember(property)) + if(doc[property].IsString()) + return doc[property].GetString(); + return ""; + }; + auto _get_value_int = [](Document& doc, const char *property){ + if(doc.HasMember(property)) + if(doc[property].IsInt()) + return doc[property].GetInt(); + return 0; + }; + + config.username = _get_value_string(doc, "username"); + config.password = _get_value_string(doc, "password"); + config.path = _get_value_string(doc, "path"); + config.token = _get_value_string(doc, "token"); + config.godot_version = _get_value_string(doc, "godot_version"); + config.packages_dir = _get_value_string(doc, "packages_dir"); + config.tmp_dir = _get_value_string(doc, "tmp_dir"); + config.threads = _get_value_int(doc, "threads"); + config.enable_sync = _get_value_int(doc, "enable_sync"); + config.enable_file_logging = _get_value_int(doc, "enable_file_logging"); + } + return config; + } + + int save(const config_context& config, int verbose){ + using namespace rapidjson; + + /* Build a JSON string to pass to document */ + std::string json = to_json(config); + if(verbose > 0) + log::info("Save config...\n{}", json.c_str()); + + /* Dump JSON config to file */ + Document doc; + doc.Parse(json.c_str()); + std::ofstream ofs(config.path); + OStreamWrapper osw(ofs); + + PrettyWriter writer(osw); + doc.Accept(writer); + + return 0; + } + + config_context make_config(const std::string& username, const std::string& password, const std::string& path, const std::string& token, const std::string& godot_version, const std::string& packages_dir, const std::string& tmp_dir, const std::vector& remote_sources, size_t threads, size_t timeout, bool enable_sync, bool enable_file_logging, int verbose){ + config_context config { + .username = username, + .password = password, + .path = path, + .token = token, + .godot_version = godot_version, + .packages_dir = (packages_dir.empty()) ? std::string(getenv("HOME")) + ".gdpm" : packages_dir, + .tmp_dir = tmp_dir, + .remote_sources = remote_sources, + .threads = threads, + .timeout = timeout, + .enable_sync = enable_sync, + .enable_file_logging = enable_file_logging, + .verbose = verbose + }; + return config; + } + +} \ No newline at end of file diff --git a/src/http.cpp b/src/http.cpp new file mode 100644 index 0000000..3a5f476 --- /dev/null +++ b/src/http.cpp @@ -0,0 +1,125 @@ + +#include "http.hpp" +#include "utils.hpp" +#include "log.hpp" + +#include +#include +#include + + +namespace gdpm::http{ + response request_get(const std::string& url, size_t timeout){ + CURL *curl = nullptr; + CURLcode res; + utils::memory_buffer buf = utils::make_buffer(); + response r; + +#if (GDPM_DELAY_HTTP_REQUESTS == 1) + using namespace std::chrono_literals; + utils::delay(); +#endif + + curl_global_init(CURL_GLOBAL_ALL); + curl = curl_easy_init(); + if(curl){ + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + // curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "name=daniel&project=curl"); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "GET"); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void*)&buf); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, utils::curl_write_to_buffer); + curl_easy_setopt(curl, CURLOPT_USERAGENT, constants::UserAgent); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout); + res = curl_easy_perform(curl); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &r.code); + if(res != CURLE_OK) + log::error("_make_request.curl_easy_perform(): {}", curl_easy_strerror(res)); + curl_easy_cleanup(curl); + } + + r.body = buf.addr; + utils::free_buffer(buf); + curl_global_cleanup(); + return r; + } + + response request_post(const std::string& url, const char *post_fields, size_t timeout){ + CURL *curl = nullptr; + CURLcode res; + utils::memory_buffer buf = utils::make_buffer(); + response r; + +#if (GDPM_DELAY_HTTP_REQUESTS == 1) + using namespace std::chrono_literals; + utils::delay(); +#endif + + curl_global_init(CURL_GLOBAL_ALL); + curl = curl_easy_init(); + if(curl){ + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + // curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "name=daniel&project=curl"); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, post_fields); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void*)&buf); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, utils::curl_write_to_buffer); + curl_easy_setopt(curl, CURLOPT_USERAGENT, constants::UserAgent); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout); + res = curl_easy_perform(curl); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &r.code); + if(res != CURLE_OK) + log::error("_make_request.curl_easy_perform(): {}", curl_easy_strerror(res)); + curl_easy_cleanup(curl); + } + + r.body = buf.addr; + utils::free_buffer(buf); + curl_global_cleanup(); + return r; + } + + response download_file(const std::string& url, const std::string& storage_path, size_t timeout){ + CURL *curl = nullptr; + CURLcode res; + response r; + FILE *fp; + +#if (GDPM_DELAY_HTTP_REQUESTS == 1) + using namespace std::chrono_literals; + utils::delay(); +#endif + + curl_global_init(CURL_GLOBAL_ALL); + curl = curl_easy_init(); + if(curl){ + fp = fopen(storage_path.c_str(), "wb"); + // if(!config.username.empty() && !config.password.empty()){ + // std::string curlopt_userpwd{config.username + ":" + config.password}; + // curl_easy_setopt(curl, CURLOPT_USERPWD, curlopt_userpwd.c_str()); + // } + + // /* Switch on full protocol/debug output while testing and disable + // * progress meter by setting to 0L */ + // if(config.verbose){ + // curl_easy_setopt(curl, CURLOPT_VERBOSE, 1L); + // curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + // } + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + // curl_easy_setopt(curl, CURLOPT_USERPWD, "user:pass"); + curl_easy_setopt(curl, CURLOPT_FAILONERROR, true); + curl_easy_setopt(curl, CURLOPT_HEADER, 0); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, true); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, utils::curl_write_to_stream); + curl_easy_setopt(curl, CURLOPT_USERAGENT, constants::UserAgent); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout); + res = curl_easy_perform(curl); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &r.code); + if(res != CURLE_OK){ + log::error("download_file.curl_easy_perform() failed: {}", curl_easy_strerror(res)); + } + fclose(fp); + } + curl_global_cleanup(); + return r; + } +} \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..aff763e --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,14 @@ + +// Godot Package Manager (GPM) + +#include "constants.hpp" +#include "log.hpp" +#include "config.hpp" +#include "package_manager.hpp" + +int main(int argc, char **argv){ + gdpm::package_manager::initialize(argc, argv); + gdpm::package_manager::execute(); + gdpm::package_manager::finalize(); + return 0; +} \ No newline at end of file diff --git a/src/package_manager.cpp b/src/package_manager.cpp new file mode 100644 index 0000000..84d75b6 --- /dev/null +++ b/src/package_manager.cpp @@ -0,0 +1,771 @@ + +#include "package_manager.hpp" +#include "utils.hpp" +#include "rest_api.hpp" +#include "config.hpp" +#include "constants.hpp" +#include "log.hpp" +#include "http.hpp" +#include "cache.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + + +/* + * For cURLpp examples...see the link below: + * + * https://github.com/jpbarrette/curlpp/tree/master/examples + */ + +namespace gdpm::package_manager{ + std::vector repo_sources; + CURL *curl; + CURLcode res; + config::config_context config; + rest_api::asset_list_context params; + command_e command; + std::vector packages; + std::vector opts; + bool skip_prompt = false; + bool clean_tmp_dir = false; + int priority = -1; + + + int initialize(int argc, char **argv){ + // curl_global_init(CURL_GLOBAL_ALL); + curl = curl_easy_init(); + config = config::make_config(); + params = rest_api::make_context(); + command = none; + + /* Check for config and create if not exists */ + if(!std::filesystem::exists(config.path)){ + config::save(config); + } + config = config::load(config.path); + config.enable_sync = true; + std::string json = to_json(config); + + /* Create the local databases if it doesn't exist already */ + cache::create_package_database(); + + /* Run the rest of the program then exit */ + cxxargs args = parse_arguments(argc, argv); + handle_arguments(args); + return 0; + } + + int execute(){ + run_command(command, packages, opts); + if(clean_tmp_dir) + clean_temporary(packages); + return 0; + } + + + void finalize(){ + curl_easy_cleanup(curl); + config::save(config); + // curl_global_cleanup(); + } + + + void install_packages(const std::vector& package_titles){ + using namespace rapidjson; + + /* Check if the package data is already stored in cache. If it is, there + is no need to do a lookup to synchronize the local database since we + have all the information we need to fetch the asset data. */ + std::vector p_found = {}; + std::vector p_cache = cache::get_package_info_by_title(package_titles); + + /* Synchronize database information and then try to get data again from + cache if possible. */ + if(config.enable_sync){ + if(p_cache.empty()){ + log::info("Synchronizing database..."); + p_cache = synchronize_database(package_titles); + p_cache = cache::get_package_info_by_title(package_titles); + } + } + + // FIXME: This does not return the package to be is_installed correctly + for(const auto& p_title : package_titles){ + auto found = std::find_if(p_cache.begin(), p_cache.end(), [&p_title](const package_info& p){ return p.title == p_title; }); + if(found != p_cache.end()){ + p_found.emplace_back(*found); + } + } + + /* Found nothing to install so there's nothing to do at this point. */ + if(p_found.empty()){ + log::error("No packages found to install."); + return; + } + + log::println("Packages to install: "); + for(const auto& p : p_found) + log::print(" {} ", (p.is_installed) ? p.title + " (reinstall)" : p.title); + log::println(""); + + if(!skip_prompt){ + if(!utils::prompt_user_yn("Do you want to install these packages? (y/n)")) + return; + } + + using ss_pair = std::pair; + std::vector dir_pairs; + for(auto& p : p_found){ + log::info_n("Fetching asset data for \"{}\"...", p.title); + std::string url{constants::HostUrl}, package_dir, tmp_dir, tmp_zip; + url += rest_api::endpoints::GET_AssetId; + + /* Retrieve necessary asset data if it was found already in cache */ + Document doc; + if(p.download_url.empty() || p.category.empty() || p.description.empty() || p.support_level.empty()){ + doc = rest_api::get_asset(url, p.asset_id, config.verbose); + if(doc.HasParseError() || doc.IsNull()){ + log::error("Could not get a response from server. ({})", doc.GetParseError()); + return; + } + p.category = doc["category"].GetString(); + p.description = doc["description"].GetString(); + p.support_level = doc["support_level"].GetString(); + p.download_url = doc["download_url"].GetString(); + p.download_hash = doc["download_hash"].GetString(); + } + else{ + } + + /* Set directory and temp paths for storage */ + package_dir = config.packages_dir + "/" + p.title; + tmp_dir = config.tmp_dir + "/" + p.title; + tmp_zip = tmp_dir + ".zip"; + + /* Make directories for packages if they don't exist to keep everything organized */ + if(!std::filesystem::exists(config.tmp_dir)) + std::filesystem::create_directories(config.tmp_dir); + if(!std::filesystem::exists(config.packages_dir)) + std::filesystem::create_directories(config.packages_dir); + + /* Dump asset information for lookup into JSON in package directory */ + if(!std::filesystem::exists(package_dir)) + std::filesystem::create_directory(package_dir); + std::ofstream ofs(package_dir + "/package.json"); + OStreamWrapper osw(ofs); + PrettyWriter writer(osw); + doc.Accept(writer); + + /* Check if we already have a stored temporary file before attempting to download */ + if(std::filesystem::exists(tmp_zip) && std::filesystem::is_regular_file(tmp_zip)){ + log::println("Found cached package. Skipping download.", p.title); + } + else{ + /* Download all the package files and place them in tmp directory. */ + log::print("Downloading \"{}\"...", p.title); + std::string download_url = doc["download_url"].GetString(); + std::string title = doc["title"].GetString(); + http::response response = http::download_file(download_url, tmp_zip); + if(response.code == 200){ + log::println("Done."); + }else{ + log::error("Something went wrong...(code {})", response.code); + return; + } + } + + dir_pairs.emplace_back(ss_pair(tmp_zip, package_dir + "/")); + + p.is_installed = true; + p.install_path = package_dir; + } + + /* Extract all the downloaded packages to their appropriate directory location. */ + for(const auto& p : dir_pairs) + utils::extract_zip(p.first.c_str(), p.second.c_str()); + + /* Update the cache data with information from */ + log::info_n("Updating local package database..."); + cache::update_package_info(p_found); + log::println("Done."); + } + + + void remove_packages(const std::vector& package_titles){ + using namespace rapidjson; + using namespace std::filesystem; + + if(package_titles.empty()){ + log::error("No packages to remove."); + return; + } + + /* Find the packages to remove if they're is_installed and show them to the user */ + std::vector p_cache = cache::get_package_info_by_title(package_titles); + if(p_cache.empty()){ + log::error("Could not find any packages to remove."); + return; + } + + /* Count number packages in cache flagged as is_installed. If there are none, then there's nothing to do. */ + size_t p_count = 0; + std::for_each(p_cache.begin(), p_cache.end(), [&p_count](const package_info& p){ + p_count += (p.is_installed) ? 1 : 0; + }); + + if(p_count == 0){ + log::error("No packages to remove."); + return; + } + + log::println("Packages to remove:"); + for(const auto& p : p_cache) + if(p.is_installed) + log::print(" {} ", p.title); + log::println(""); + + if(!skip_prompt){ + if(!utils::prompt_user_yn("Do you want to remove these packages? (y/n)")) + return; + } + + log::info_n("Removing packages..."); + for(auto& p : p_cache){ + const path path{config.packages_dir}; + std::filesystem::remove_all(config.packages_dir + "/" + p.title); + if(config.verbose > 0){ + log::debug("package directory: {}", path.string()); + } + + /* Traverse the package directory */ + for(const auto& entry : recursive_directory_iterator(path)){ + if(entry.is_directory()){ + } + else if(entry.is_regular_file()){ + std::string filename = entry.path().filename().string(); + std::string pkg_path = entry.path().lexically_normal().string(); + + // pkg_path = utils::replace_all(pkg_path, " ", "\\ "); + if(filename == "package.json"){ + std::string contents = utils::readfile(pkg_path); + Document doc; + if(config.verbose > 0){ + log::debug("package path: {}", pkg_path); + log::debug("contents: \n{}", contents); + } + doc.Parse(contents.c_str()); + if(doc.IsNull()){ + log::error("Could not remove packages. Parsing 'package.json' returned NULL."); + return; + } + } + } + } + p.is_installed = false; + } + log::println("Done."); + log::info_n("Updating local package database..."); + cache::update_package_info(p_cache); + log::println("Done."); + } + + + void update_packages(const std::vector& package_titles){ + using namespace rapidjson; + /* If no package titles provided, update everything and then exit */ + if(package_titles.empty()){ + + return; + } + + /* Fetch remote asset data and compare to see if there are package updates */ + std::vector p_updates = {}; + std::vector p_cache = cache::get_package_info_by_title(package_titles); + + std::string url{constants::HostUrl}; + url += rest_api::endpoints::GET_Asset; + Document doc = rest_api::get_assets_list(url, params); + + if(doc.IsNull()){ + log::error("Could not get response from server. Aborting."); + return; + } + + for(const auto& p : p_updates){ + for(const auto& o : doc["result"].GetArray()){ + size_t local_version = std::stoul(p.version); + std::string remote_version_s = o[""].GetString(); + } + } + } + + + void search_for_packages(const std::vector &package_titles){ + std::vector p_cache = cache::get_package_info_by_title(package_titles); + + if(!p_cache.empty()){ + print_package_list(p_cache); + return; + } + for(const auto& p_title : package_titles){ + using namespace rapidjson; + + params.filter = curl_easy_escape(curl, p_title.c_str(), p_title.size()); + params.verbose = config.verbose; + params.godot_version = config.godot_version; + params.max_results = 200; + + std::string request_url{constants::HostUrl}; + request_url += rest_api::endpoints::GET_Asset; + Document doc = rest_api::get_assets_list(request_url, params); + if(doc.IsNull()){ + log::error("Could not search for packages. Are you connected to the internet?"); + return; + } + + log::info("{} package(s) found...", doc["total_items"].GetInt()); + print_package_list(doc); + } + } + + + void list_installed_packages(){ + using namespace rapidjson; + using namespace std::filesystem; + const path path{config.packages_dir}; + std::vector p_installed = cache::get_installed_packages(); + if(p_installed.empty()) + return; + log::println("Installed packages:"); + print_package_list(p_installed); + } + + + void read_package_contents(const std::string& package_title){ + + } + + + void clean_temporary(const std::vector& package_titles){ + if(package_titles.empty()){ + log::info("Cleaned all temporary files."); + std::filesystem::remove_all(config.tmp_dir); + } + /* Find the path of each packages is_installed then delete temporaries */ + log::info_n("Cleaning temporary files..."); + for(const auto& p_title : package_titles){ + std::string tmp_zip = config.tmp_dir + "/" + p_title + ".zip"; + if(config.verbose > 0) + log::info("Removed '{}'", tmp_zip); + std::filesystem::remove_all(tmp_zip); + } + log::println("Done."); + } + + + void link_packages(const std::vector& package_titles, const std::vector& paths){ + using namespace std::filesystem; + + std::vector p_found = {}; + std::vector p_cache = cache::get_package_info_by_title(package_titles); + if(p_cache.empty()){ + log::error("Could not find any packages to link."); + return; + } + + for(const auto& p_title : package_titles){ + auto found = std::find_if(p_cache.begin(), p_cache.end(), [&p_title](const package_info& p){ return p.title == p_title; }); + if(found != p_cache.end()){ + p_found.emplace_back(*found); + } + } + + /* Get the storage paths for all packages to create symlinks */ + const path package_dir{config.packages_dir}; + for(const auto& p : p_found){ + for(const auto& path : paths){ + log::info("Creating symlink for \"{}\" package to {}", p.title, path + "/" + p.title); + // std::filesystem::path target{config.packages_dir + "/" + p.title}; + std::filesystem::path target = {current_path().string() + "/" + config.packages_dir + "/" + p.title}; + std::filesystem::path symlink_path{path + "/" + p.title}; + if(!std::filesystem::exists(symlink_path.string())) + std::filesystem::create_directories(path + "/"); + std::error_code ec; + std::filesystem::create_directory_symlink(target, symlink_path, ec); + if(ec){ + log::error("Could not create symlink: {}", ec.message()); + } + } + } + } + + + void clone_packages(const std::vector& package_titles, const std::vector& paths){ + using namespace std::filesystem; + + std::vector p_found = {}; + std::vector p_cache = cache::get_package_info_by_title(package_titles); + if(p_cache.empty()){ + log::error("Could not find any packages to clone."); + return; + } + + for(const auto& p_title : package_titles){ + auto found = std::find_if(p_cache.begin(), p_cache.end(), [&p_title](const package_info& p){ return p.title == p_title; }); + if(found != p_cache.end()){ + p_found.emplace_back(*found); + } + } + + /* Get the storage paths for all packages to create clones */ + const path package_dir{config.packages_dir}; + for(const auto& p : p_found){ + for(const auto& path : paths){ + log::info("Cloning \"{}\" package to {}", p.title, path + "/" + p.title); + std::filesystem::path from{config.packages_dir + "/" + p.title}; + std::filesystem::path to{path + "/" + p.title}; + if(!std::filesystem::exists(to.string())) + std::filesystem::create_directories(to); + + /* TODO: Add an option to force overwriting (i.e. --overwrite) */ + std::filesystem::copy(from, to, copy_options::update_existing | copy_options::recursive); + } + } + } + + + void add_remote_repository(const std::string& repository, ssize_t offset){ + auto& s = config.remote_sources; + auto iter = (offset > 0) ? s.begin() + offset : s.end() - offset; + config.remote_sources.insert(iter, repository); + } + + + void delete_remote_repository(const std::string& repository){ + auto& s = config.remote_sources; + + std::erase(s, repository); + (void)std::remove_if(s.begin(), s.end(), [&repository](const std::string& rs){ + return repository == rs; + }); + } + + + void delete_remote_repository(size_t index){ + auto& s = config.remote_sources; + // std::erase(s, index); + } + + + cxxargs parse_arguments(int argc, char **argv){ + /* Parse command-line arguments using cxxopts */ + cxxopts::Options options( + argv[0], + "Package manager made for managing Godot assets." + ); + options.allow_unrecognised_options(); + options.custom_help("This is a custom help string."); + options.add_options("Command") + ("input", "", cxxopts::value>()) + ("install", "Install package or packages.", cxxopts::value>()->implicit_value(""), "") + ("remove", "Remove a package or packages.", cxxopts::value>()->implicit_value(""), "") + ("update", "Update a package or packages. This option will update all packages if no argument is supplied.", cxxopts::value>()->implicit_value(""), "") + ("search", "Search for a package or packages.", cxxopts::value>(), "") + ("list", "Show list of is_installed packages.") + ("link", "Create a symlink (or shortcut) to target directory.", cxxopts::value>(), "") + ("clone", "Clone packages into target directory.", cxxopts::value>(), "") + ("clean", "Clean temporary downloaded files.") + ("sync", "Sync local database with remote server.") + ("add-remote", "Set a source repository.", cxxopts::value()->default_value(constants::AssetRepo), "") + ("delete-remote", "Remove a source repository from list.", cxxopts::value(), "") + ("h,help", "Print this message and exit.") + ; + options.parse_positional({"input"}); + options.add_options("Options") + ("c,config", "Set the config file path.", cxxopts::value()) + ("f,file", "Read file to install or remove packages.", cxxopts::value(), "") + ("path", "Specify a path to use with a command", cxxopts::value>()) + ("type", "Set package type (any|addon|project).", cxxopts::value()) + ("sort", "Sort packages in order (rating|cost|name|updated).", cxxopts::value()) + ("support", "Set the support level for API (all|official|community|testing).") + ("max-results", "Set the max results to return from search.", cxxopts::value()->default_value("500"), "") + ("godot-version", "Set the Godot version to include in request.", cxxopts::value()) + ("set-priority", "Set the priority for remote source. Lower values are used first (0...100).", cxxopts::value()) + ("set-packages-directory", "Set the local package storage location.", cxxopts::value()) + ("set-temporary-directory", "Set the local temporary storage location.", cxxopts::value()) + ("timeout", "Set the amount of time to wait for a response.", cxxopts::value()) + ("no-sync", "Disable synchronizing with remote.", cxxopts::value()->implicit_value("true")->default_value("false")) + ("y,yes", "Bypass yes/no prompt for installing or removing packages.") + ("v,verbose", "Show verbose output.", cxxopts::value()->implicit_value("1")->default_value("0"), "0-5") + ; + + auto result = options.parse(argc, argv); + return {result, options}; + } + + + void handle_arguments(const cxxargs& args){ + auto _get_package_list = [](const cxxopts::ParseResult& result, const char *arg){ + return result[arg].as>(); + }; + const auto& result = args.result; + const auto& options = args.options; + + /* Set option variables first to be used in functions below. */ + if(result.count("config")){ + config.path = result["config"].as(); + config = config::load(config.path); + log::info("Config: {}", config.path); + } + if(result.count("add-remote")){ + std::string repo = result["remote-add"].as(); + config.remote_sources.emplace_back(repo); + } + if(result.count("delete-remote")){ + std::string repo = result["remote-add"].as(); + auto iter = std::find(config.remote_sources.begin(), config.remote_sources.end(), repo); + if(iter != config.remote_sources.end()) + config.remote_sources.erase(iter); + } + if(result.count("file")){ + std::string path = result["file"].as(); + std::string contents = utils::readfile(path); + packages = utils::parse_lines(contents); + } + if(result.count("path")){ + opts = result["path"].as>(); + } + if(result.count("sort")){ + rest_api::sort_e sort = rest_api::sort_e::none; + std::string r = result["sort"].as(); + if(r == "none") sort = rest_api::sort_e::none; + else if(r == "rating") sort = rest_api::sort_e::rating; + else if(r == "cost") sort = rest_api::sort_e::cost; + else if(r == "name") sort = rest_api::sort_e::name; + else if(r == "updated") sort = rest_api::sort_e::updated; + params.sort = sort; + } + if(result.count("type")){ + rest_api::type_e type = rest_api::type_e::any; + std::string r = result["type"].as(); + if(r == "any") type = rest_api::type_e::any; + else if(r == "addon") type = rest_api::type_e::addon; + else if(r == "project") type = rest_api::type_e::project; + params.type = type; + } + if(result.count("support")){ + rest_api::support_e support = rest_api::support_e::all; + std::string r = result["support"].as(); + if(r == "all") support = rest_api::support_e::all; + else if(r == "official") support = rest_api::support_e::official; + else if(r == "community") support = rest_api::support_e::community; + else if(r == "testing") support = rest_api::support_e::testing; + params.support = support; + } + if(result.count("max-results")){ + params.max_results = result["max-results"].as(); + } + if(result.count("godot-version")){ + config.godot_version = result["godot-version"].as(); + } + if(result.count("timeout")){ + config.timeout = result["timeout"].as(); + } + if(result.count("no-sync")){ + config.enable_sync = false; + } + if(result.count("set-priority")){ + priority = result["set-priority"].as(); + } + if(result.count("set-packages-directory")){ + config.packages_dir = result["set-packages-directory"].as(); + } + if(result.count("set-temporary-directory")){ + config.tmp_dir = result["set-temporary-directory"].as(); + } + if(result.count("yes")){ + skip_prompt = true; + } + if(result.count("link")){ + packages = result["link"].as>(); + } + if(result.count("clone")){ + packages = result["clone"].as>(); + } + if(result.count("clean")){ + clean_tmp_dir = true; + } + config.verbose = 0; + config.verbose += result["verbose"].as(); + std::string json = to_json(config); + if(config.verbose > 0){ + log::println("Verbose set to level {}", config.verbose); + log::println("{}", json); + } + + if(!result.count("input")){ + log::error("Command required. See \"help\" for more information."); + return; + } + + std::vector argv = result["input"].as>(); + std::vector opts{argv.begin()+1, argv.end()}; + if(packages.empty() && opts.size() > 0){ + for(const auto& opt : opts){ + packages.emplace_back(opt); + } + } + + if(argv[0] == "install" || argv[0] == "--install") command = install; + else if (argv[0] == "remove" || argv[0] == "--remove") command = remove; + else if(argv[0] == "update" || argv[0] == "--update") command = update; + else if(argv[0] == "search" || argv[0] == "--search") command = search; + else if(argv[0] == "list" || argv[0] == "-ls") command = list; + else if (argv[0] == "link" || argv[0] == "--link") command = link; + else if(argv[0] == "clone" || argv[0] == "--clone") command = clone; + else if(argv[0] == "clean" || argv[0] == "--clean") command = clean; + else if(argv[0] == "sync" || argv[0] == "--sync") command = sync; + else if(argv[0] == "add-remote" || argv[0] == "--add-remote") command = add_remote; + else if(argv[0] == "delete-remote" || argv[0] == "--delete-remote") command = delete_remote; + else if(argv[0] == "help" || argv[0] == "-h" || argv[0] == "--help"){ + log::println("{}", options.help()); + } + } + + + /* Used to run the command AFTER parsing and setting all command line args. */ + void run_command(command_e c, const std::vector& package_titles, const std::vector& opts){ + switch(c){ + case install: install_packages(package_titles); break; + case remove: remove_packages(package_titles); break; + case update: update_packages(package_titles); break; + case search: search_for_packages(package_titles); break; + case list: list_installed_packages(); break; + /* ...opts are the paths here */ + case link: link_packages(package_titles, opts); break; + case clone: clone_packages(package_titles, opts); break; + case clean: clean_temporary(package_titles); break; + case sync: synchronize_database(package_titles); break; + case add_remote: add_remote_repository(opts[0], priority); break; + case delete_remote: delete_remote_repository(opts[0]); break; + case help: /* ...runs in handle_arguments() */ break; + case none: /* ...here to run with no command */ break; + } + } + + + void print_package_list(const std::vector& packages){ + for(const auto& p : packages){ + log::println("{}/{} {} id={}\n\t{}, Godot {}, {}, {}, Last Modified: {}", + p.support_level, + p.title, + p.version, + p.asset_id, + p.author, + p.godot_version, + p.cost, + p.category, + p.modify_date + ); + } + } + + + void print_package_list(const rapidjson::Document& json){ + for(const auto& o : json["result"].GetArray()){ + log::println("{}/{} {} id={}\n\t{}, Godot {}, {}, {}, Last Modified: {}", + o["support_level"] .GetString(), + o["title"] .GetString(), + o["version_string"] .GetString(), + o["asset_id"] .GetString(), + o["author"] .GetString(), + o["godot_version"] .GetString(), + o["cost"] .GetString(), + o["category"] .GetString(), + o["modify_date"] .GetString() + ); + } + } + + + std::vector synchronize_database(const std::vector& package_titles){ + using namespace rapidjson; + params.verbose = config.verbose; + params.godot_version = config.godot_version; + params.page = 0; + int page = 0; + int page_length = 0; + // int total_pages = 0; + int total_items = 0; + int items_left = 0; + + do{ + /* Make the GET request to get page data and store it in the local + package database. Also, check to see if we need to keep going. */ + std::string url{constants::HostUrl}; + url += rest_api::endpoints::GET_Asset; + Document doc = rest_api::get_assets_list(url, params); + params.page += 1; + + if(doc.IsNull()){ + log::error("Could not get response from server. Aborting."); + return {}; + } + + /* Need to know how many pages left to get and how many we get per + request. */ + page = doc["page"].GetInt(); + page_length = doc["page_length"].GetInt(); + // total_pages = doc["pages"].GetInt(); + total_items = doc["total_items"].GetInt(); + items_left = total_items - (page + 1) * page_length; + + // log::info("page: {}, page length: {}, total pages: {}, total items: {}, items left: {}", page, page_length, total_pages, total_items, items_left); + + if(page == 0){ + cache::drop_package_database(); + cache::create_package_database(); + } + + std::vector packages; + for(const auto& o : doc["result"].GetArray()){ + // log::println("======================="); + package_info p{ + .asset_id = std::stoul(o["asset_id"].GetString()), + .title = o["title"].GetString(), + .author = o["author"].GetString(), + .author_id = std::stoul(o["author_id"].GetString()), + .version = o["version"].GetString(), + .godot_version = o["godot_version"].GetString(), + .cost = o["cost"].GetString(), + .modify_date = o["modify_date"].GetString(), + .category = o["category"].GetString(), + .remote_source = url + }; + packages.emplace_back(p); + } + cache::insert_package_info(packages); + + /* Make the same request again to get the rest of the needed data + using the same request, but with a different page, then update + variables as needed. */ + + + } while(items_left > 0); + + return cache::get_package_info_by_title(package_titles); + } + +} // namespace gdpm::package_manager \ No newline at end of file diff --git a/src/rest_api.cpp b/src/rest_api.cpp new file mode 100644 index 0000000..eb473e0 --- /dev/null +++ b/src/rest_api.cpp @@ -0,0 +1,189 @@ + +#include "rest_api.hpp" +#include "constants.hpp" +#include "http.hpp" +#include "log.hpp" +#include "utils.hpp" +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace gdpm::rest_api{ + bool register_account(const std::string& username, const std::string& password, const std::string& email){ + return false; + } + + bool login(const std::string& username, const std::string& password){ + return false; + } + + bool logout(){ + return false; + } + + asset_list_context make_context(type_e type, int category, support_e support, const std::string& filter, const std::string& user, const std::string& godot_version, int max_results, int page, sort_e sort, bool reverse, int verbose){ + asset_list_context params{ + .type = type, + .category = category, + .support = support, + .filter = filter, + .user = user, + .godot_version = godot_version, + .max_results = max_results, + .page = page, + .sort = sort, + .reverse = reverse, + .verbose = verbose + }; + return params; + } + + rapidjson::Document _parse_json(const std::string& r, int verbose){ + using namespace rapidjson; + Document d; + d.Parse(r.c_str()); + + StringBuffer buffer; + PrettyWriter writer(buffer); + d.Accept(writer); + + if(verbose > 1) + log::info("JSON Response: \n{}", buffer.GetString()); + return d; + } + + std::string _get_type_string(type_e type){ + std::string _s{"type="}; + switch(type){ + case any: _s += "any"; break; + case addon: _s += "addon"; break; + case project: _s += "project"; break; + } + return _s; + } + + std::string _get_support_string(support_e support){ + std::string _s{"support="}; + switch(support){ + case all: _s += "official+community+testing"; break; + case official: _s += "official"; break; + case community: _s += "community"; break; + case testing: _s += "testing"; break; + } + return _s; + } + + std::string _get_sort_string(sort_e sort){ + std::string _s{"sort="}; + switch(sort){ + case none: _s += ""; break; + case rating: _s += "rating"; break; + case cost: _s += "cost"; break; + case name: _s += "name"; break; + case updated: _s += "updated"; break; + } + return _s; + } + + void _print_params(const asset_list_context& params){ + log::println("params: \n" + "\ttype: {}\n" + "\tcategory: {}\n" + "\tsupport: {}\n" + "\tfilter: {}\n" + "\tgodot version: {}\n" + "\tmax results: {}\n", + params.type, + params.category, + params.support, + params.filter, + params.godot_version, + params.max_results + ); + } + + + rapidjson::Document configure(const std::string& url, type_e type, int verbose){ + std::string request_url{url}; + request_url += _get_type_string(type); + http::response r = http::request_get(url); + if(verbose > 0) + log::info("URL: {}", url); + return _parse_json(r.body); + } + + rapidjson::Document get_assets_list(const std::string& url, type_e type, int category, support_e support, const std::string& filter,const std::string& user, const std::string& godot_version, int max_results, int page, sort_e sort, bool reverse, int verbose){ + std::string request_url{url}; + request_url += _get_type_string(type); + request_url += (category <= 0) ? "&category=" : "&category="+fmt::to_string(category); + request_url += "&" + _get_support_string(support); + request_url += "&" + _get_sort_string(sort); + request_url += (!filter.empty()) ? "&filter="+filter : ""; + request_url += (!godot_version.empty()) ? "&godot_version="+godot_version : ""; + request_url += "&max_results=" + fmt::to_string(max_results); + request_url += "&page=" + fmt::to_string(page); + request_url += (reverse) ? "&reverse" : ""; + + http::response r = http::request_get(request_url); + if(verbose > 0) + log::info("URL: {}", request_url); + return _parse_json(r.body, verbose); + } + + rapidjson::Document get_assets_list(const std::string& url, const asset_list_context& params){ + return get_assets_list( + url, params.type, params.category, params.support, params.filter, params.user, params.godot_version, params.max_results, params.page, params.sort, params.reverse, params.verbose + ); + } + + rapidjson::Document get_asset(const std::string& url, int asset_id, int verbose){ + std::string request_url{url}; + request_url = utils::replace_all(request_url, "{id}", fmt::to_string(asset_id)); + http::response r = http::request_get(request_url.c_str()); + if(verbose > 0) + log::info("URL: {}", request_url); + return _parse_json(r.body); + } + + bool delete_asset(int asset_id){ + return false; + } + + bool undelete_asset(int asset_id){ + return false; + } + + bool set_support_level(int asset_id){ + return false; + } + + namespace edits{ + + void edit_asset(){ + + } + + void get_asset_edit(int asset_id){ + + } + + std::string review_asset_edit(int asset_id){ + return std::string(); + } + + std::string accept_asset_edit(int asset_id){ + return std::string(); + } + + std::string reject_asset_edit(int asset_id){ + return std::string(); + } + + } // namespace edits +} \ No newline at end of file diff --git a/src/utils.cpp b/src/utils.cpp new file mode 100644 index 0000000..7033344 --- /dev/null +++ b/src/utils.cpp @@ -0,0 +1,182 @@ + +#include "utils.hpp" +#include "config.hpp" +#include "log.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace gdpm::utils{ + + + #if (GDPM_READFILE_IMPL == 0) + std::string readfile(const std::string& path){ + constexpr auto read_size = std::size_t{4096}; + auto stream = std::ifstream{path.data()}; + stream.exceptions(std::ios_base::badbit); + + auto out = std::string{}; + auto buf = std::string(read_size, '\0'); + while (stream.read(& buf[0], read_size)) { + out.append(buf, 0, stream.gcount()); + } + out.append(buf, 0, stream.gcount()); + return out; + } +#elif(GDPM_READFILE_IMPL == 1) + + std::string readfile(const std::string& path){ + std::ifstream ifs(path); + return std::string( + (std::istreambuf_iterator(ifs)), + (std::istreambuf_iterator()) + ); + } +#elif(GDPM_READFILE_IMPL == 2) + std::string readfile(const std::string& path){ + std::ifstream ifs(path); + std::stringstream buffer; + buffer << ifs.rdbuf(); + return buffer.str(); + } +#endif + + void to_lower(std::string& s){ + std::transform(s.begin(), s.end(), s.begin(), tolower); + } + + std::vector parse_lines(const std::string &s){ + std::string line; + std::vector result; + std::stringstream ss(s); + while(std::getline(ss, line)){ + result.emplace_back(line); + } + return result; + } + + std::string replace_first(std::string &s, const std::string &from, const std::string &to){ + size_t pos = s.find(from); + if(pos == std::string::npos) + return s; + return s.replace(pos, from.length(), to); + } + + std::string replace_all(std::string& s, const std::string& from, const std::string& to){ + size_t pos = 0; + while((pos = s.find(from, pos)) != std::string::npos){ + s.replace(pos, s.length(), to); + pos += to.length(); + } + return s; + } + + /* Ref: https://gist.github.com/mobius/1759816 */ + int extract_zip(const char *archive, const char *dest, int verbose){ + const char *prog = "gpdm"; + struct zip *za; + struct zip_file *zf; + struct zip_stat sb; + char buf[100]; + int err; + int i, len, fd; + zip_uint64_t sum; + + log::info_n("Extracting package contents to '{}'...", dest); + if((za = zip_open(archive, 0, &err)) == nullptr){ + zip_error_to_str(buf, sizeof(buf), err, errno); + log::error("{}: can't open zip archive {}: {}", prog, archive, buf); + return 1; + } + + for(i = 0; i < zip_get_num_entries(za, 0); i++){ + if(zip_stat_index(za, i, 0, &sb) == 0){ + len = strlen(sb.name); + if(verbose > 1){ + log::print("{}, ", sb.name); + log::println("size: {}, ", sb.size); + } + std::string path{dest}; + path += sb.name; + if(sb.name[len-1] == '/'){ + // safe_create_dir(sb.name); + std::filesystem::create_directory(path); + } else { + zf = zip_fopen_index(za, i, 0); + if(!zf){ + log::error("extract_zip: zip_fopen_index() failed."); + return 100; + } +#ifdef _WIN32 + fd = open(sb.name, O_RDWR | O_TRUNC | O_CREAT | O_BINARY, 0644); +#else + fd = open(path.c_str(), O_RDWR | O_TRUNC | O_CREAT, 0644); +#endif + if(fd < 0){ + log::error("extract_zip: open() failed. (path: {}, fd={})", path, fd); + return 101; + } + + sum = 0; + while(sum != sb.size){ + len = zip_fread(zf, buf, 100); + if(len < 0){ + log::error("extract_zip: zip_fread() returned len < 0 (len={})", len); + return 102; + } + write(fd, buf, len); + sum += len; + } + close(fd); + zip_fclose(zf); + } + } else { + log::println("File[{}] Line[{}]\n", __FILE__, __LINE__); + } + } + + if(zip_close(za) == -1){ + log::error("{}: can't close zip archive '{}'", prog, archive); + return 1; + } + log::println("Done."); + return 0; + } + + std::string prompt_user(const char *message){ + log::print("{} ", message); + std::string input; + std::cin >> input; + return input; + } + + bool prompt_user_yn(const char *message){ + std::string input{"y"}; + do{ + input = utils::prompt_user(message); + to_lower(input); + input = (input == "\0" || input == "\n" || input.empty()) ? "y" : input; + }while( !std::cin.fail() && input != "y" && input != "n"); + return input == "y"; + } + + void delay(std::chrono::milliseconds millis){ + using namespace std::this_thread; + using namespace std::chrono_literals; + using std::chrono::system_clock; + + sleep_for(millis); + // sleep_until(system_clock::now() + millis); + } +} // namespace towk::utils \ No newline at end of file