diff --git a/cli/CMakeLists.txt b/cli/CMakeLists.txt new file mode 100644 index 0000000..4880874 --- /dev/null +++ b/cli/CMakeLists.txt @@ -0,0 +1,23 @@ +# cli/ produces the `velox` binary — the scriptable RPC client. Owned by lane DAEMON. +# Built early (AGENT-DAEMON.md build step 8, pulled forward): it is how the daemon is +# tested before the GUI exists. +# +# Links velox::proto for the wire types and error codes. It speaks the same NDJSON Unix +# socket veloxd listens on; no daemon code is linked. + +if(NOT TARGET nlohmann_json::nlohmann_json) + find_package(nlohmann_json 3.11 REQUIRED) +endif() + +add_executable(velox + src/main.cpp + src/client.cpp +) +target_include_directories(velox PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) +target_compile_features(velox PRIVATE cxx_std_23) +target_compile_options(velox PRIVATE -Wall -Wextra -Wpedantic -Werror) +target_link_libraries(velox PRIVATE velox::proto nlohmann_json::nlohmann_json) + +if(VELOX_BUILD_TESTS AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt) + add_subdirectory(tests) +endif() diff --git a/cli/src/.gitkeep b/cli/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/cli/src/client.cpp b/cli/src/client.cpp new file mode 100644 index 0000000..2bbba2f --- /dev/null +++ b/cli/src/client.cpp @@ -0,0 +1,130 @@ +#include "client.hpp" + +#include +#include +#include + +#include +#include +#include + +#include "velox_proto.hpp" + +namespace velox::cli { + +namespace { + +std::string read_error_message(int e) { return std::strerror(e); } + +} // namespace + +std::string default_socket_path() { + std::string base; + if (const char* xdg = ::getenv("XDG_RUNTIME_DIR"); xdg != nullptr && xdg[0] != '\0') { + base = xdg; + } else { + base = "/run/user/" + std::to_string(::geteuid()); + } + if (!base.empty() && base.back() == '/') base.pop_back(); + return base + "/velox/velox.sock"; +} + +Client::~Client() { + if (fd_ >= 0) ::close(fd_); +} + +std::optional Client::connect() { + socket_path_ = default_socket_path(); + if (socket_path_.size() + 1 > sizeof(sockaddr_un::sun_path)) { + return CallError{CallError::kConnect, "socket path too long: " + socket_path_, {}}; + } + + fd_ = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd_ < 0) return CallError{CallError::kConnect, read_error_message(errno), {}}; + + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + std::memcpy(addr.sun_path, socket_path_.c_str(), socket_path_.size()); + if (::connect(fd_, reinterpret_cast(&addr), sizeof(addr)) != 0) { + const int e = errno; + ::close(fd_); + fd_ = -1; + return CallError{CallError::kConnect, + "cannot reach veloxd at " + socket_path_ + ": " + read_error_message(e), + {}}; + } + + nlohmann::json hello = { + {"clientType", "cli"}, + {"clientName", std::string("velox ") + std::string(velox::proto::kProtocolVersion)}, + {"protocolVersion", std::string(velox::proto::kProtocolVersion)}, + }; + auto r = call("session.hello", hello); + if (!r) return r.error(); + hello_result_ = *r; + return std::nullopt; +} + +std::expected Client::call(const std::string& method, + const nlohmann::json& params) { + const nlohmann::json request = { + {"jsonrpc", "2.0"}, + {"id", next_id_++}, + {"method", method}, + {"params", params}, + }; + return round_trip(request); +} + +std::expected Client::round_trip(const nlohmann::json& request) { + if (fd_ < 0) return std::unexpected(CallError{CallError::kConnect, "not connected", {}}); + + std::string out = request.dump(); + out.push_back('\n'); + std::size_t off = 0; + while (off < out.size()) { + const ssize_t n = ::write(fd_, out.data() + off, out.size() - off); + if (n > 0) { + off += static_cast(n); + continue; + } + if (n < 0 && errno == EINTR) continue; + return std::unexpected(CallError{CallError::kConnect, + "write to daemon failed: " + read_error_message(errno), {}}); + } + + // Read until a newline completes a frame. + for (;;) { + if (const auto nl = inbuf_.find('\n'); nl != std::string::npos) { + const std::string line = inbuf_.substr(0, nl); + inbuf_.erase(0, nl + 1); + nlohmann::json reply = nlohmann::json::parse(line, nullptr, false); + if (reply.is_discarded()) { + return std::unexpected( + CallError{CallError::kProtocol, "daemon sent a malformed reply", {}}); + } + if (reply.contains("error")) { + const auto& e = reply.at("error"); + return std::unexpected(CallError{e.value("code", 0), e.value("message", ""), + e.contains("data") ? e.at("data") : nlohmann::json()}); + } + return reply.contains("result") ? reply.at("result") : nlohmann::json(nullptr); + } + + char chunk[8192]; + const ssize_t n = ::read(fd_, chunk, sizeof(chunk)); + if (n > 0) { + inbuf_.append(chunk, static_cast(n)); + continue; + } + if (n == 0) { + return std::unexpected(CallError{CallError::kConnect, + "daemon closed the connection", {}}); + } + if (errno == EINTR) continue; + return std::unexpected(CallError{CallError::kConnect, + "read from daemon failed: " + read_error_message(errno), {}}); + } +} + +} // namespace velox::cli diff --git a/cli/src/client.hpp b/cli/src/client.hpp new file mode 100644 index 0000000..1ddbae1 --- /dev/null +++ b/cli/src/client.hpp @@ -0,0 +1,55 @@ +#pragma once + +// A synchronous, blocking RPC client for the Unix socket. The CLI does one request at a +// time and waits for the reply, so none of the daemon's async machinery is needed here — +// just connect, session.hello, call, read one NDJSON frame back. + +#include +#include +#include + +#include + +namespace velox::cli { + +struct CallError { + int code; // JSON-RPC error code, or a negative transport code below + std::string message; + nlohmann::json data; // may be null + + static constexpr int kConnect = -1000; // could not reach the daemon + static constexpr int kProtocol = -1001; // malformed reply / framing error +}; + +class Client { +public: + ~Client(); + + // Resolve the socket path ($XDG_RUNTIME_DIR/velox/velox.sock), connect, and complete + // session.hello with clientType "cli". On failure returns the error and leaves the + // client unusable. + std::optional connect(); + + // Send one request and return its result, or the error. `params` is passed through + // verbatim as the JSON-RPC params. + std::expected call(const std::string& method, + const nlohmann::json& params); + + const std::string& socket_path() const noexcept { return socket_path_; } + const nlohmann::json& hello_result() const noexcept { return hello_result_; } + +private: + std::expected round_trip(const nlohmann::json& request); + + int fd_ = -1; + int next_id_ = 1; + std::string socket_path_; + std::string inbuf_; + nlohmann::json hello_result_; +}; + +// $XDG_RUNTIME_DIR/velox/velox.sock, or /run/user//velox/velox.sock when the env var +// is unset. Mirrors daemon/src/rpc/runtime_dir.cpp; kept in sync by being trivial. +std::string default_socket_path(); + +} // namespace velox::cli diff --git a/cli/src/main.cpp b/cli/src/main.cpp new file mode 100644 index 0000000..ee1fad5 --- /dev/null +++ b/cli/src/main.cpp @@ -0,0 +1,179 @@ +// velox — the command-line client for veloxd. +// +// velox add [--dir D] [--out NAME] [--segments N] [--json] +// velox ls [--json] +// velox pause ... velox resume ... +// velox rm ... [--delete-file] +// +// Exit codes: 0 ok, 1 daemon returned an error, 2 usage error, 3 cannot reach the daemon. + +#include +#include +#include +#include +#include + +#include + +#include "client.hpp" + +namespace { + +using nlohmann::json; +using velox::cli::CallError; +using velox::cli::Client; + +constexpr int kOk = 0; +constexpr int kRpcError = 1; +constexpr int kUsage = 2; +constexpr int kNoDaemon = 3; + +struct Args { + std::vector positional; + bool json = false; + bool delete_file = false; + std::string dir; + std::string out; + long segments = 0; +}; + +[[noreturn]] void usage(int code) { + std::fprintf(code == kOk ? stdout : stderr, + "usage: velox [options]\n\n" + " add [--dir DIR] [--out NAME] [--segments N]\n" + " ls\n" + " pause ...\n" + " resume ...\n" + " rm ... [--delete-file]\n\n" + " --json print the raw JSON-RPC result\n"); + std::exit(code); +} + +Args parse_args(int argc, char** argv) { + Args a; + for (int i = 2; i < argc; ++i) { + const std::string_view arg = argv[i]; + if (arg == "--json") { + a.json = true; + } else if (arg == "--delete-file") { + a.delete_file = true; + } else if (arg == "--dir" && i + 1 < argc) { + a.dir = argv[++i]; + } else if (arg == "--out" && i + 1 < argc) { + a.out = argv[++i]; + } else if (arg == "--segments" && i + 1 < argc) { + a.segments = std::strtol(argv[++i], nullptr, 10); + } else if (arg == "-h" || arg == "--help") { + usage(kOk); + } else if (!arg.empty() && arg.front() == '-') { + std::fprintf(stderr, "velox: unknown option %s\n", argv[i]); + usage(kUsage); + } else { + a.positional.emplace_back(arg); + } + } + return a; +} + +int report_error(const CallError& e, bool as_json) { + if (as_json) { + json j = {{"error", {{"code", e.code}, {"message", e.message}}}}; + if (!e.data.is_null()) j["error"]["data"] = e.data; + std::printf("%s\n", j.dump(2).c_str()); + } else { + std::fprintf(stderr, "velox: %s\n", e.message.c_str()); + } + return e.code == CallError::kConnect ? kNoDaemon : kRpcError; +} + +void print_task_table(const json& items) { + std::printf("%-38s %-10s %-9s %s\n", "ID", "STATE", "PROGRESS", "NAME"); + for (const auto& t : items) { + const std::string id = t.value("taskId", ""); + const std::string state = t.value("state", ""); + const std::string name = t.value("filename", ""); + const long long total = t.value("sizeBytes", 0LL); + const long long done = t.value("downloadedBytes", 0LL); + char pct[12] = "-"; + if (total > 0) std::snprintf(pct, sizeof(pct), "%lld%%", done * 100 / total); + std::printf("%-38s %-10s %-9s %s\n", id.c_str(), state.c_str(), pct, name.c_str()); + } +} + +int cmd_ls(Client& c, const Args& a) { + auto r = c.call("download.list", json::object()); + if (!r) return report_error(r.error(), a.json); + if (a.json) { + std::printf("%s\n", r->dump(2).c_str()); + return kOk; + } + const auto& items = r->contains("items") ? r->at("items") : json::array(); + if (items.empty()) { + std::printf("no downloads\n"); + return kOk; + } + print_task_table(items); + return kOk; +} + +int cmd_add(Client& c, const Args& a) { + if (a.positional.empty()) { + std::fprintf(stderr, "velox add: a URL is required\n"); + return kUsage; + } + json params = {{"url", a.positional.front()}}; + if (!a.dir.empty()) params["saveDir"] = a.dir; + if (!a.out.empty()) params["filename"] = a.out; + if (a.segments > 0) params["segments"] = a.segments; + + auto r = c.call("download.add", params); + if (!r) return report_error(r.error(), a.json); + if (a.json) { + std::printf("%s\n", r->dump(2).c_str()); + } else { + std::printf("added %s\n", r->value("taskId", "?").c_str()); + } + return kOk; +} + +int cmd_bulk(Client& c, const Args& a, const char* method) { + if (a.positional.empty()) { + std::fprintf(stderr, "velox: at least one task id is required\n"); + return kUsage; + } + json params = {{"taskIds", a.positional}}; + if (std::string_view(method) == "download.remove" && a.delete_file) params["deleteFile"] = true; + + auto r = c.call(method, params); + if (!r) return report_error(r.error(), a.json); + if (a.json) { + std::printf("%s\n", r->dump(2).c_str()); + } else { + std::printf("ok\n"); + } + return kOk; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) usage(kUsage); + const std::string command = argv[1]; + if (command == "-h" || command == "--help") usage(kOk); + + const Args args = parse_args(argc, argv); + + Client client; + if (const auto err = client.connect()) { + return report_error(*err, args.json); + } + + if (command == "ls") return cmd_ls(client, args); + if (command == "add") return cmd_add(client, args); + if (command == "pause") return cmd_bulk(client, args, "download.pause"); + if (command == "resume") return cmd_bulk(client, args, "download.resume"); + if (command == "rm") return cmd_bulk(client, args, "download.remove"); + + std::fprintf(stderr, "velox: unknown command '%s'\n", command.c_str()); + usage(kUsage); +} diff --git a/cli/tests/CMakeLists.txt b/cli/tests/CMakeLists.txt new file mode 100644 index 0000000..15165f2 --- /dev/null +++ b/cli/tests/CMakeLists.txt @@ -0,0 +1,13 @@ +# CLI integration test: a real in-process UdsServer on a temp socket, the real Client +# against it. Links veloxd_rpc for the server half. + +add_executable(velox_client_test client_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/client.cpp) +target_include_directories(velox_client_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../src + ${CMAKE_CURRENT_SOURCE_DIR}/../../daemon/tests +) +target_compile_features(velox_client_test PRIVATE cxx_std_23) +target_compile_options(velox_client_test PRIVATE -Wall -Wextra -Wpedantic -Werror) +target_link_libraries(velox_client_test PRIVATE veloxd_rpc velox::proto nlohmann_json::nlohmann_json) +add_test(NAME velox.client COMMAND velox_client_test) +set_tests_properties(velox.client PROPERTIES TIMEOUT 30) diff --git a/cli/tests/client_test.cpp b/cli/tests/client_test.cpp new file mode 100644 index 0000000..0f1f1a7 --- /dev/null +++ b/cli/tests/client_test.cpp @@ -0,0 +1,78 @@ +// The CLI's Client against a real in-process daemon RPC server. + +#include +#include + +#include +#include +#include + +#include "check.hpp" +#include "client.hpp" +#include "rpc/dispatcher.hpp" +#include "rpc/event_loop.hpp" +#include "rpc/uds_server.hpp" + +namespace rpc = velox::daemon::rpc; +using velox::cli::CallError; +using velox::cli::Client; + +void run() { + // connect() with no daemon -> a kConnect error, not a crash. + { + ::setenv("XDG_RUNTIME_DIR", "/tmp/velox-cli-test-nonexistent-xyz", 1); + Client c; + auto err = c.connect(); + CHECK(err.has_value()); + if (err) CHECK_EQ(err->code, CallError::kConnect); + } + + // Point XDG_RUNTIME_DIR at a fresh temp dir; the client derives + // /velox/velox.sock and the server binds exactly that. + char tmpl[] = "/tmp/velox-cli-test-XXXXXX"; + const char* xdg = ::mkdtemp(tmpl); + CHECK(xdg != nullptr); + if (xdg == nullptr) return; + ::setenv("XDG_RUNTIME_DIR", xdg, 1); + const std::string velox_dir = std::string(xdg) + "/velox"; + ::mkdir(velox_dir.c_str(), 0700); + const std::string server_sock = velox_dir + "/velox.sock"; + + rpc::EventLoop loop; + rpc::VeloxDispatcher dispatcher; + rpc::UdsServer server(loop, dispatcher, server_sock); + const auto ec = server.start(); + CHECK(!ec); + if (ec) return; + std::thread th([&loop] { loop.run(); }); + + { + Client c; + auto err = c.connect(); + CHECK(!err.has_value()); + if (err) { + loop.stop(); + th.join(); + return; + } + CHECK_EQ(c.hello_result().value("transport", ""), std::string("uds")); + + auto ls = c.call("download.list", nlohmann::json::object()); + CHECK(ls.has_value()); + if (ls) { + CHECK_EQ(ls->value("total", -1), 0); + CHECK(ls->at("items").is_array()); + } + + // A not-yet-implemented method surfaces the daemon's error, not a transport error. + auto add = c.call("download.add", {{"url", "https://example.com/x"}}); + CHECK(!add.has_value()); + if (!add) CHECK_EQ(add.error().code, -32603); + } + + loop.stop(); + th.join(); + ::unlink(server_sock.c_str()); +} + +TEST_MAIN()