Build a CLI app that uses the Storage API
Wrap the Storage module API in a simple synchronous CLI interface.
This document is accurate for Testnet v0.2.1.
The Storage Module API offers a comprehensive way to access the Storage module, but can be inconveniently complex for CLI access. This tutorial builds a wrapper module—a separate module that depends on Logos Storage and exposes a simpler, synchronous interface over it. It is intended for developers building custom Logos modules who want a straightforward CLI-style interface instead of working with the Storage module's asynchronous API directly.
-
A supported OS
- Linux
- Mac OS (should work, but not tested)
-
Nix with flakes enabled. Install from nixos.org, then enable flakes:
mkdir -p ~/.config/nixecho 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf -
Git
-
The Logos tooling suite:
logoscore(the Logos runtime);lgpd(the Logos package downloader);lgpm(the Logos package manager).
You can obtain those by running:
# Export those first or the script will fetch the latest version, which might not# work with this tutorialexport LGPM_TAG=0.2.1export LGPD_TAG=0.2.1export LOGOSCORE_TAG=0.2.2curl -fsSL https://raw.githubusercontent.com/logos-co/logos-docs/main/resources/scripts/install-node-tools.sh | shexport PATH="$PWD/bin:$PATH"
What to expect
- You can scaffold a Logos module that wraps the Storage module's asynchronous API in a synchronous interface.
- You can publish a local file to the Logos Storage network with a single
storage_cli publishcommand. - You can download a file from the network with a single
storage_cli downloadcommand.
Step 1: Scaffold the module project
-
Use the Logos module builder template to scaffold a new module project:
mkdir ./storage_clicd ./storage_clinix flake init -t github:logos-co/logos-module-builder/0.2.0
Step 2: Configure the module metadata, flake, and CMake files
-
Replace the contents of
metadata.jsonwith the following. It declares a dependency onstorage_moduleand setsconcurrencyso the module's calls run off the main event loop:{"name": "storage_cli","display_name": "Simple Storage CLI","version": "1.0.0","type": "core","interface": "universal","category": "example","description": "A simple CLI frontend module to Logos Storage","main": "storage_cli_plugin","dependencies": ["storage_module"],"concurrency": "multi","nix": {"packages": {"build": [],"runtime": []},"external_libraries": [],"cmake": {"find_packages": [],"extra_sources": [],"extra_include_dirs": [],"extra_link_libraries": []}}}"dependencies": ["storage_module"]declares that this module depends on the Storage module."concurrency": "multi"runs the request handler on a thread separate from the single-threaded event loop, which makes it easier to turn the asynchronous Storage API calls into synchronous ones later.
-
Delete the generated placeholder implementation, since you'll provide your own:
rm src/minimal_impl.{h,cpp} -
Edit the generated
flake.nixto add the Storage module as an input:inputs = {# Replace the line:# logos-module-builder.url = "github:logos-co/logos-module-builder";# with:logos-module-builder.url = "github:logos-co/logos-module-builder/0.2.0";# and add this:storage_module.url = "github:logos-co/logos-storage-module/v2.1.2";}; -
Edit
CMakeLists.txtto reference the new source file names. Replacelogos_modulewith:# Define the module. The generated glue is compiled automatically.logos_module(NAME ${MODULE_NAME}SOURCESsrc/storage_cli_impl.hsrc/storage_cli_impl.cpp)
Step 3: Define the module interface
-
Create
src/storage_cli_impl.hwith the following interface. It declares two operations—publishanddownload—both of which return aStdLogosResult(result type), and overridesonContextReady, a Logos C++ SDK hook called when the module is loaded:// storage_cli_impl.h#pragma once#include <logos_module_context.h>#include <logos_result.h>#include <string>/*** A synchronous, CLI-shaped facade over the asynchronous `storage_module`.*/class StorageCliImpl : public LogosModuleContext {public:/*** Uploads a file to the local node.*/StdLogosResult publish(const std::string &input);/*** Downloads a file from the network onto the specified local path.*/StdLogosResult download(const std::string &cid, const std::string &output);protected:/// Starts (and configures) the storage node when the module is loaded.void onContextReady() override;};
Step 4: Set up shared state and helper functions
The rest of the implementation goes in src/storage_cli_impl.cpp. Add the file's includes, then the shared state and small helper functions the rest of the module will use.
-
Start
src/storage_cli_impl.cppwith its header and includes.nlohmann/jsonships with the Logos SDK, so you don't need to install it separately:// storage_cli_impl.cpp#include "storage_cli_impl.h"#include "logos_sdk.h"#include <algorithm>#include <cstdint>#include <filesystem>#include <functional>#include <future>#include <iostream>#include <mutex>#include <system_error>#include <nlohmann/json.hpp>using nlohmann::json; -
Open an anonymous namespace and define the node configuration and transfer chunk size:
namespace {constexpr const char *kNodeConfig = R"({"log-level": "INFO","nat": "auto","data-dir": "/tmp/logos-storage","network": "logos.test"})";constexpr int64_t kChunkSize = 65536; -
Add standard C++ promises to make the
publish/downloadoperations synchronous and a mutex to serialise these operations:// We'll use two promises: one for synchronising node startup, and another for// upload/download operation results.std::promise<bool> gStarted;// The start future is potentially called by several different threads, so we// need a shared future.std::shared_future<bool> gStartedFut = gStarted.get_future().share();// gResult is initialised during an asynchronous operation dispatch, set in// the callback once, and consumed by the dispatcher exactly once, so we can// use a regular future.std::promise<std::string> gResult;// Serialises upload/download operations.std::mutex gOpLock;int64_t gTransferBytes = 0;int64_t gTransferTotal = 0;gTransferTotalandgTransferBytestrack the progress of the singlepublish/downloadoperation this module allows to run at a time.
-
Add helper functions for printing transfer progress and for parsing JSON payloads returned by the Storage module:
void echo(const std::string &line, bool endline = true) {std::cout << line;if (endline) {std::cout << '\n';}std::cout.flush();}void printProgress() {if (gTransferTotal == 0) {echo(" " + std::to_string(gTransferBytes) + " bytes");return;}int64_t completed = std::min(gTransferBytes, gTransferTotal);echo(" " + std::to_string(completed * 100 / gTransferTotal) + "% (" +std::to_string(completed) + " of " + std::to_string(gTransferTotal) +" bytes)");}json parsePayload(const std::string &payload) {json j = json::parse(payload, nullptr, false);if (j.is_discarded()) {echo("Failed to parse payload: " + payload);return {};}return j;}
Step 5: Implement the synchronous transfer helper
-
Add the
onProgressandonDonecallbacks. The Storage module invokes these as theuploadUrloperation progresses and thedownloadToUrloperation completes, respectively:void onProgress(const std::string &payload) {gTransferBytes += parsePayload(payload).value("bytes", int64_t{0});printProgress();}void onDone(const std::string &payload) { gResult.set_value(payload); } -
Add
syncTransferOp, the helper that turns an asynchronous Storage operation into a synchronous one—this is the core of the module:StdLogosResult syncTransferOp(const std::string &what, int64_t total,const std::function<StdLogosResult()> &op) {echo("Waiting for node to start.");if (!gStartedFut.get()) {return StdLogosResult{.success = false, .value = {}, .error = "Node start failed"};}echo("Node is started, attempting to run " + what + " operation.");// This will block attempts to run multiple operations at once.// This is not a limitation in storage but of our state tracking.std::scoped_lock lock(gOpLock);gResult = std::promise<std::string>();gTransferBytes = 0;gTransferTotal = total;// Actually sends the operation to Storage.StdLogosResult started = op();if (!started.success) {return started;}const std::string result = gResult.get_future().get();const json payload = parsePayload(result);return StdLogosResult{.success = payload.value("success", false),.value = payload,.error = ""};}} // namespace- Lines 81-85 wait on the
gStartedFutpromise, which is set once the node has started. - Line 90 acquires
gOpLockso twosyncTransferOpcalls can't run concurrently. - Lines 92-94 reset the result promise and progress counters
- Line 97 dispatches the operation via
op(), and blocks on the result promise, which is fulfilled by theonDonecallback above once the operation completes. - Lines 103-106 parse and return the result.
- Lines 81-85 wait on the
Step 6: Implement the context hook and the public operations
-
Implement
onContextReady, which registers the Storage module's callbacks and starts the Storage node:void StorageCliImpl::onContextReady() {StorageModule &storage = modules().storage_module;storage.onStorageStart([](const std::string &payload) {const json j = parsePayload(payload);echo("Node started with result: " + j.dump());gStarted.set_value(j.value("success", false));});storage.onStorageUploadProgress(&onProgress);storage.onStorageDownloadProgress(&onProgress);storage.onStorageUploadDone(&onDone);storage.onStorageDownloadDone(&onDone);echo("starting storage node (data-dir /tmp/logos-storage, network ""logos.test)...");if (!storage.init(kNodeConfig)) {echo("failed to initialise storage module. We'll assume it has already ""been initialised.");gStarted.set_value(true);return;}if (!storage.start()) {echo("node start was rejected. We'll assume it has already been started.");gStarted.set_value(true);}}noteonContextReadyassumes that a failure to dispatchinitorstartmeans the module was already initialised or started, since there's no API to distinguish that case from a genuine failure. Avoid reloading this module; if you do, restart the whole node instead. -
Implement
publish, which uploads a local file:StdLogosResult StorageCliImpl::publish(const std::string &input) {std::error_code ec;const std::filesystem::path path = std::filesystem::absolute(input, ec);const auto size = static_cast<int64_t>(std::filesystem::file_size(path, ec));if (ec) {echo("upload failed: cannot read " + input + " (" + ec.message() + ")");return {.success = false, .value = {}, .error = ec.message()};}echo("uploading " + path.string() + " (" + std::to_string(size) + " bytes)");return syncTransferOp("upload", size, [&] {return modules().storage_module.uploadUrl(path.string(), kChunkSize);});} -
Implement
download, which downloads a file by its CID onto local disk:StdLogosResult StorageCliImpl::download(const std::string &cid,const std::string &output) {std::error_code ec;const std::filesystem::path path = std::filesystem::absolute(output, ec);if (ec) {echo("download failed: bad output path " + output + " (" + ec.message() +")");return {.success = false, .value = {}, .error = ec.message()};}echo("Downloading " + cid + " to " + path.string());return syncTransferOp("download", 0, [&] {return modules().storage_module.downloadToUrl(cid, path.string(), false,kChunkSize);});}
Step 7: Build your module
-
Build the module:
nix build '.#lgx-portable'-
Expected result: an
.lgxpackage appears under theresultfolder:$ ls resultlogos-storage_cli-module-lib.lgx
-
Step 8: Download and install the Storage module
The Storage module is a dependency of your module, so install it before loading your own.
-
Download the Storage module package:
lgpd --version 2.1.2 download storage_module -o . -
Install it:
lgpm install --file ./storage_module-2.1.2.lgx --modules-dir ./modules
Step 9: Install your module
-
Install your module package:
lgpm install --file ./result/logos-storage_cli-module-lib.lgx --modules-dir ./modules
Step 10: Start the Logos daemon
-
In a new terminal, start
logoscore:# Make sure to export the PATH and navigate to the correct repository in the new terminal windowexport PATH="$PWD/bin:$PATH"cd ./storage_clilogoscore -D --config-dir ./config-dir -m ./modules- The daemon prints its logs to the terminal. You can also run it as a background process and redirect logs if you prefer.
Step 11: Load the CLI module
-
Confirm both modules are installed:
logoscore --config-dir ./config-dir status-
Expected result:
Logoscore DaemonStatus: runningPID: 405049Uptime: 0sVersion: v1.0.0Modules: 1 loaded, 0 crashed, 2 not loadedstorage_module v2.1.2 not_loaded -storage_cli v1.0.0 not_loaded -capability_module v1.0.0 loaded 14s
-
-
Load the CLI module:
logoscore --config-dir ./config-dir load-module storage_cli-
Expected result:
Loaded module: storage_cli (v1.0.0)Dependencies loaded: storage_module
-
Step 12: Publish a file
-
Create a sample file and publish it with the CLI module:
echo "Hello, World!" > hello.txtlogoscore --config-dir ./config-dir call storage_cli publish ./hello.txt-
Expected result:
{"error": null,"success": true,"value": {"cid": "zDvZRwzm9g47yb761bU9ZRsteTiAxgTdgKz81NndDu5ESgmGfYWZ","sessionId": "0","success": true}} -
Because the
onProgresscallback runs inside the daemon process, progress logs appear in the daemon's terminal, not here. For a small file like this, progress is a single line:[2026-08-19 18:58:13.540] [out] [storage_cli] 100% (13 of 13 bytes)
-
Step 13: Download a file
-
Download Farewell to Westphalia from the Storage network by its CID:
logoscore --config-dir ./config-dir call storage_cli download zDvZRwzkzrrYB6sS1rRpRLt4gBhc1pWoyTSjkfszfmj1seaYYLCZ ./farewell-to-westphalia.pdfThis may take a little while.
-
Expected result:
{"error": null,"success": true,"value": {"sessionId": "zDvZRwzkzrrYB6sS1rRpRLt4gBhc1pWoyTSjkfszfmj1seaYYLCZ","success": true}} -
The daemon logs show the download progressing, e.g.:
[2026-08-19 19:04:53.920] [out] [storage_cli] [storage_cli] Downloading zDvZRwzkzrrYB6sS1rRpRLt4gBhc1pWoyTSjkfszfmj1seaYYLCZ to /home/giuliano/logos-v0.2.1/./farewell-to-westphalia.pdf[2026-08-19 19:04:53.920] [out] [storage_cli] Waiting for node to start.[2026-08-19 19:04:53.920] [out] [storage_cli] Node is started.[2026-08-19 19:04:53.922] [out] [storage_cli] 65536 bytes[2026-08-19 19:04:53.922] [out] [storage_cli] 131072 bytes...[2026-08-19 19:04:53.928] [out] [storage_cli] 2228224 bytes[2026-08-19 19:04:53.928] [out] [storage_cli] 2276462 bytes
-
You may now stop the daemon, or leave it running and use it for other operations.