diff --git a/Cargo.lock b/Cargo.lock index 1b2f97ede765..383bfacf1862 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -349,6 +349,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -405,6 +414,46 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cln-bip353" version = "0.1.0" @@ -516,6 +565,28 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "cln-reckless" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "cln-plugin", + "cln-rpc", + "futures", + "hex", + "log", + "log-panics", + "serde", + "serde_json", + "sha2 0.11.0", + "tokio", + "tokio-util", + "url", + "which", +] + [[package]] name = "cln-rpc" version = "0.7.0" @@ -582,6 +653,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -645,6 +722,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -691,8 +777,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", ] [[package]] @@ -1169,6 +1266,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "0.14.32" @@ -2288,7 +2394,7 @@ version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ - "sha2", + "sha2 0.10.9", "walkdir", ] @@ -2628,7 +2734,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -2639,7 +2745,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2793,6 +2910,12 @@ dependencies = [ "loom", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -3584,6 +3707,15 @@ version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "which" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +dependencies = [ + "libc", +] + [[package]] name = "winapi-util" version = "0.1.11" diff --git a/Cargo.toml b/Cargo.toml index 6f476f0c16c9..f0be9353b25b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "plugins/wss-proxy-plugin", "plugins/bip353-plugin", "plugins/currencyrate-plugin", + "plugins/reckless-plugin", ] [workspace.dependencies] diff --git a/Makefile b/Makefile index 72c8d7757647..396bbde92031 100644 --- a/Makefile +++ b/Makefile @@ -460,8 +460,7 @@ ALL_NONGEN_SRCFILES := $(ALL_NONGEN_HEADERS) $(ALL_NONGEN_SOURCES) BIN_PROGRAMS = \ cli/lightning-cli \ lightningd/lightningd \ - tools/lightning-hsmtool\ - tools/reckless + tools/lightning-hsmtool PKGLIBEXEC_PROGRAMS = \ lightningd/lightning_channeld \ lightningd/lightning_closingd \ @@ -867,7 +866,7 @@ clean: obsclean # See doc/contribute-to-core-lightning/contributor-workflow.md PYLNS=client proto testing -update-versions: update-pyln-versions update-reckless-version update-dot-version # FIXME: update-doc-examples +update-versions: update-pyln-versions update-dot-version # FIXME: update-doc-examples @uv lock update-pyln-versions: $(PYLNS:%=update-pyln-version-%) @@ -905,11 +904,6 @@ pyln-build-all: pyln-build pyln-build-bolts pyln-build-grpc-proto pyln-build-wss update-lock: uv sync --all-extras --all-groups -update-reckless-version: - @if [ -z "$(NEW_VERSION)" ]; then echo "Set NEW_VERSION!" >&2; exit 1; fi - @echo "Updating tools/reckless to $(NEW_VERSION)" - @$(SED) -i.bak "s/__VERSION__ = '.*'/__VERSION__ = '$(NEW_VERSION)'/" tools/reckless && rm tools/reckless.bak - update-dot-version: @if [ -z "$(NEW_VERSION)" ]; then echo "Set NEW_VERSION!" >&2; exit 1; fi echo $(NEW_VERSION) > .version diff --git a/contrib/pyln-testing/pyln/testing/utils.py b/contrib/pyln-testing/pyln/testing/utils.py index c8c5d6b0c0cf..0b4d77e9dfb7 100644 --- a/contrib/pyln-testing/pyln/testing/utils.py +++ b/contrib/pyln-testing/pyln/testing/utils.py @@ -1009,7 +1009,7 @@ def __init__(self, node_id, lightning_dir, bitcoind, executor, may_fail=False, if dsn is not None: self.daemon.opts['wallet'] = dsn if VALGRIND: - trace_skip_pattern = '*python*,*bitcoin-cli*,*elements-cli*,*cln-grpc*,*clnrest*,*wss-proxy*,*cln-bip353*,*reckless,*cln-currencyrate*' + trace_skip_pattern = '*python*,*bitcoin-cli*,*elements-cli*,*cln-grpc*,*clnrest*,*wss-proxy*,*cln-bip353*,*reckless,*cln-currencyrate*,*cln-reckless*' if not valgrind_plugins: trace_skip_pattern += ',*plugins*' self.daemon.cmd_prefix = [ diff --git a/plugins/.gitignore b/plugins/.gitignore index 06daa728eca9..badd5cb51948 100644 --- a/plugins/.gitignore +++ b/plugins/.gitignore @@ -23,3 +23,4 @@ cln-xpay cln-lsps-client cln-lsps-service cln-bwatch +cln-reckless diff --git a/plugins/Makefile b/plugins/Makefile index d302cc74d597..c731401a654a 100644 --- a/plugins/Makefile +++ b/plugins/Makefile @@ -87,9 +87,6 @@ PLUGIN_FUNDER_HEADER := \ plugins/funder_policy.h PLUGIN_FUNDER_OBJS := $(PLUGIN_FUNDER_SRC:.c=.o) -PLUGIN_RECKLESSRPC_SRC := plugins/recklessrpc.c -PLUGIN_RECKLESSRPC_OBJS := $(PLUGIN_RECKLESSRPC_SRC:.c=.o) - PLUGIN_ALL_SRC := \ $(PLUGIN_AUTOCLEAN_SRC) \ $(PLUGIN_chanbackup_SRC) \ @@ -106,8 +103,7 @@ PLUGIN_ALL_SRC := \ $(PLUGIN_PAY_LIB_SRC) \ $(PLUGIN_PAY_SRC) \ $(PLUGIN_SPENDER_SRC) \ - $(PLUGIN_RECOVER_SRC) \ - $(PLUGIN_RECKLESSRPC_SRC) + $(PLUGIN_RECOVER_SRC) PLUGIN_ALL_HEADER := \ $(PLUGIN_PAY_HEADER) \ @@ -130,7 +126,6 @@ C_PLUGINS := \ plugins/keysend \ plugins/offers \ plugins/pay \ - plugins/recklessrpc \ plugins/recover \ plugins/txprepare \ plugins/cln-renepay \ @@ -153,7 +148,7 @@ $(shell test -d plugins/clnrest && $(RM) -r plugins/clnrest || true) $(shell test -d plugins/wss-proxy && $(RM) -r plugins/wss-proxy || true) ifneq ($(RUST),0) -RUST_PLUGIN_NAMES := cln-grpc clnrest cln-lsps-client cln-lsps-service wss-proxy cln-bip353 cln-currencyrate +RUST_PLUGIN_NAMES := cln-grpc clnrest cln-lsps-client cln-lsps-service wss-proxy cln-bip353 cln-currencyrate cln-reckless # Builtin plugins must be in this plugins dir to work when we're executed # *without* make install. @@ -218,8 +213,6 @@ plugins/funder: $(PLUGIN_FUNDER_OBJS) $(PLUGIN_LIB_OBJS) libcommon.a plugins/recover: $(PLUGIN_RECOVER_OBJS) $(PLUGIN_LIB_OBJS) libcommon.a -plugins/recklessrpc: $(PLUGIN_RECKLESSRPC_OBJS) $(PLUGIN_LIB_OBJS) libcommon.a - # This covers all the low-level list RPCs which return simple arrays SQL_LISTRPCS := listchannels listforwards listhtlcs listinvoices listnodes listoffers listpeers listpeerchannels listclosedchannels listtransactions listsendpays listchainmoves listchannelmoves bkpr-listaccountevents bkpr-listincome listnetworkevents SQL_LISTRPCS_SCHEMAS := $(foreach l,$(SQL_LISTRPCS),doc/schemas/$l.json) @@ -274,6 +267,7 @@ CLN_LSPS_PLUGIN_SRC = $(shell find plugins/lsps-plugin/src -name "*.rs") CLN_WSS_PROXY_PLUGIN_SRC = $(shell find plugins/wss-proxy-plugin/src -name "*.rs") CLN_BIP353_PLUGIN_SRC = $(shell find plugins/bip353-plugin/src -name "*.rs") CLN_CURRENCYRATE_PLUGIN_SRC = $(shell find plugins/currencyrate-plugin/src -name "*.rs") +CLN_RECKLESS_PLUGIN_SRC = $(shell find plugins/reckless-plugin/src -name "*.rs") $(RUST_TARGET_DIR)/cln-grpc: ${CLN_PLUGIN_SRC} ${CLN_GRPC_PLUGIN_SRC} $(MSGGEN_GENALL) $(MSGGEN_GEN_ALL) $(CARGO) build ${CARGO_OPTS} --bin cln-grpc @@ -289,11 +283,13 @@ $(RUST_TARGET_DIR)/cln-bip353: ${CLN_BIP353_PLUGIN_SRC} $(CARGO) build ${CARGO_OPTS} --bin cln-bip353 $(RUST_TARGET_DIR)/cln-currencyrate: ${CLN_CURRENCYRATE_PLUGIN_SRC} $(CARGO) build ${CARGO_OPTS} --bin cln-currencyrate +$(RUST_TARGET_DIR)/cln-reckless: ${CLN_RECKLESS_PLUGIN_SRC} + $(CARGO) build ${CARGO_OPTS} --bin cln-reckless ifneq ($(RUST),0) include plugins/rest-plugin/Makefile include plugins/wss-proxy-plugin/Makefile -DEFAULT_TARGETS += $(CLN_PLUGIN_EXAMPLES) plugins/cln-grpc plugins/clnrest plugins/cln-lsps-client plugins/cln-lsps-service plugins/wss-proxy plugins/cln-bip353 plugins/cln-currencyrate +DEFAULT_TARGETS += $(CLN_PLUGIN_EXAMPLES) plugins/cln-grpc plugins/clnrest plugins/cln-lsps-client plugins/cln-lsps-service plugins/wss-proxy plugins/cln-bip353 plugins/cln-currencyrate plugins/cln-reckless endif clean: plugins-clean diff --git a/plugins/reckless-plugin/Cargo.toml b/plugins/reckless-plugin/Cargo.toml new file mode 100644 index 000000000000..eb5fef18dd7d --- /dev/null +++ b/plugins/reckless-plugin/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "cln-reckless" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Install plugins from remote or local repositories" +homepage = "https://github.com/ElementsProject/lightning/tree/master/plugins" +repository = "https://github.com/ElementsProject/lightning" +rust-version.workspace = true + +[dependencies] +anyhow = "1" +log = { version = "0.4", features = ['std'] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["fs", "process"] } +tokio-util = { version = "0.7", features = ["codec"] } + +futures = "0.3" + +log-panics = "2" + +which = "8" +url = "2" +sha2 = "0.11" +chrono = "0.4" +hex = "0.4" + +clap = { version = "4", features = ["derive"] } + +cln-plugin = { workspace = true } +cln-rpc = { workspace = true } diff --git a/plugins/reckless-plugin/src/cmds.rs b/plugins/reckless-plugin/src/cmds.rs new file mode 100644 index 000000000000..52e6f7d19609 --- /dev/null +++ b/plugins/reckless-plugin/src/cmds.rs @@ -0,0 +1,7 @@ +pub mod enable; +pub mod install; +pub mod search; +pub mod source; +pub mod tip; +pub mod update; +pub mod version; diff --git a/plugins/reckless-plugin/src/cmds/enable.rs b/plugins/reckless-plugin/src/cmds/enable.rs new file mode 100644 index 000000000000..838f4b52b088 --- /dev/null +++ b/plugins/reckless-plugin/src/cmds/enable.rs @@ -0,0 +1,212 @@ +use anyhow::anyhow; +use cln_plugin::Plugin; +use cln_rpc::notifications::LogLevel; +use serde_json::json; + +use crate::{ + structs::{EnableArgs, PluginState, RecklessPlugin, RecklessTopic}, + util::{ + RecklessLogger, add_plugin_to_config, cln_list_plugins, cln_start_plugin, cln_stop_plugin, + find_entryfile, get_plugin_manifest, parse_options, parse_target, read_reckless_manifest, + remove_plugin_from_config, search_sources, + }, +}; + +pub async fn handle_enable( + plugin: Plugin, + enable_args: EnableArgs, + verbose: bool, +) -> Result { + let mut result = Vec::new(); + let mut log = Vec::new(); + let mut logger = RecklessLogger { + plugin: &plugin, + log: &mut log, + topic: RecklessTopic::Enable, + verbose, + }; + + let (plugin_name, git_ref) = match parse_target(&enable_args.target) { + Ok((n, g)) => (n, g), + Err(e) => { + logger.log(&e.to_string(), LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + if git_ref.is_some() { + let line = "git refs are not supported here"; + logger.log(line, LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + + let mut search_results = + match search_sources(plugin.clone(), Some(plugin_name.clone()), &mut logger).await { + Ok(s) => s, + Err(e) => { + logger.log(&e.to_string(), LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + let Some(rl_plugin) = search_results.get_mut(&plugin_name) else { + let line = format!("{plugin_name} not found in any known sources"); + logger.log(&line, LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + }; + + if !rl_plugin.path().exists() { + let line = format!("{plugin_name} is not installed"); + logger.log(&line, LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + + match enable_plugin( + plugin.clone(), + &plugin_name, + rl_plugin, + enable_args.options, + &mut logger, + ) + .await + { + Ok(()) => result.push(plugin_name), + Err(e) => { + logger.log(&e.to_string(), LogLevel::UNUSUAL).await?; + } + } + + Ok(json!({"result": result, "log": log})) +} + +pub async fn enable_plugin( + plugin: Plugin, + plugin_name: &str, + rl_plugin: &RecklessPlugin, + options: Vec<(String, Option)>, + logger: &mut RecklessLogger<'_>, +) -> Result<(), anyhow::Error> { + let plugin_entry = rl_plugin + .path() + .join(find_entryfile(rl_plugin.path(), plugin_name).await?); + + let entry_file = plugin_entry + .file_name() + .ok_or_else(|| anyhow!("plugin entry path has no last segment"))? + .to_str() + .ok_or_else(|| anyhow!("entry filename has invalid unicode"))? + .to_owned(); + + let plugin_manifest = get_plugin_manifest(&plugin_entry, logger).await?; + logger + .log(&format!("{plugin_manifest:#?}"), LogLevel::TRACE) + .await?; + + let parsed_options = parse_options(&plugin_manifest, &options)?; + + let rl_manifest = read_reckless_manifest(rl_plugin.source_path()).await?; + + if let Some(req_opts) = rl_manifest.and_then(|rl_m| rl_m.required_options) { + for option in req_opts { + if !parsed_options.iter().any(|(o, _)| o == &option) { + return Err(anyhow!("option `{option}` is required")); + } + } + } + + let running_plugins = cln_list_plugins(plugin.clone(), logger).await?; + if running_plugins.contains(&entry_file) { + let line = format!("Plugin {plugin_name} is already running"); + logger.log(&line, LogLevel::INFO).await?; + } else if !running_plugins.contains(&entry_file) && plugin_manifest.is_dynamic() { + cln_start_plugin(plugin.clone(), plugin_name, &plugin_entry, options, logger).await?; + } else if !plugin_manifest.is_dynamic() { + let line = format!( + "{plugin_name} is not dynamic and will be started the next time the node starts" + ); + logger.log(&line, LogLevel::INFO).await?; + } + + match add_plugin_to_config( + plugin.clone(), + plugin_entry, + parsed_options, + plugin_manifest, + ) + .await + { + Ok(()) => { + let line = format!("{plugin_name} enabled"); + logger.log(&line, LogLevel::INFO).await?; + } + Err(e) => { + return Err(anyhow!("{plugin_name} failed to enable: {e}")); + } + } + + Ok(()) +} + +pub async fn handle_disable( + plugin: Plugin, + target: String, + verbose: bool, +) -> Result { + let mut result = Vec::new(); + let mut log = Vec::new(); + let mut logger = RecklessLogger { + plugin: &plugin, + log: &mut log, + topic: RecklessTopic::Enable, + verbose, + }; + + let (plugin_name, git_ref) = match parse_target(&target) { + Ok((pn, g)) => (pn, g), + Err(e) => { + logger.log(&e.to_string(), LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + if git_ref.is_some() { + let line = "git refs are not supported here"; + logger.log(line, LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + + match disable_plugin(plugin.clone(), &plugin_name, &mut logger).await { + Ok(()) => result.push(plugin_name), + Err(e) => { + logger.log(&e.to_string(), LogLevel::BROKEN).await?; + } + } + + Ok(json!({"result": result, "log": log})) +} + +pub async fn disable_plugin( + plugin: Plugin, + plugin_name: &str, + logger: &mut RecklessLogger<'_>, +) -> Result<(), anyhow::Error> { + let install_path = plugin.state().reckless_dir.join(plugin_name); + let entry_file = find_entryfile(&install_path, plugin_name).await?; + let entry_path = install_path.join(&entry_file); + + let manifest = get_plugin_manifest(&entry_path, logger).await?; + logger + .log(&format!("{manifest:#?}"), LogLevel::TRACE) + .await?; + + let running_plugins = cln_list_plugins(plugin.clone(), logger).await?; + if running_plugins.contains(&entry_file) { + cln_stop_plugin(plugin.clone(), plugin_name, &entry_path, logger).await?; + } else { + let line = format!("{plugin_name} already stopped"); + logger.log(&line, LogLevel::INFO).await?; + } + + remove_plugin_from_config(plugin.clone(), entry_path, manifest).await?; + let line = format!("{plugin_name} disabled"); + logger.log(&line, LogLevel::INFO).await?; + + Ok(()) +} diff --git a/plugins/reckless-plugin/src/cmds/install.rs b/plugins/reckless-plugin/src/cmds/install.rs new file mode 100644 index 000000000000..8797d7d738b3 --- /dev/null +++ b/plugins/reckless-plugin/src/cmds/install.rs @@ -0,0 +1,276 @@ +use std::{collections::HashMap, path::PathBuf}; + +use anyhow::anyhow; +use cln_plugin::Plugin; +use cln_rpc::notifications::LogLevel; +use serde_json::json; +use tokio::{fs, process::Command}; + +use crate::{ + cmds::enable::{disable_plugin, enable_plugin}, + installers::{ + install_custom_plugin, install_go_plugin, install_nodejs_plugin, install_poetry_plugin, + install_python_plugin, install_rust_plugin, install_uv_legacy_plugin, install_uv_plugin, + install_uv_shebang_plugin, + }, + structs::{InstallArgs, Installer, PluginState, RecklessPlugin, RecklessTopic}, + util::{ + RecklessLogger, copy_dir_all, detect_installer, parse_install_target, parse_target, + run_logged_command, search_sources, write_metadata, + }, +}; + +pub async fn handle_install( + plugin: Plugin, + install_args: InstallArgs, + verbose: bool, +) -> Result { + let mut result: Vec = Vec::new(); + let mut log: Vec = Vec::new(); + let mut logger = RecklessLogger { + plugin: &plugin, + log: &mut log, + topic: RecklessTopic::Install, + verbose, + }; + + let mut search_results: HashMap = HashMap::new(); + + let (plugin_name, git_ref) = match parse_install_target( + &mut logger, + &install_args.target, + &mut search_results, + &plugin.state().reckless_dir, + ) + .await + { + Ok((pn, g)) => (pn, g), + Err(e) => { + logger.log(&e.to_string(), LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + + if search_results.is_empty() { + search_results = + search_sources(plugin.clone(), Some(plugin_name.clone()), &mut logger).await?; + } + let Some(rl_plugin) = search_results.get_mut(&plugin_name) else { + let line = format!("{plugin_name} not found in any known sources"); + logger.log(&line, LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + }; + rl_plugin.set_requested_commit(git_ref); + + if rl_plugin.path().exists() { + let line = format!("{plugin_name} is already installed"); + logger.log(&line, LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + + let install_result = + install(&plugin_name, rl_plugin, &mut logger, install_args.developer).await; + let mut try_enable = false; + match install_result { + Ok(entrypoint) => { + let line = format!( + "{plugin_name} installed, entrypoint: {}", + entrypoint.display() + ); + logger.log(&line, LogLevel::DEBUG).await?; + + try_enable = true; + } + Err(e) => { + let line = format!("{plugin_name} install failed: {e}"); + logger.log(&line, LogLevel::UNUSUAL).await?; + if let Err(e) = uninstall(plugin.clone(), plugin_name.clone(), &mut logger).await { + let line = format!("{plugin_name} uninstall failed: {e}"); + logger.log(&line, LogLevel::UNUSUAL).await?; + } + } + } + + if try_enable { + match enable_plugin( + plugin.clone(), + &plugin_name, + rl_plugin, + install_args.options, + &mut logger, + ) + .await + { + Ok(()) => (), + Err(e) => { + logger.log(&e.to_string(), LogLevel::UNUSUAL).await?; + let line = format!( + "{plugin_name} failed to start, it may require options, read the logs!" + ); + logger.log(&line, LogLevel::UNUSUAL).await?; + } + } + result.push(plugin_name); + } + + Ok(json!({"result": result, "log": log})) +} + +async fn install( + plugin_name: &str, + rl_plugin: &mut RecklessPlugin, + logger: &mut RecklessLogger<'_>, + developer: bool, +) -> Result { + if !rl_plugin.origin_plugin_path().exists() { + return Err(anyhow!( + "origin path does not exist: {}", + rl_plugin.origin_repo_path().display() + )); + } + + fs::create_dir_all(rl_plugin.path()).await?; + + if !rl_plugin.is_local_path() { + let mut command = Command::new("git"); + command + .args(["pull", "--ff-only"]) + .current_dir(rl_plugin.origin_repo_path()); + run_logged_command(command, logger).await?; + + let mut command = Command::new("git"); + command + .args(["submodule", "sync", "--recursive"]) + .current_dir(rl_plugin.origin_repo_path()); + run_logged_command(command, logger).await?; + + let mut command = Command::new("git"); + command + .args(["submodule", "update", "--init", "--recursive"]) + .current_dir(rl_plugin.origin_repo_path()); + run_logged_command(command, logger).await?; + + let mut command = Command::new("git"); + command + .args(["checkout", rl_plugin.requested_commit().unwrap_or("HEAD")]) + .current_dir(rl_plugin.origin_plugin_path()); + run_logged_command(command, logger).await?; + + fs::create_dir_all(rl_plugin.source_path()).await?; + copy_dir_all( + rl_plugin.origin_plugin_path(), + rl_plugin.source_path(), + logger, + ) + .await?; + } + + let installer = detect_installer(rl_plugin.source_path()).await?; + + let entrypoint = match installer { + Installer::PythonUv => install_uv_plugin(plugin_name, rl_plugin, logger).await?, + Installer::PythonUvShebang => { + install_uv_shebang_plugin(plugin_name, rl_plugin, logger).await? + } + Installer::PythonUvLegacy => { + install_uv_legacy_plugin(plugin_name, rl_plugin, logger).await? + } + Installer::PoetryVenv => install_poetry_plugin(plugin_name, rl_plugin, logger).await?, + Installer::PyprojectViaPip | Installer::Python => { + install_python_plugin(plugin_name, rl_plugin, logger).await? + } + Installer::Nodejs => install_nodejs_plugin(plugin_name, rl_plugin, logger).await?, + Installer::Rust => install_rust_plugin(plugin_name, rl_plugin, logger, developer).await?, + Installer::Go => install_go_plugin(plugin_name, rl_plugin, logger).await?, + Installer::Custom => install_custom_plugin(plugin_name, rl_plugin, logger).await?, + }; + + if !rl_plugin.is_local_path() { + let mut command = Command::new("git"); + command + .args(["rev-parse", "HEAD"]) + .current_dir(rl_plugin.origin_plugin_path()); + let commit_hash = run_logged_command(command, logger).await?; + + let installed_ref = if let Some(gr) = rl_plugin.requested_commit() { + gr.to_owned() + } else { + commit_hash + }; + + rl_plugin.set_installed_commit(installed_ref); + } + + write_metadata(rl_plugin).await?; + + let line = format!("plugin installed: {}", entrypoint.display()); + logger.log(&line, LogLevel::INFO).await?; + + Ok(entrypoint) +} + +pub async fn handle_uninstall( + plugin: Plugin, + target: String, + verbose: bool, +) -> Result { + let mut result: Vec = Vec::new(); + let mut log: Vec = Vec::new(); + let mut logger = RecklessLogger { + plugin: &plugin, + log: &mut log, + topic: RecklessTopic::Install, + verbose, + }; + + let (name, git_ref) = match parse_target(&target) { + Ok((pn, g)) => (pn, g), + Err(e) => { + logger.log(&e.to_string(), LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + if git_ref.is_some() { + let line = "git refs are not supported here"; + logger.log(line, LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + + match uninstall(plugin.clone(), name.clone(), &mut logger).await { + Ok(()) => result.push(name), + Err(e) => { + let line = format!("{name} NOT uninstalled: {e}"); + logger.log(&line, LogLevel::INFO).await?; + } + } + + Ok(json!({"result":result, "log":log})) +} + +async fn uninstall( + plugin: Plugin, + plugin_name: String, + logger: &mut RecklessLogger<'_>, +) -> Result<(), anyhow::Error> { + let install_path = plugin.state().reckless_dir.join(&plugin_name); + if !install_path.exists() { + return Err(anyhow!("plugin is not installed")); + } + + if let Err(e) = disable_plugin(plugin.clone(), &plugin_name, logger).await { + let line = format!("{plugin_name} NOT disabled: {e}, remove plugin from config manually!"); + logger.log(&line, LogLevel::UNUSUAL).await?; + } + + match fs::remove_dir_all(&install_path).await { + Ok(()) => { + let line = format!("{plugin_name} uninstalled"); + logger.log(&line, LogLevel::INFO).await?; + } + Err(e) => { + return Err(e.into()); + } + } + + Ok(()) +} diff --git a/plugins/reckless-plugin/src/cmds/search.rs b/plugins/reckless-plugin/src/cmds/search.rs new file mode 100644 index 000000000000..3214bf7573ef --- /dev/null +++ b/plugins/reckless-plugin/src/cmds/search.rs @@ -0,0 +1,145 @@ +use std::collections::HashMap; + +use cln_plugin::Plugin; +use cln_rpc::notifications::LogLevel; +use serde_json::{Map, Value, json}; +use tokio::fs; + +use crate::{ + structs::{Metadata, PluginState, RecklessTopic}, + util::{RecklessLogger, parse_target, read_metadata, read_reckless_manifest, search_sources}, +}; + +pub async fn handle_search( + plugin: Plugin, + target: String, + verbose: bool, +) -> Result { + let mut result: Vec = Vec::new(); + let mut log: Vec = Vec::new(); + let mut logger = RecklessLogger { + plugin: &plugin, + log: &mut log, + topic: RecklessTopic::Search, + verbose, + }; + + let (name, git_ref) = match parse_target(&target) { + Ok((pn, g)) => (pn, g), + Err(e) => { + logger.log(&e.to_string(), LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + if git_ref.is_some() { + let line = "git refs are not supported here"; + logger.log(line, LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + + let exact_matches = match search_sources(plugin.clone(), Some(name.clone()), &mut logger).await + { + Ok(s) => s, + Err(e) => { + logger.log(&e.to_string(), LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + if let Some(plugin) = exact_matches.get(&name) { + result.push(plugin.origin().to_owned()); + } else { + result.push("null".to_owned()); + } + + Ok(json!({"result": result, "log": log})) +} + +pub async fn handle_list_available( + plugin: Plugin, + verbose: bool, +) -> Result { + let mut log = Vec::new(); + let mut result = Vec::new(); + let mut logger = RecklessLogger { + plugin: &plugin, + log: &mut log, + topic: RecklessTopic::Search, + verbose, + }; + let reckless_plugins = match search_sources(plugin.clone(), None, &mut logger).await { + Ok(o) => o, + Err(e) => { + logger.log(&e.to_string(), LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + + for (plugin_name, plugin) in reckless_plugins { + let mut entry = Map::new(); + let reckless_manifests = read_reckless_manifest(plugin.source_path()).await?; + entry.insert("name".to_owned(), json!(plugin_name)); + let Ok(Value::Object(plugin_json)) = serde_json::to_value(&plugin) else { + logger + .log("failed to serialize plugin", LogLevel::BROKEN) + .await?; + return Ok(json!({"result": result, "log": log})); + }; + entry.extend(plugin_json); + + if let Some(manifest) = reckless_manifests { + let Ok(Value::Object(m)) = serde_json::to_value(&manifest) else { + logger + .log("failed to serialize manifest", LogLevel::BROKEN) + .await?; + return Ok(json!({"result": result, "log": log})); + }; + entry.extend(m); + } + result.push(entry); + } + + Ok(json!({"result": result, "log": log})) +} + +pub async fn handle_list_installed( + plugin: Plugin, + verbose: bool, +) -> Result { + let mut log: Vec = Vec::new(); + let mut logger = RecklessLogger { + plugin: &plugin, + log: &mut log, + topic: RecklessTopic::Search, + verbose, + }; + + let result = match list_installed(plugin.clone()).await { + Ok(i) => i, + Err(e) => { + logger.log(&e.to_string(), LogLevel::BROKEN).await?; + HashMap::new() + } + }; + + Ok(json!({"result": result, "log": log})) +} + +pub async fn list_installed( + plugin: Plugin, +) -> Result, anyhow::Error> { + let mut result: HashMap = HashMap::new(); + let mut entries = fs::read_dir(&plugin.state().reckless_dir).await?; + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if file_type.is_file() { + continue; + } + if let Ok(metadata) = read_metadata(&entry.path()).await { + result.insert(entry.file_name().to_string_lossy().to_string(), metadata); + } + } + + Ok(result) +} diff --git a/plugins/reckless-plugin/src/cmds/source.rs b/plugins/reckless-plugin/src/cmds/source.rs new file mode 100644 index 000000000000..c5f47a694771 --- /dev/null +++ b/plugins/reckless-plugin/src/cmds/source.rs @@ -0,0 +1,269 @@ +use std::{path::PathBuf, str::FromStr}; + +use anyhow::anyhow; +use cln_plugin::Plugin; +use cln_rpc::notifications::LogLevel; +use serde_json::json; +use tokio::{ + fs::{self, OpenOptions}, + io::AsyncWriteExt, +}; +use url::Url; + +use crate::{ + structs::{PluginState, RecklessTopic}, + util::{RecklessLogger, read_sources_file, repo_path_from_url, validate_path}, +}; + +pub async fn handle_source_list( + plugin: Plugin, + verbose: bool, +) -> Result { + let mut result = Vec::new(); + let mut log = Vec::::new(); + let mut logger = RecklessLogger { + plugin: &plugin, + log: &mut log, + topic: RecklessTopic::Search, + verbose, + }; + + let (urls, paths, _source_file) = match read_sources_file(plugin.clone()).await { + Ok(res) => res, + Err(e) => { + logger.log(&e.to_string(), LogLevel::BROKEN).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + + for url in &urls { + logger.log(url.as_ref(), LogLevel::INFO).await?; + } + for path in &paths { + let line = format!("{}", path.display()); + logger.log(&line, LogLevel::INFO).await?; + } + + result.extend(urls.iter().map(url::Url::as_str).collect::>()); + result.extend( + paths + .iter() + .filter_map(|p| p.to_str()) + .collect::>(), + ); + + Ok(json!({"result": result, "log": log})) +} + +pub async fn handle_source_add( + plugin: Plugin, + target: String, + verbose: bool, +) -> Result { + let mut result: Vec<&str> = Vec::new(); + let mut log: Vec = Vec::new(); + let mut logger = RecklessLogger { + plugin: &plugin, + log: &mut log, + topic: RecklessTopic::Source, + verbose, + }; + + let (urls, paths, source_file) = match read_sources_file(plugin.clone()).await { + Ok(res) => res, + Err(e) => { + logger.log(&e.to_string(), LogLevel::BROKEN).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + + let mut file_handle = match OpenOptions::new().append(true).open(source_file).await { + Ok(f) => f, + Err(e) => { + logger.log(&e.to_string(), LogLevel::BROKEN).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + + let target_trimmed = target.trim(); + let url_result = Url::from_str(target_trimmed); + let path_result = validate_path(target_trimmed); + if let Ok(url) = url_result { + if !urls.contains(&url) { + if let Err(e) = file_handle + .write_all(format!("{target_trimmed}\n").as_bytes()) + .await + { + logger.log(&e.to_string(), LogLevel::BROKEN).await?; + return Ok(json!({"result": result, "log": log})); + } + result.push(target_trimmed); + } + } else if let Ok(path) = path_result { + if !paths.contains(&path) { + if let Err(e) = file_handle + .write_all(format!("{target_trimmed}\n").as_bytes()) + .await + { + logger.log(&e.to_string(), LogLevel::BROKEN).await?; + return Ok(json!({"result": result, "log": log})); + } + result.push(target_trimmed); + } + } else { + let line = format!( + "failed to add source {target_trimmed}, not a valid URL:{} or path:{}", + url_result.err().unwrap(), + path_result.err().unwrap() + ); + logger.log(&line, LogLevel::UNUSUAL).await?; + } + + Ok(json!({"result": result, "log": log})) +} + +pub async fn handle_source_remove( + plugin: Plugin, + target: String, + verbose: bool, +) -> Result { + let mut result: Vec<&str> = Vec::new(); + let mut log: Vec = Vec::new(); + let mut logger = RecklessLogger { + plugin: &plugin, + log: &mut log, + topic: RecklessTopic::Source, + verbose, + }; + + let (urls, paths, source_file) = match read_sources_file(plugin.clone()).await { + Ok(res) => res, + Err(e) => { + logger.log(&e.to_string(), LogLevel::BROKEN).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + + let target_trimmed = target.trim(); + + let (remove_urls, remove_paths) = + match remove_source(&plugin, target_trimmed, &mut result, &mut logger).await { + Ok(res) => res, + Err(e) => { + logger.log(&e.to_string(), LogLevel::BROKEN).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + + let Ok(mut file_handle) = OpenOptions::new() + .write(true) + .truncate(true) + .open(&source_file) + .await + else { + logger + .log( + &format!("failed to open sources file: {}", source_file.display()), + LogLevel::BROKEN, + ) + .await?; + return Ok(json!({"result": result, "log": log})); + }; + + for url in &urls { + if !remove_urls.contains(url) { + if let Err(e) = file_handle.write_all(format!("{url}\n").as_bytes()).await { + logger.log(&e.to_string(), LogLevel::BROKEN).await?; + return Ok(json!({"result": result, "log": log})); + } + } + } + for path in &paths { + if !remove_paths.contains(path) { + if let Err(e) = file_handle + .write_all(format!("{}\n", path.to_str().unwrap()).as_bytes()) + .await + { + logger.log(&e.to_string(), LogLevel::BROKEN).await?; + return Ok(json!({"result": result, "log": log})); + } + } + } + Ok(json!({"result": result, "log": log})) +} + +async fn remove_source<'a>( + plugin: &Plugin, + target: &'a str, + result: &mut Vec<&'a str>, + logger: &mut RecklessLogger<'_>, +) -> Result<(Vec, Vec), anyhow::Error> { + let mut remove_urls: Vec = Vec::new(); + let mut remove_paths: Vec = Vec::new(); + + let url_result = Url::from_str(target); + let path_result = validate_path(target); + if let Ok(url) = url_result { + let repo_path = plugin.state().reckless_dir.join(repo_path_from_url(&url)?); + match fs::remove_dir_all(&repo_path).await { + Ok(()) => { + let line = format!("source directory removed: {}", repo_path.display()); + logger.log(&line, LogLevel::INFO).await?; + remove_urls.push(url); + result.push(target); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let line = format!("source directory never existed: {}", repo_path.display()); + logger.log(&line, LogLevel::INFO).await?; + remove_urls.push(url); + result.push(target); + } + Err(e) => { + let line = format!( + "failed to remove source directory: {}: {}", + repo_path.display(), + e + ); + logger.log(&line, LogLevel::UNUSUAL).await?; + } + } + + match fs::read_dir(&repo_path).await { + Ok(mut entries) => { + if entries.next_entry().await?.is_none() { + fs::remove_dir_all( + repo_path + .parent() + .ok_or_else(|| anyhow!("source repo has no owner directory"))?, + ) + .await?; + } + } + Err(e) => { + logger + .log( + &format!( + "failed to read source directory: {} {e}", + repo_path.display() + ), + LogLevel::BROKEN, + ) + .await?; + } + } + } else if let Ok(path) = path_result { + let line = format!("plugin source removed: {target}"); + logger.log(&line, LogLevel::INFO).await?; + remove_paths.push(path); + result.push(target); + } else { + let line = format!( + "failed to remove source {target}, not a valid URL:{} or path:{}", + url_result.err().unwrap(), + path_result.err().unwrap() + ); + logger.log(&line, LogLevel::UNUSUAL).await?; + } + + Ok((remove_urls, remove_paths)) +} diff --git a/plugins/reckless-plugin/src/cmds/tip.rs b/plugins/reckless-plugin/src/cmds/tip.rs new file mode 100644 index 000000000000..e6c5cdc30bdd --- /dev/null +++ b/plugins/reckless-plugin/src/cmds/tip.rs @@ -0,0 +1,113 @@ +use anyhow::anyhow; +use cln_plugin::Plugin; +use cln_rpc::{model::requests::XpayRequest, notifications::LogLevel, primitives::Amount}; +use serde_json::json; + +use crate::{ + structs::{PluginState, RecklessTopic, TipArgs}, + util::{RecklessLogger, parse_target, read_reckless_manifest, search_sources}, +}; + +pub async fn handle_tip( + plugin: Plugin, + args: TipArgs, + verbose: bool, +) -> Result { + let mut log: Vec = Vec::new(); + let mut result: Vec = Vec::new(); + let mut logger = RecklessLogger { + plugin: &plugin, + log: &mut log, + topic: RecklessTopic::Tip, + verbose, + }; + + let (plugin_name, git_ref) = match parse_target(&args.target) { + Ok(res) => res, + Err(e) => { + logger.log(&e.to_string(), LogLevel::UNUSUAL).await?; + return Ok(json!({"result":result, "log":log})); + } + }; + if git_ref.is_some() { + let line = "git refs are not supported here"; + logger.log(line, LogLevel::UNUSUAL).await?; + return Ok(json!({"result":result, "log":log})); + } + + let mut search_results = + match search_sources(plugin.clone(), Some(plugin_name.clone()), &mut logger).await { + Ok(s) => s, + Err(e) => { + logger.log(&e.to_string(), LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + let Some(rl_plugin) = search_results.get_mut(&plugin_name) else { + let line = format!("{plugin_name} not found in any known sources"); + logger.log(&line, LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + }; + + let reckless_manifest = match read_reckless_manifest(rl_plugin.source_path()).await { + Ok(res) => res, + Err(e) => { + logger.log(&e.to_string(), LogLevel::BROKEN).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + + let Some(rm) = reckless_manifest else { + return Err(anyhow!("no reckless manifest found for {plugin_name}")); + }; + + let Some(offer) = rm.offer else { + return Err(anyhow!( + "no offer found in reckless manifest for {plugin_name}" + )); + }; + + let line = format!( + "Sending {}msat to {plugin_name} author...", + args.amount_msat + ); + logger.log(&line, LogLevel::INFO).await?; + + let mut rpc = plugin.state().rpc.lock().await; + let xpay = match rpc + .call_typed(&XpayRequest { + amount_msat: Some(Amount::from_msat(args.amount_msat)), + dev_use_shadow: None, + label: None, + localinvreqid: None, + maxdelay: None, + maxfee: None, + partial_msat: None, + payer_note: args.payer_note, + retry_for: None, + layers: None, + invstring: offer, + }) + .await + { + Ok(o) => o, + Err(e) => { + let line = format!( + "Error sending {}msat to {plugin_name} author: {e}", + args.amount_msat + ); + logger.log(&line, LogLevel::UNUSUAL).await?; + return Ok(json!({"result":result, "log":log})); + } + }; + + let line = format!( + "Successfully sent {}msat to {plugin_name} author!", + args.amount_msat + ); + logger.log(&line, LogLevel::INFO).await?; + + result.push(hex::encode(xpay.payment_preimage.to_vec())); + + Ok(json!({"result":result, "log":log})) +} diff --git a/plugins/reckless-plugin/src/cmds/update.rs b/plugins/reckless-plugin/src/cmds/update.rs new file mode 100644 index 000000000000..e3160ee034ae --- /dev/null +++ b/plugins/reckless-plugin/src/cmds/update.rs @@ -0,0 +1,212 @@ +use anyhow::anyhow; +use cln_plugin::Plugin; +use cln_rpc::notifications::LogLevel; +use serde_json::json; +use tokio::{fs, process::Command}; + +use crate::{ + cmds::search::list_installed, + installers::{ + install_custom_plugin, install_go_plugin, install_nodejs_plugin, install_poetry_plugin, + install_python_plugin, install_rust_plugin, install_uv_legacy_plugin, install_uv_plugin, + install_uv_shebang_plugin, + }, + structs::{Installer, PluginState, RecklessPlugin, RecklessTopic, UpdateArgs}, + util::{ + RecklessLogger, copy_dir_all, detect_installer, parse_target, read_metadata, + run_logged_command, search_sources, write_metadata, + }, +}; + +pub async fn handle_update( + plugin: Plugin, + install_args: UpdateArgs, + verbose: bool, +) -> Result { + let mut result: Vec = Vec::new(); + let mut log: Vec = Vec::new(); + let mut logger = RecklessLogger { + plugin: &plugin, + log: &mut log, + topic: RecklessTopic::Install, + verbose, + }; + + let mut ignore_pinned = false; + + let targets = if let Some(target) = install_args.target { + ignore_pinned = true; + vec![target] + } else { + let listinstalled = match list_installed(plugin.clone()).await { + Ok(o) => o, + Err(e) => { + logger.log(&e.to_string(), LogLevel::BROKEN).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + let mut targets = Vec::new(); + for (name, _) in listinstalled { + targets.push(name); + } + targets + }; + + for target in targets { + let (plugin_name, git_ref) = match parse_target(&target) { + Ok(o) => o, + Err(e) => { + logger.log(&e.to_string(), LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + let mut search_results = + match search_sources(plugin.clone(), Some(plugin_name.clone()), &mut logger).await { + Ok(o) => o, + Err(e) => { + logger.log(&e.to_string(), LogLevel::BROKEN).await?; + return Ok(json!({"result": result, "log": log})); + } + }; + let Some(rl_plugin) = search_results.get_mut(&plugin_name) else { + let line = format!("{plugin_name} not found in any known sources"); + logger.log(&line, LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + }; + + if !rl_plugin.path().exists() { + let line = format!("{plugin_name} is not installed"); + logger.log(&line, LogLevel::UNUSUAL).await?; + return Ok(json!({"result": result, "log": log})); + } + + let update_result = update( + &plugin_name, + git_ref, + rl_plugin, + ignore_pinned, + &mut logger, + install_args.developer, + ) + .await; + match update_result { + Ok(()) => result.push(target), + Err(e) => { + logger.log(&e.to_string(), LogLevel::UNUSUAL).await?; + } + } + } + + Ok(json!({"result": result, "log": log})) +} + +async fn update( + plugin_name: &str, + git_ref: Option, + rl_plugin: &mut RecklessPlugin, + ignore_pinned: bool, + logger: &mut RecklessLogger<'_>, + developer: bool, +) -> Result<(), anyhow::Error> { + let metadata = read_metadata(rl_plugin.path()).await?; + + if metadata.requested_commit.is_some() && !ignore_pinned { + return Err(anyhow!("skipping update for pinned plugin: {plugin_name}")); + } + + if !rl_plugin.origin_plugin_path().exists() { + return Err(anyhow!( + "repo does not exist: {}", + rl_plugin.origin_repo_path().display() + )); + } + + if !rl_plugin.is_local_path() { + let mut command = Command::new("git"); + command + .args(["pull", "--ff-only"]) + .current_dir(rl_plugin.origin_repo_path()); + run_logged_command(command, logger).await?; + + let mut command = Command::new("git"); + command + .args(["submodule", "sync", "--recursive"]) + .current_dir(rl_plugin.origin_repo_path()); + run_logged_command(command, logger).await?; + + let mut command = Command::new("git"); + command + .args(["submodule", "update", "--init", "--recursive"]) + .current_dir(rl_plugin.origin_repo_path()); + run_logged_command(command, logger).await?; + + let mut command = Command::new("git"); + command + .args(["checkout", git_ref.as_ref().unwrap_or(&"HEAD".to_owned())]) + .current_dir(rl_plugin.origin_plugin_path()); + run_logged_command(command, logger).await?; + + let mut command = Command::new("git"); + command + .args(["rev-parse", "HEAD"]) + .current_dir(rl_plugin.origin_plugin_path()); + let commit_hash = run_logged_command(command, logger).await?; + + let ref_to_be_installed = if let Some(gr) = &git_ref { + gr.clone() + } else { + commit_hash + }; + + rl_plugin.set_installed_commit(ref_to_be_installed.clone()); + + if metadata.installed_commit == ref_to_be_installed { + let line = + format!("{plugin_name} is already on the latest version: {ref_to_be_installed}"); + logger.log(&line, LogLevel::INFO).await?; + return Ok(()); + } + + let line = format!( + "updating {plugin_name} from {} to {ref_to_be_installed}", + metadata.installed_commit + ); + logger.log(&line, LogLevel::INFO).await?; + + fs::create_dir_all(rl_plugin.source_path()).await?; + + copy_dir_all( + rl_plugin.origin_plugin_path(), + rl_plugin.source_path(), + logger, + ) + .await?; + } + + let installer = detect_installer(rl_plugin.source_path()).await?; + + let entrypoint = match installer { + Installer::PythonUv => install_uv_plugin(plugin_name, rl_plugin, logger).await?, + Installer::PythonUvShebang => { + install_uv_shebang_plugin(plugin_name, rl_plugin, logger).await? + } + Installer::PythonUvLegacy => { + install_uv_legacy_plugin(plugin_name, rl_plugin, logger).await? + } + Installer::PoetryVenv => install_poetry_plugin(plugin_name, rl_plugin, logger).await?, + Installer::PyprojectViaPip | Installer::Python => { + install_python_plugin(plugin_name, rl_plugin, logger).await? + } + Installer::Nodejs => install_nodejs_plugin(plugin_name, rl_plugin, logger).await?, + Installer::Rust => install_rust_plugin(plugin_name, rl_plugin, logger, developer).await?, + Installer::Go => install_go_plugin(plugin_name, rl_plugin, logger).await?, + Installer::Custom => install_custom_plugin(plugin_name, rl_plugin, logger).await?, + }; + + write_metadata(rl_plugin).await?; + + let line = format!("plugin updated: {}", entrypoint.display()); + logger.log(&line, LogLevel::INFO).await?; + + Ok(()) +} diff --git a/plugins/reckless-plugin/src/cmds/version.rs b/plugins/reckless-plugin/src/cmds/version.rs new file mode 100644 index 000000000000..cc3cd5bc7b94 --- /dev/null +++ b/plugins/reckless-plugin/src/cmds/version.rs @@ -0,0 +1,25 @@ +use cln_plugin::Plugin; +use cln_rpc::model::requests::GetinfoRequest; +use serde_json::json; + +use crate::structs::PluginState; + +pub async fn handle_version( + plugin: Plugin, +) -> Result { + let mut result: Vec = Vec::new(); + let log: Vec = Vec::new(); + + let mut rpc = plugin.state().rpc.lock().await; + let getinfo = rpc.call_typed(&GetinfoRequest {}).await?; + + let version = getinfo + .version + .split_once('-') + .unwrap_or((&getinfo.version, "")) + .0; + + result.push(version.to_owned()); + + Ok(json!({"result":result, "log":log})) +} diff --git a/plugins/reckless-plugin/src/installers.rs b/plugins/reckless-plugin/src/installers.rs new file mode 100644 index 000000000000..cfa8853f53a8 --- /dev/null +++ b/plugins/reckless-plugin/src/installers.rs @@ -0,0 +1,638 @@ +use std::{ + collections::{HashMap, VecDeque}, + path::{Path, PathBuf}, +}; + +use anyhow::anyhow; +use cln_rpc::notifications::LogLevel; +use tokio::{fs, process::Command}; + +use crate::{ + structs::RecklessPlugin, + util::{ + RecklessLogger, create_symlink, find_entryfile, read_reckless_manifest, run_logged_command, + }, +}; + +pub async fn install_custom_plugin( + plugin_name: &str, + rl_plugin: &RecklessPlugin, + logger: &mut RecklessLogger<'_>, +) -> Result { + let line = "using installer custom"; + logger.log(line, LogLevel::INFO).await?; + + let Some(rl_manifest) = read_reckless_manifest(rl_plugin.source_path()).await? else { + return Err(anyhow!("custom installer requires a manifest")); + }; + + let entrypoint = Path::new( + rl_manifest + .entrypoint + .as_ref() + .ok_or_else(|| anyhow!("custom installer requires entrypoint in manifest"))?, + ); + let install_cmds = rl_manifest + .install_cmd + .as_ref() + .ok_or_else(|| anyhow!("custom installer requires install_cmd in manifest"))?; + + let line = format!("running install commands for {plugin_name}, this might take a while..."); + logger.log(&line, LogLevel::INFO).await?; + + for install_cmd in install_cmds { + let cmd_parts = install_cmd.split_whitespace().collect::>(); + + if cmd_parts.is_empty() { + return Err(anyhow!("install_cmd in manifest is empty!?")); + } + + let mut cmd = Command::new(cmd_parts.first().unwrap()); + if cmd_parts.len() > 1 { + cmd.args(&cmd_parts[1..]); + } + cmd.current_dir(rl_plugin.source_path()); + run_logged_command(cmd, logger).await?; + } + + let entrypoint_path = rl_plugin.source_path().join(entrypoint); + + if !entrypoint_path.exists() { + return Err(anyhow!("plugin entry not present after install_cmd")); + } + + set_executable(&entrypoint_path).await?; + + let symlink_path = rl_plugin.path().join(plugin_name); + + create_symlink(&entrypoint_path, &symlink_path)?; + + Ok(symlink_path) +} + +pub async fn install_nodejs_plugin( + plugin_name: &str, + rl_plugin: &RecklessPlugin, + logger: &mut RecklessLogger<'_>, +) -> Result { + let line = "using installer nodejs"; + logger.log(line, LogLevel::INFO).await?; + + let line = "installing dependencies with `npm install`..."; + logger.log(line, LogLevel::INFO).await?; + + let mut npm_install = Command::new("npm"); + npm_install + .arg("install") + .current_dir(rl_plugin.source_path()); + run_logged_command(npm_install, logger).await?; + + let line = "dependencies installed successfully"; + logger.log(line, LogLevel::INFO).await?; + + let plugin_entry = find_entryfile(rl_plugin.source_path(), plugin_name).await?; + + let plugin_path = rl_plugin.source_path().join(&plugin_entry); + set_executable(&plugin_path).await?; + + let symlink_path = rl_plugin.path().join(&plugin_entry); + + create_symlink(&plugin_path, &symlink_path)?; + + Ok(symlink_path) +} + +pub async fn install_rust_plugin( + plugin_name: &str, + rl_plugin: &RecklessPlugin, + logger: &mut RecklessLogger<'_>, + developer: bool, +) -> Result { + let line = "using installer rust_cargo"; + logger.log(line, LogLevel::INFO).await?; + + let line = "compiling with `cargo`, this might take a while..."; + logger.log(line, LogLevel::INFO).await?; + + let mut cargo_build = Command::new("cargo"); + cargo_build.arg("build"); + if !developer { + cargo_build.arg("--release"); + } + cargo_build.current_dir(rl_plugin.source_path()); + + run_logged_command(cargo_build, logger).await?; + + let mut cargo_metadata = Command::new("cargo"); + cargo_metadata + .arg("metadata") + .arg("--no-deps") + .arg("--format-version") + .arg("1") + .current_dir(rl_plugin.source_path()); + + let metadata_str = run_logged_command(cargo_metadata, logger).await?; + + let metadata: serde_json::Value = serde_json::from_str(&metadata_str)?; + + let packages = if let Some(p) = metadata.get("packages").and_then(|t| t.as_array()) { + p.clone() + } else { + Vec::new() + }; + + if packages.len() > 1 { + return Err(anyhow!("Multiple packages found in Cargo.toml")); + } + + let mut targets = packages + .first() + .and_then(|p| p.get("targets").and_then(|t| t.as_array().cloned())) + .unwrap_or_default(); + + targets.retain(|p| { + if let Some(serde_json::Value::Array(arr)) = p.get("kind") { + return arr == &["bin"]; + } + false + }); + + if targets.len() > 1 { + return Err(anyhow!("Multiple binaries found in Cargo.toml")); + } + + if let Some(package_name) = targets + .first() + .and_then(|n| n.get("name").and_then(|n| n.as_str())) + { + let profile = if developer { "debug" } else { "release" }; + let binary = rl_plugin + .source_path() + .join("target") + .join(profile) + .join(package_name); + if !binary.exists() { + return Err(anyhow!( + "Binary {package_name} not found in target/{profile}" + )); + } + + let destination = rl_plugin.path().join(plugin_name); + + let line = format!("moving {} to {}", binary.display(), destination.display()); + logger.log(&line, LogLevel::DEBUG).await?; + + fs::copy(&binary, &destination).await?; + + if !developer { + let mut cargo_clean = Command::new("cargo"); + cargo_clean.arg("clean"); + cargo_clean.current_dir(rl_plugin.source_path()); + run_logged_command(cargo_clean, logger).await?; + } + + Ok(destination) + } else { + Err(anyhow!("No binary found in Cargo.toml")) + } +} + +pub async fn install_go_plugin( + plugin_name: &str, + rl_plugin: &RecklessPlugin, + logger: &mut RecklessLogger<'_>, +) -> Result { + let line = "using installer go"; + logger.log(line, LogLevel::INFO).await?; + + let main_packages = find_go_main_packages(rl_plugin.source_path().to_owned()).await?; + if main_packages.len() > 1 { + return Err(anyhow!( + "Multiple main packages found, can't install: {main_packages:?}" + )); + } + if main_packages.is_empty() { + return Err(anyhow!("No main package found, can't install")); + } + + let destination = rl_plugin.path().join(plugin_name); + + let line = "compiling with `go`, this might take a moment..."; + logger.log(line, LogLevel::INFO).await?; + + let mut command = Command::new("go"); + command.arg("build"); + command.arg("-o"); + command.arg(&destination); + command.arg(main_packages.first().unwrap()); + command.current_dir(rl_plugin.source_path()); + + run_logged_command(command, logger).await?; + + if !destination.exists() { + return Err(anyhow!("Binary {} not found", destination.display())); + } + + Ok(destination) +} + +const GO_SKIP_DIRS: &[&str] = &[ + "examples", + "example", + "testdata", + "tests", + "docs", + "doc", + ".git", + ".github", + ".vscode", + ".idea", + "vendor", + "node_modules", + "target", + "dist", + "build", + "tmp", + "bench", + "benchmark", + "benchmarks", +]; + +async fn find_go_main_packages(root: PathBuf) -> Result, anyhow::Error> { + let mut results: Vec = Vec::new(); + + let mut queue = VecDeque::new(); + queue.push_back((root.clone(), 0usize)); + + while let Some((path, depth)) = queue.pop_front() { + if depth > 256 { + continue; + } + + let Ok(mut read_dir) = fs::read_dir(&path).await else { + continue; + }; + + while let Some(entry) = read_dir.next_entry().await? { + let path = entry.path(); + let file_type = entry.file_type().await?; + + let Some(file_name) = path.file_name().and_then(|p| p.to_str()) else { + continue; + }; + + if file_type.is_dir() { + if !GO_SKIP_DIRS.contains(&file_name) { + queue.push_back((path, depth + 1)); + } + continue; + } + + if !file_type.is_file() { + continue; + } + + if path.extension().and_then(|e| e.to_str()) != Some("go") { + continue; + } + + let Ok(content) = fs::read_to_string(&path).await else { + continue; + }; + + if !content.contains("package main") { + continue; + } + + if !content.contains("func main(") { + continue; + } + + results.push(path.clone()); + } + } + + Ok(results) +} + +pub async fn install_poetry_plugin( + plugin_name: &str, + rl_plugin: &RecklessPlugin, + logger: &mut RecklessLogger<'_>, +) -> Result { + let line = "using installer poetryvenv"; + logger.log(line, LogLevel::INFO).await?; + + let venv_dir = rl_plugin.source_path().join(".venv"); + create_venv(rl_plugin.source_path(), &venv_dir, logger).await?; + + let mut env: HashMap = std::env::vars().collect(); + + env.insert("VIRTUAL_ENV".into(), venv_dir.to_string_lossy().to_string()); + env.remove("POETRY_VIRTUAL_ENV"); + + env.insert("LANG".to_string(), "C.UTF-8".to_string()); + env.insert("LC_ALL".to_string(), "C.UTF-8".to_string()); + env.insert("PYTHONUTF8".to_string(), "1".to_string()); + env.insert("PYTHONIOENCODING".to_string(), "utf-8".to_string()); + env.insert( + "POETRY_VIRTUALENVS_PATH".into(), + venv_dir.to_string_lossy().into_owned(), + ); + + let line = "installing dependencies with `poetry install`, this might take a moment..."; + logger.log(line, LogLevel::INFO).await?; + + let mut command = Command::new("poetry"); + command + .arg("install") + .arg("--no-root") + .arg("--no-interaction") + .current_dir(rl_plugin.source_path()) + .env_clear() + .envs(&env); + + run_logged_command(command, logger).await?; + + let line = "dependencies installed successfully"; + logger.log(line, LogLevel::INFO).await?; + + let plugin_entry = find_entryfile(rl_plugin.source_path(), plugin_name).await?; + + let entry_name = plugin_entry.trim_end_matches(".py"); + + let wrapper = format!("{plugin_name}.py"); + let wrapper_path = rl_plugin.path().join(wrapper); + + let wrapper = create_wrapper(rl_plugin, entry_name, &venv_dir).await?; + fs::write(&wrapper_path, wrapper).await?; + + set_executable(&wrapper_path).await?; + + Ok(wrapper_path) +} + +pub async fn install_uv_plugin( + plugin_name: &str, + rl_plugin: &RecklessPlugin, + logger: &mut RecklessLogger<'_>, +) -> Result { + let line = "using installer pythonuv"; + logger.log(line, LogLevel::INFO).await?; + + let mut command = Command::new("uv"); + command.arg("sync").current_dir(rl_plugin.source_path()); + run_logged_command(command, logger).await?; + + let line = "dependencies installed successfully"; + logger.log(line, LogLevel::INFO).await?; + + let plugin_entry = find_entryfile(rl_plugin.source_path(), plugin_name).await?; + + let entry_name = plugin_entry.trim_end_matches(".py"); + + let wrapper = format!("{plugin_name}.py"); + let wrapper_path = rl_plugin.path().join(wrapper); + let venv_dir = rl_plugin.source_path().join(".venv"); + + let wrapper = create_wrapper(rl_plugin, entry_name, &venv_dir).await?; + fs::write(&wrapper_path, wrapper).await?; + + set_executable(&wrapper_path).await?; + + Ok(wrapper_path) +} + +pub async fn install_uv_shebang_plugin( + plugin_name: &str, + rl_plugin: &RecklessPlugin, + logger: &mut RecklessLogger<'_>, +) -> Result { + let line = "using installer pythonuvshebang"; + logger.log(line, LogLevel::INFO).await?; + + let plugin_entry = format!("{plugin_name}.py"); + + let plugin_path = rl_plugin.source_path().join(&plugin_entry); + set_executable(&plugin_path).await?; + + let symlink_path = rl_plugin.path().join(&plugin_entry); + + create_symlink(&plugin_path, &symlink_path)?; + + Ok(symlink_path) +} + +pub async fn install_uv_legacy_plugin( + plugin_name: &str, + rl_plugin: &RecklessPlugin, + logger: &mut RecklessLogger<'_>, +) -> Result { + let line = "using installer pythonuvlegacy"; + logger.log(line, LogLevel::INFO).await?; + + let mut command = Command::new("uv"); + command + .arg("venv") + .arg("--clear") + .current_dir(rl_plugin.source_path()); + + run_logged_command(command, logger).await?; + + let python = python_bin(&rl_plugin.source_path().join(".venv")); + + let mut command = Command::new("uv"); + command + .arg("pip") + .arg("install") + .arg("--python") + .arg(python) + .arg("-r") + .arg("requirements.txt") + .current_dir(rl_plugin.source_path()); + run_logged_command(command, logger).await?; + + let line = "dependencies installed successfully"; + logger.log(line, LogLevel::INFO).await?; + + let plugin_entry = find_entryfile(rl_plugin.source_path(), plugin_name).await?; + + let entry_name = plugin_entry.trim_end_matches(".py"); + + let wrapper = format!("{plugin_name}.py"); + let wrapper_path = rl_plugin.path().join(wrapper); + let venv_dir = rl_plugin.source_path().join(".venv"); + + let wrapper = create_wrapper(rl_plugin, entry_name, &venv_dir).await?; + fs::write(&wrapper_path, wrapper).await?; + + set_executable(&wrapper_path).await?; + + Ok(wrapper_path) +} + +pub async fn install_python_plugin( + plugin_name: &str, + rl_plugin: &RecklessPlugin, + logger: &mut RecklessLogger<'_>, +) -> Result { + let line = "using installer PyprojectViaPip/Python"; + logger.log(line, LogLevel::INFO).await?; + + let venv_dir = rl_plugin.source_path().join(".venv"); + create_venv(rl_plugin.source_path(), &venv_dir, logger).await?; + + let pip = pip_bin(&venv_dir); + + let req_txt = rl_plugin.source_path().join("requirements.txt"); + let pyproject = rl_plugin.source_path().join("pyproject.toml"); + + let line = "installing dependencies with `pip install`..."; + logger.log(line, LogLevel::INFO).await?; + + let mut command = Command::new(&pip); + command.arg("install"); + + if req_txt.exists() { + command.arg("-r").arg(&req_txt); + } else if pyproject.exists() { + command.arg("."); + } else { + return Err(anyhow!("No requirements.txt or pyproject.toml found")); + } + + command.current_dir(rl_plugin.source_path()); + run_logged_command(command, logger).await?; + + let line = "dependencies installed successfully"; + logger.log(line, LogLevel::INFO).await?; + + let plugin_entry = find_entryfile(rl_plugin.source_path(), plugin_name).await?; + + let entry_name = plugin_entry.trim_end_matches(".py"); + + let wrapper = format!("{plugin_name}.py"); + let wrapper_path = rl_plugin.path().join(wrapper); + + let wrapper = create_wrapper(rl_plugin, entry_name, &venv_dir).await?; + fs::write(&wrapper_path, wrapper).await?; + + set_executable(&wrapper_path).await?; + + Ok(wrapper_path) +} + +async fn create_venv( + source: &Path, + venv_dir: &Path, + logger: &mut RecklessLogger<'_>, +) -> Result<(), anyhow::Error> { + let line = format!("creating venv at: {}", venv_dir.display()); + logger.log(&line, LogLevel::INFO).await?; + + let mut command = Command::new("python"); + command + .arg("-m") + .arg("venv") + .arg(venv_dir) + .current_dir(source); + + run_logged_command(command, logger).await?; + + Ok(()) +} + +fn python_bin(venv: &Path) -> PathBuf { + #[cfg(unix)] + { + venv.join("bin").join("python") + } + #[cfg(windows)] + { + venv.join("Scripts").join("python.exe") + } +} + +fn pip_bin(venv: &Path) -> PathBuf { + #[cfg(unix)] + { + venv.join("bin").join("pip") + } + #[cfg(windows)] + { + venv.join("Scripts").join("pip.exe") + } +} + +async fn create_wrapper( + rl_plugin: &RecklessPlugin, + plugin_filename: &str, + venv: &Path, +) -> Result { + let source_str = rl_plugin + .source_path() + .to_str() + .ok_or_else(|| anyhow!("source path contains invalid utf-8"))? + .to_owned(); + + let venv = check_venv(venv).await?; + + let python = python_bin(&venv); + + let python = python.display().to_string(); + + let path_str = rl_plugin + .path() + .to_str() + .ok_or_else(|| anyhow!("path contains invalid utf-8"))?; + + let wrapper = format!( + r#"#!{python} +import sys +import runpy + +if '{source_str}' not in sys.path: + sys.path.append('{source_str}') + +if '{path_str}' in sys.path: + sys.path.remove('{path_str}') + +runpy.run_module("{plugin_filename}", {{}}, "__main__") +"# + ); + Ok(wrapper) +} + +async fn check_venv(venv: &Path) -> Result { + if python_bin(venv).exists() { + return Ok(venv.to_path_buf()); + } + + let mut venv_entries = fs::read_dir(venv).await?; + + while let Ok(Some(entry)) = venv_entries.next_entry().await { + if entry.file_type().await?.is_file() { + continue; + } + if python_bin(&entry.path()).exists() { + return Ok(entry.path()); + } + } + + Err(anyhow!("python not found in venv: {}", venv.display())) +} + +#[cfg(unix)] +async fn set_executable(path: &Path) -> Result<(), anyhow::Error> { + use std::os::unix::fs::PermissionsExt; + + let mut perm = fs::metadata(path).await?.permissions(); + perm.set_mode(0o755); + fs::set_permissions(path, perm).await?; + Ok(()) +} + +#[cfg(not(unix))] +async fn set_executable(_path: &Path) -> Result<()> { + Ok(()) +} diff --git a/plugins/reckless-plugin/src/main.rs b/plugins/reckless-plugin/src/main.rs new file mode 100644 index 000000000000..bc6ad9eaf765 --- /dev/null +++ b/plugins/reckless-plugin/src/main.rs @@ -0,0 +1,204 @@ +use std::{path::PathBuf, str::FromStr, sync::Arc}; + +use clap::{CommandFactory, Parser}; +use cln_plugin::{ + Builder, Plugin, RpcMethodBuilder, + messages::NotificationTopic, + options::{ConfigOption, StringConfigOption}, +}; +use cln_rpc::{ClnRpc, model::requests::ListconfigsRequest}; +use serde_json::json; +use tokio::sync::Mutex; + +use crate::structs::{RecklessArgs, RecklessCmd, SourceCmd}; +use crate::{ + cmds::{ + enable::{handle_disable, handle_enable}, + install::{handle_install, handle_uninstall}, + search::{handle_list_available, handle_list_installed, handle_search}, + source::{handle_source_add, handle_source_list, handle_source_remove}, + tip::handle_tip, + update::handle_update, + version::handle_version, + }, + structs::PluginState, +}; + +mod cmds; +mod installers; +mod structs; +mod util; + +const RECKLESS_DIR: StringConfigOption = ConfigOption::new_str_no_default( + "reckless-dir", + "directory where reckless config, git repos and metadata are stored, default to /reckless", +); +const RECKLESS_INSTALL_NOTIFICATION: &str = "reckless_install"; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), anyhow::Error> { + unsafe { + std::env::set_var( + "CLN_PLUGIN_LOG", + "cln_plugin=info,cln_rpc=info,cln_reckless=trace,warn", + ); + }; + + log_panics::init(); + + let github_redir = std::env::var("REDIR_GITHUB").ok(); + + let Some(conf_plugin) = Builder::new(tokio::io::stdin(), tokio::io::stdout()) + .rpcmethod_from_builder( + RpcMethodBuilder::new("reckless", reckless) + .description("manage CLN plugins: install, uninstall, and configuration"), + ) + .notification(NotificationTopic::new(RECKLESS_INSTALL_NOTIFICATION)) + .option(RECKLESS_DIR) + .dynamic() + .configure() + .await? + else { + return Ok(()); + }; + + let mut lightning_dir = PathBuf::from_str(&conf_plugin.configuration().lightning_dir)?; + let mut rpc = match ClnRpc::new(lightning_dir.join(conf_plugin.configuration().rpc_file)).await + { + Ok(o) => o, + Err(e) => { + return conf_plugin + .disable(&format!("could not establish RPC connection to CLN: {e}")) + .await; + } + }; + + let configs_resp = match rpc + .call_typed(&ListconfigsRequest { + config: Some("conf".to_owned()), + }) + .await + { + Ok(o) => o, + Err(e) => { + return conf_plugin + .disable(&format!("could not get listconfigs: {e}")) + .await; + } + }; + let custom_cln_config = configs_resp + .configs + .and_then(|c| c.conf) + .map(|cc| PathBuf::from(cc.value_str)); + + let (cln_global_conf, cln_conf, cln_setconfig) = if let Some(custom_config) = custom_cln_config + { + let Some(conf_dir) = custom_config.parent() else { + return conf_plugin + .disable("`--conf` has no parent directory") + .await; + }; + let Some(conf_name) = custom_config.file_name().and_then(|f| f.to_str()) else { + return conf_plugin.disable("`--conf` has no valid file name").await; + }; + let setconfig = conf_dir.join(format!("{conf_name}.setconfig")); + (None, custom_config, setconfig) + } else { + let Some(global_dir) = lightning_dir.parent() else { + return conf_plugin + .disable("`lightning-dir` has no parent directory") + .await; + }; + let global_config = Some(global_dir.join("config")); + let network_config = lightning_dir.join("config"); + let setconfig_config = lightning_dir.join("config.setconfig"); + + (global_config, network_config, setconfig_config) + }; + + lightning_dir.pop(); + + let reckless_dir = conf_plugin + .option(&RECKLESS_DIR)? + .map_or_else(|| lightning_dir.join("reckless"), PathBuf::from); + + let reckless_conf = reckless_dir.join(format!( + "{}-reckless.conf", + conf_plugin.configuration().network + )); + + let state = PluginState { + reckless_dir, + cln_conf, + cln_global_conf, + cln_setconfig, + reckless_conf, + github_redir, + rpc: Arc::new(Mutex::new(rpc)), + }; + let plugin = conf_plugin.start(state).await?; + + plugin.join().await +} + +async fn reckless( + plugin: Plugin, + args: serde_json::Value, +) -> Result { + let gather_args = structs::json_to_argv(&args)?; + + let parsed = match RecklessArgs::try_parse_from(gather_args) { + Ok(p) => p, + Err(e) if e.kind() == clap::error::ErrorKind::DisplayHelp => { + return Ok( + serde_json::json!({ "format-hint": "simple","result": e.render().to_string() }), + ); + } + Err(e) => { + return Err(anyhow::anyhow!( + "{}", + e.render().to_string().replace('\n', " ") + )); + } + }; + + match parsed.command { + Some(RecklessCmd::Install(args)) => { + handle_install(plugin.clone(), args, parsed.verbose).await + } + Some(RecklessCmd::Uninstall(args)) => { + handle_uninstall(plugin.clone(), args.target, parsed.verbose).await + } + Some(RecklessCmd::Search(args)) => { + handle_search(plugin.clone(), args.target, parsed.verbose).await + } + Some(RecklessCmd::Listavailable) => { + handle_list_available(plugin.clone(), parsed.verbose).await + } + Some(RecklessCmd::Enable(args)) => { + handle_enable(plugin.clone(), args, parsed.verbose).await + } + Some(RecklessCmd::Disable(args)) => { + handle_disable(plugin.clone(), args.target, parsed.verbose).await + } + Some(RecklessCmd::Source(args)) => match args.subcommand { + SourceCmd::List => handle_source_list(plugin.clone(), parsed.verbose).await, + SourceCmd::Add(t) => handle_source_add(plugin.clone(), t.target, parsed.verbose).await, + SourceCmd::Remove(t) => { + handle_source_remove(plugin.clone(), t.target, parsed.verbose).await + } + }, + Some(RecklessCmd::Update(args)) => { + handle_update(plugin.clone(), args, parsed.verbose).await + } + Some(RecklessCmd::Listinstalled) => { + handle_list_installed(plugin.clone(), parsed.verbose).await + } + Some(RecklessCmd::Tip(args)) => handle_tip(plugin.clone(), args, parsed.verbose).await, + None if parsed.version => handle_version(plugin.clone()).await, + None => Ok(json!({ + "format-hint": "simple", + "result": RecklessArgs::command().render_help().to_string() + })), + } +} diff --git a/plugins/reckless-plugin/src/structs.rs b/plugins/reckless-plugin/src/structs.rs new file mode 100644 index 000000000000..99155be44e63 --- /dev/null +++ b/plugins/reckless-plugin/src/structs.rs @@ -0,0 +1,342 @@ +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use anyhow::{Error, anyhow}; +use clap::{Args, Parser, Subcommand}; +use cln_plugin::options; +use cln_rpc::ClnRpc; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +#[derive(Clone)] +pub struct PluginState { + pub reckless_dir: PathBuf, + pub cln_conf: PathBuf, + pub cln_global_conf: Option, + pub cln_setconfig: PathBuf, + pub reckless_conf: PathBuf, + pub github_redir: Option, + pub rpc: Arc>, +} +impl PluginState { + pub fn get_cln_configs(&self) -> Vec<&Path> { + if let Some(cln_global) = self.cln_global_conf.as_ref() { + return vec![cln_global, &self.cln_conf, &self.cln_setconfig]; + } + + vec![&self.cln_conf, &self.cln_setconfig] + } +} + +#[derive(Parser, Debug, Deserialize)] +#[command(name = "reckless", no_binary_name = true, disable_help_flag = true)] +#[allow(clippy::struct_excessive_bools)] +pub struct RecklessArgs { + #[arg(short = 'v', long, default_value_t = false, global = true)] + pub verbose: bool, + + // #[arg(short = 'j', long, default_value_t = false, global = true)] + // pub json: bool, + #[arg(short = 'V', long, default_value_t = false, global = true)] + pub version: bool, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand, Debug, Deserialize)] +pub enum RecklessCmd { + /// Search for and install a plugin, then test and activate + Install(InstallArgs), + + /// Deactivate a plugin and remove it from the directory + Uninstall(TargetArgs), + + /// Search for a plugin from the available source repositories + Search(TargetArgs), + + /// List plugins available from the sources list + Listavailable, + + /// Dynamically enable a plugin and update config + Enable(EnableArgs), + + /// Disable a plugin, remove it from the config, but keep the plugin files + Disable(TargetArgs), + + /// Manage plugin search sources + Source(SourceArgs), + + /// Update plugins to latest version + Update(UpdateArgs), + + /// List reckless-installed plugins + Listinstalled, + + /// Tip a plugin author + Tip(TipArgs), +} + +/// Shared args for commands that take one or more plugin name targets +#[derive(Args, Debug, Deserialize)] +pub struct TargetArgs { + pub target: String, +} + +/// `reckless source ` +#[derive(Args, Debug, Deserialize)] +pub struct SourceArgs { + #[command(subcommand)] + pub subcommand: SourceCmd, +} + +#[derive(Args, Debug, Deserialize)] +pub struct InstallArgs { + #[arg(help = "name of plugin to install")] + pub target: String, + + #[arg(long, help = "build plugins in debug mode and keep build files")] + pub developer: bool, + + #[arg(value_parser = parse_key_val)] + pub options: Vec<(String, Option)>, +} + +#[derive(Args, Debug, Deserialize)] +pub struct UpdateArgs { + #[arg(help = "name of plugin to update, leave empty to update all installed plugins")] + pub target: Option, + + #[arg(long, help = "build plugins in debug mode and keep build files")] + pub developer: bool, + + #[arg(value_parser = parse_key_val)] + pub options: Vec<(String, Option)>, +} + +#[derive(Args, Debug, Deserialize)] +pub struct EnableArgs { + #[arg(help = "name of plugin to install")] + pub target: String, + + #[arg(value_parser = parse_key_val)] + pub options: Vec<(String, Option)>, +} + +#[allow(clippy::unnecessary_wraps)] +fn parse_key_val(s: &str) -> Result<(String, Option), String> { + if let Some((k, v)) = s.split_once('=') { + Ok((k.to_owned(), Some(v.to_owned()))) + } else { + Ok((s.to_owned(), None)) + } +} + +#[derive(Args, Debug, Deserialize)] +pub struct TipArgs { + #[arg(help = "plugin name which author to tip")] + pub target: String, + + #[arg(help = "tip amount in msat")] + pub amount_msat: u64, + + #[arg(help = "tip message")] + pub payer_note: Option, +} + +#[derive(Subcommand, Debug, Deserialize)] +pub enum SourceCmd { + /// List available plugin sources + List, + + /// Add a source repository + Add(TargetArgs), + + /// Remove a plugin source repository + #[command(aliases = ["rem", "rm"])] + Remove(TargetArgs), +} + +pub fn json_to_argv(value: &serde_json::Value) -> Result, Error> { + match value { + serde_json::Value::Array(arr) => arr + .iter() + .map(|v| match v { + serde_json::Value::String(s) => Ok(s.clone()), + other => Ok(other.to_string()), + }) + .collect(), + _ => Err(anyhow!("expected array")), + } +} + +pub enum Installer { + PythonUv, + PythonUvShebang, + PythonUvLegacy, + PoetryVenv, + PyprojectViaPip, + Python, + Nodejs, + Rust, + Go, + Custom, +} + +#[derive(Debug, Clone, Copy)] +pub enum RecklessTopic { + Install, + Search, + Enable, + Source, + Tip, +} + +#[derive(Deserialize, Default, Debug)] +pub struct GetManifestResponse { + pub options: Vec, + pub dynamic: Option, +} +impl GetManifestResponse { + pub fn is_dynamic(&self) -> bool { + self.dynamic.unwrap_or(false) + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct UntypedConfigOption { + pub name: String, + #[serde(rename = "type")] + pub value_type: options::ValueType, + // pub default: Option, + // pub description: String, + // pub deprecated: Option, + // pub dynamic: Option, + pub multi: Option, +} +impl UntypedConfigOption { + // pub fn is_dynamic(&self) -> bool { + // self.dynamic.unwrap_or(false) + // } + pub fn is_multi(&self) -> bool { + self.multi.unwrap_or(false) + } + // pub fn is_deprecated(&self) -> bool { + // self.deprecated.unwrap_or(false) + // } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct RecklessManifest { + #[serde(skip_serializing_if = "Option::is_none")] + pub short_description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub long_description: Option, + #[serde(skip_serializing)] + pub entrypoint: Option, + #[serde(skip_serializing_if = "option_vec_is_empty")] + pub dependencies: Option>, + #[serde(skip_serializing_if = "option_vec_is_empty")] + pub install_cmd: Option>, + #[serde(skip_serializing_if = "option_vec_is_empty")] + pub required_options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub offer: Option, +} + +#[allow(clippy::ref_option)] +fn option_vec_is_empty(v: &Option>) -> bool { + v.as_ref().is_none_or(Vec::is_empty) +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RecklessPlugin { + /// URL or path where we got the plugin from, e.g. + /// + origin: String, + /// path that to the overall repo + /// e.g. lightningd/plugins + origin_repo_path: PathBuf, + /// we might need to traverse deeper into a repo to find the plugin path + /// e.g. lightningd/plugins/summary + origin_plugin_path: PathBuf, + /// always ``reckless_dir/`` + path: PathBuf, + /// if origin is a remote git repo it is ``reckless_dir//source/`` + /// if it's from a local path it is just that + source_path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + requested_commit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + installed_commit: Option, + #[serde(skip_serializing)] + is_local_path: bool, +} +impl RecklessPlugin { + pub fn new( + origin: String, + origin_plugin_path: PathBuf, + origin_repo_path: PathBuf, + name: &str, + reckless_dir: &Path, + is_local_path: bool, + ) -> Self { + let source_path = if is_local_path { + origin_plugin_path.clone() + } else { + reckless_dir.join(name).join("source").join(name) + }; + RecklessPlugin { + origin, + origin_plugin_path, + origin_repo_path, + path: reckless_dir.join(name), + source_path, + requested_commit: None, + installed_commit: None, + is_local_path, + } + } + pub fn origin(&self) -> &str { + &self.origin + } + pub fn origin_plugin_path(&self) -> &PathBuf { + &self.origin_plugin_path + } + pub fn origin_repo_path(&self) -> &PathBuf { + &self.origin_repo_path + } + pub fn path(&self) -> &PathBuf { + &self.path + } + pub fn source_path(&self) -> &PathBuf { + &self.source_path + } + pub fn requested_commit(&self) -> Option<&str> { + self.requested_commit.as_deref() + } + pub fn installed_commit(&self) -> Option<&str> { + self.installed_commit.as_deref() + } + pub fn set_requested_commit(&mut self, commit: Option) { + self.requested_commit = commit; + } + pub fn set_installed_commit(&mut self, commit: String) { + self.installed_commit = Some(commit); + } + pub fn is_local_path(&self) -> bool { + self.is_local_path + } +} + +#[derive(Debug, Serialize)] +pub struct Metadata { + pub installation_date: String, + pub installation_time: u64, + pub original_source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub requested_commit: Option, + pub installed_commit: String, +} diff --git a/plugins/reckless-plugin/src/util.rs b/plugins/reckless-plugin/src/util.rs new file mode 100644 index 000000000000..306d8efebd64 --- /dev/null +++ b/plugins/reckless-plugin/src/util.rs @@ -0,0 +1,1517 @@ +use std::{ + collections::{HashMap, HashSet, VecDeque}, + path::{Path, PathBuf}, + process::Stdio, + str::FromStr, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use anyhow::anyhow; +use chrono::Utc; +use cln_plugin::{Plugin, options}; +use cln_rpc::{ + codec::MultiLineCodec, + model::{requests::PluginRequest, responses::PluginResponse}, + notifications::LogLevel, + primitives::PluginSubcommand, +}; +use futures::StreamExt; +use serde_json::json; +use sha2::{Digest, Sha256}; +use tokio::{ + fs::{self, OpenOptions}, + io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, + process::Command, + time::timeout, +}; +use tokio_util::codec::FramedRead; +use url::Url; +use which::which; + +use crate::{ + RECKLESS_INSTALL_NOTIFICATION, + structs::{ + GetManifestResponse, Installer, Metadata, PluginState, RecklessManifest, RecklessPlugin, + RecklessTopic, + }, +}; + +const DEFAULT_REPO: &str = "https://github.com/lightningd/plugins"; +const RECKLESS_CONFIG_HEADER: &str = "# This configuration file is managed by reckless to \ +activate and disable\n# reckless-installed plugins\n\n"; + +pub async fn run_logged_command( + mut command: Command, + logger: &mut RecklessLogger<'_>, +) -> Result { + let line = format!("running command: `{command:#?}`"); + logger.log(&line, LogLevel::DEBUG).await?; + + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let mut child = command.spawn()?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("failed to capture stdout"))?; + + let stderr = child + .stderr + .take() + .ok_or_else(|| anyhow!("failed to capture stderr"))?; + + let mut stdout_lines = BufReader::new(stdout).lines(); + let mut stderr_lines = BufReader::new(stderr).lines(); + + let mut stdout_done = false; + let mut stderr_done = false; + + let mut stdout_buf = String::new(); + + loop { + if stdout_done && stderr_done { + break; + } + + tokio::select! { + line = stdout_lines.next_line(), if !stdout_done => { + match line? { + Some(line) => { + logger.log(&line, LogLevel::INFO).await?; + stdout_buf.push_str(&line); + stdout_buf.push('\n'); + } + None => { + stdout_done = true; + } + } + } + + line = stderr_lines.next_line(), if !stderr_done => { + match line? { + Some(line) => { + logger.log(&line, LogLevel::UNUSUAL).await?; + } + None => { + stderr_done = true; + } + } + } + } + } + + let status = child.wait().await?; + + if !status.success() { + return Err(anyhow!("command failed: {command:#?}, status={status}")); + } + + Ok(stdout_buf.trim().to_owned()) +} + +pub async fn get_plugin_manifest( + entrypoint: &PathBuf, + logger: &mut RecklessLogger<'_>, +) -> Result { + let line = format!("getmanifest: {}", entrypoint.display()); + logger.log(&line, LogLevel::DEBUG).await?; + + let mut child = Command::new(entrypoint) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn()?; + + let line = format!("spawned: {}", entrypoint.display()); + logger.log(&line, LogLevel::DEBUG).await?; + + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("could not take stdin handle"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("could not take stdout handle"))?; + + logger.log("took stdin/stdout", LogLevel::DEBUG).await?; + + let mut reader = FramedRead::new(stdout, MultiLineCodec::default()); + + // CLN plugin handshake: simulate lightningd sending getmanifest + let getmanifest = serde_json::json!({ + "jsonrpc": "2.0", + "id": "reckless-1", + "method": "getmanifest", + "params": {} + }); + + stdin.write_all(getmanifest.to_string().as_bytes()).await?; + stdin.write_all(b"\n\n").await?; + stdin.flush().await?; + + logger.log("requested manifest", LogLevel::DEBUG).await?; + + let result = timeout(Duration::from_secs(10), async { + let mut message_count = 0; + + loop { + let message = reader + .next() + .await + .ok_or_else(|| anyhow!("plugin exited before sending getmanifest response"))??; + + message_count += 1; + + if message_count > 100 { + return Err(anyhow!("plugin send alot, but no manifest found")); + } + + let line = format!("got message: `{message}`"); + logger.log(&line, LogLevel::TRACE).await?; + + let mut json = match serde_json::from_str::(&message) { + Ok(v) => v, + Err(e) => { + logger.log(&e.to_string(), LogLevel::TRACE).await?; + continue; + } + }; + + let line = format!("got json: {json:#?}"); + logger.log(&line, LogLevel::TRACE).await?; + + if json.get("id") == Some(&serde_json::Value::String("reckless-1".into())) { + let result = json + .get_mut("result") + .ok_or_else(|| anyhow!("invalid getmanifest response, no `result`"))? + .take(); + + return Ok(result); + } + } + }) + .await + .map_err(|_| anyhow!("timed out reading getmanifest response"))??; + + let parsed: GetManifestResponse = serde_json::from_value(result.clone())?; + let line = format!("got manifest: {parsed:#?}"); + logger.log(&line, LogLevel::DEBUG).await?; + + drop(stdin); + logger.log("dropped stdin", LogLevel::DEBUG).await?; + + let _ = child.start_kill(); + logger.log("child exited", LogLevel::DEBUG).await?; + + let _ = child.wait().await?; + logger.log("waited for child", LogLevel::DEBUG).await?; + + Ok(parsed) +} + +pub fn parse_options( + manifest: &GetManifestResponse, + options: &[(String, Option)], +) -> Result)>, anyhow::Error> { + let manifest_options: HashMap<_, _> = manifest + .options + .iter() + .map(|opt| (&opt.name, opt)) + .collect(); + + let mut seen = HashSet::new(); + + let mut result = Vec::new(); + + for (option_name, value_str) in options { + let manifest_opt = manifest_options + .get(option_name) + .ok_or_else(|| anyhow!("option {option_name} not found in manifest"))?; + + if !manifest_opt.is_multi() && !seen.insert(option_name) { + return Err(anyhow!("{option_name} is not a multi option")); + } + + let value = match (&manifest_opt.value_type, value_str) { + (cln_plugin::options::ValueType::String, Some(v)) => { + Some(options::Value::String(v.clone())) + } + (cln_plugin::options::ValueType::Integer, Some(v)) => { + Some(options::Value::Integer(i64::from_str(v)?)) + } + (cln_plugin::options::ValueType::Boolean, Some(v)) => { + Some(options::Value::Boolean(bool::from_str(v)?)) + } + (cln_plugin::options::ValueType::Flag, None) => None, + _ => { + return Err(anyhow!( + "Invalid option value, expected {:#?} for {option_name}", + manifest_opt.value_type + )); + } + }; + + result.push((option_name.clone(), value)); + } + + Ok(result) +} + +pub struct RecklessLogger<'a> { + pub plugin: &'a Plugin, + pub log: &'a mut Vec, + pub topic: RecklessTopic, + pub verbose: bool, +} +impl RecklessLogger<'_> { + pub async fn log(&mut self, line: &str, log_level: LogLevel) -> Result<(), anyhow::Error> { + if !self.verbose + && (log_level == LogLevel::IO + || log_level == LogLevel::TRACE + || log_level == LogLevel::DEBUG) + { + return Ok(()); + } + for line in line.split('\n') { + match log_level { + LogLevel::IO => { + self.log.push(format!("IO: {line}")); + log::trace!("{line}"); + } + LogLevel::TRACE => { + self.log.push(format!("TRACE: {line}")); + log::trace!("{line}"); + } + LogLevel::DEBUG => { + self.log.push(format!("DEBUG: {line}")); + log::debug!("{line}"); + } + LogLevel::INFO => { + self.log.push(format!("INFO: {line}")); + log::info!("{line}"); + } + LogLevel::UNUSUAL => { + self.log.push(format!("WARNING: {line}")); + log::warn!("{line}"); + } + LogLevel::BROKEN => { + self.log.push(format!("ERROR: {line}")); + log::error!("{line}"); + } + } + + if let RecklessTopic::Install = self.topic { + self.plugin + .send_custom_notification( + RECKLESS_INSTALL_NOTIFICATION.to_owned(), + json!({"level": log_level, "log": line}), + ) + .await?; + } + } + + Ok(()) + } +} + +pub fn repo_path_from_url(url: &Url) -> Result { + let mut segments = url + .path_segments() + .ok_or_else(|| anyhow!("No paths in git URL"))?; + let last = segments + .next_back() + .ok_or(anyhow!("Missing repo name in git URL"))?; + + let repo_name = last.trim_end_matches(".git"); + + if repo_name.is_empty() { + return Err(anyhow!("could not determine repo name")); + } + + let repo_owner = segments + .next_back() + .ok_or(anyhow!("Missing repo owner in git URL"))?; + + for segment in segments { + if segment == "tree" { + return Err(anyhow!( + "Please provide a URL with only the repo owner and name, not a tree reference. \ + You can specify a branch when installing a plugin." + )); + } + } + + Ok(PathBuf::from(repo_owner).join(repo_name)) +} + +pub async fn init_plugin_repo( + plugin: Plugin, + url: &Url, + logger: &mut RecklessLogger<'_>, +) -> Result { + let repo_path = plugin.state().reckless_dir.join(repo_path_from_url(url)?); + let line = format!("initializing repo `{url}` in: `{}`", repo_path.display()); + logger.log(&line, LogLevel::DEBUG).await?; + + if let Some(domain) = url.domain() { + if domain == "github.com" { + if let Some(github_redir) = &plugin.state().github_redir { + return Ok(PathBuf::from_str(github_redir)?); + } + } + } + + if repo_path.exists() { + let mut command = Command::new("git"); + command + .args(["remote", "set-head", "origin", "-a"]) + .current_dir(&repo_path); + run_logged_command(command, logger).await?; + + let mut command = Command::new("git"); + command + .args(["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]) + .current_dir(&repo_path); + let default_branch = run_logged_command(command, logger).await?; + let default_branch = default_branch.trim_start_matches("origin/"); + + let mut command = Command::new("git"); + command + .args(["checkout", default_branch]) + .current_dir(&repo_path); + run_logged_command(command, logger).await?; + + let mut command = Command::new("git"); + command.args(["pull", "--ff-only"]).current_dir(&repo_path); + run_logged_command(command, logger).await?; + } else { + let repo_path_str = repo_path + .to_str() + .ok_or_else(|| anyhow!("path is invalid"))?; + let mut command = Command::new("git"); + command.args(["clone", "--recursive", url.as_str(), repo_path_str]); + run_logged_command(command, logger).await?; + } + + let mut command = Command::new("git"); + command + .args(["submodule", "sync", "--recursive"]) + .current_dir(&repo_path); + run_logged_command(command, logger).await?; + + let mut command = Command::new("git"); + command + .args(["submodule", "update", "--init", "--recursive"]) + .current_dir(&repo_path); + run_logged_command(command, logger).await?; + + Ok(repo_path) +} + +pub async fn find_plugin_locs( + reckless_dir: &Path, + origin: String, + repo_dir: PathBuf, + max_depth: usize, + is_local_path: bool, + logger: &mut RecklessLogger<'_>, +) -> Result, anyhow::Error> { + let line = format!("trying to find plugins in: `{}`", repo_dir.display()); + logger.log(&line, LogLevel::DEBUG).await?; + + let mut plugin_locs = HashMap::new(); + + let mut queue = VecDeque::new(); + queue.push_back((repo_dir.clone(), 0usize)); + + while let Some((path, depth)) = queue.pop_front() { + if depth > max_depth { + continue; + } + + if depth == 1 { + if let Some(name) = path.file_name().and_then(|p| p.to_str()) { + if name.contains("archive") + || name.eq(".git") + || name.eq(".venv") + || name.eq(".ci") + || name.eq(".github") + { + continue; + } + } + } + + let Some(name) = path.file_name().and_then(|p| p.to_str()) else { + continue; + }; + + match detect_installer(&path).await { + Ok(_installer) => { + plugin_locs.insert( + normalize_plugin_name(name), + RecklessPlugin::new( + origin.clone(), + path.clone(), + repo_dir.clone(), + name, + reckless_dir, + is_local_path, + ), + ); + + continue; + } + Err(e) => { + let line = format!("could not detect installer for {}: {e}", path.display()); + logger.log(&line, LogLevel::DEBUG).await?; + } + } + + if depth == max_depth { + continue; + } + + let Ok(mut entries) = fs::read_dir(&path).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + + if file_type.is_symlink() { + continue; + } + + if !file_type.is_dir() { + continue; + } + + queue.push_back((entry.path(), depth + 1)); + } + } + + Ok(plugin_locs) +} + +async fn file_hash(path: &Path) -> Result, anyhow::Error> { + let file = fs::File::open(path).await?; + let mut reader = BufReader::new(file); + + let mut hasher = Sha256::new(); + let mut buf = [0u8; 8192]; + + loop { + let n = reader.read(&mut buf).await?; + if n == 0 { + break; + } + + hasher.update(&buf[..n]); + } + + Ok(hasher.finalize().to_vec()) +} + +async fn files_differ(src: &Path, dst: &Path) -> Result { + let Ok(dst_meta) = fs::metadata(dst).await else { + return Ok(true); + }; + + let src_meta = fs::metadata(src).await?; + + if src_meta.len() != dst_meta.len() { + return Ok(true); + } + + let src_hash = file_hash(src).await?; + let dst_hash = file_hash(dst).await?; + + Ok(src_hash != dst_hash) +} + +pub async fn copy_dir_all( + src: &Path, + dst: &Path, + logger: &mut RecklessLogger<'_>, +) -> Result<(), anyhow::Error> { + let line = format!("copying from: {} -> {}", src.display(), dst.display()); + logger.log(&line, LogLevel::DEBUG).await?; + + let mut stack = vec![(src.to_path_buf(), dst.to_path_buf())]; + + while let Some((src, dst)) = stack.pop() { + fs::create_dir_all(&dst).await?; + + let line = format!("reading dir: {}", src.display()); + logger.log(&line, LogLevel::DEBUG).await?; + + let mut entries = fs::read_dir(&src).await?; + + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + + let line = format!("processing: {}", name.to_string_lossy()); + logger.log(&line, LogLevel::DEBUG).await?; + + if name == ".venv" || name == ".git" { + let line = format!("skipping {}", entry.path().display()); + logger.log(&line, LogLevel::DEBUG).await?; + continue; + } + + let src_path = entry.path(); + let dst_path = dst.join(&name); + + // IMPORTANT: do not follow symlinks here + let meta = fs::symlink_metadata(&src_path).await?; + let file_type = meta.file_type(); + + if meta.file_type().is_symlink() { + match fs::metadata(&src_path).await { + Ok(target_meta) => { + if target_meta.is_dir() { + stack.push((src_path, dst_path)); + } else { + fs::copy(&src_path, &dst_path).await?; + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let line = format!("skipping broken symlink: {}", src_path.display()); + logger.log(&line, LogLevel::UNUSUAL).await?; + } + Err(e) => return Err(e.into()), + } + } else if file_type.is_dir() { + stack.push((src_path, dst_path)); + } else if file_type.is_file() && files_differ(&src_path, &dst_path).await? { + let line = format!("copying file {}", src_path.display()); + logger.log(&line, LogLevel::DEBUG).await?; + fs::copy(&src_path, &dst_path).await?; + } + } + } + + let line = "copying: done"; + logger.log(line, LogLevel::DEBUG).await?; + Ok(()) +} + +pub async fn write_metadata(rl_plugin: &RecklessPlugin) -> Result<(), anyhow::Error> { + let install_dir = Path::new(rl_plugin.path()); + + if !install_dir.is_dir() { + return Err(anyhow!("{} is not a directory", install_dir.display())); + } + + let abs_source_path = match Url::parse(rl_plugin.origin()) { + Ok(url) if matches!(url.scheme(), "http" | "https") => rl_plugin.origin().to_owned(), + _ => fs::canonicalize(rl_plugin.origin()) + .await? + .to_string_lossy() + .to_string(), + }; + + let installation_date = Utc::now().date_naive().to_string(); + + let installation_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let data = format!( + "installation date\n{}\n\ + installation time\n{}\n\ + original source\n{}\n\ + requested commit\n{}\n\ + installed commit\n{}\n", + installation_date, + installation_time, + abs_source_path, + serialize_optional(rl_plugin.requested_commit()), + serialize_optional(rl_plugin.installed_commit()), + ); + + fs::write(install_dir.join(".metadata"), data).await?; + + Ok(()) +} + +pub async fn read_metadata(plugin_dir: &Path) -> Result { + let metadata_file = plugin_dir.join(".metadata"); + + let contents = fs::read_to_string(metadata_file).await?; + + let lines: Vec<&str> = contents.lines().collect(); + + let mut map = HashMap::::new(); + + let mut i = 0; + while i + 1 < lines.len() { + map.insert(lines[i].trim().to_string(), lines[i + 1].trim().to_string()); + + i += 2; + } + + let required = |key: &str| -> Result { + map.get(key) + .cloned() + .ok_or_else(|| anyhow!("missing metadata field: {key}")) + }; + + let requested_commit = map.get("requested commit").and_then(|v| parse_optional(v)); + + Ok(Metadata { + installation_date: required("installation date")?, + installation_time: required("installation time")? + .parse() + .map_err(|_| anyhow!("invalid installation time"))?, + original_source: required("original source")?, + requested_commit, + installed_commit: required("installed commit")?, + }) +} + +fn parse_optional(s: &str) -> Option { + match s.trim() { + "None" | "none" | "" => None, + v => Some(v.to_string()), + } +} + +fn serialize_optional(v: Option<&str>) -> &str { + match v { + Some(s) => s, + None => "None", + } +} + +pub fn parse_target(target: &str) -> Result<(String, Option), anyhow::Error> { + let (name, git_ref) = if let Some(x) = target.split_once('@') { + (normalize_plugin_name(x.0), Some(x.1.to_owned())) + } else { + (normalize_plugin_name(target), None) + }; + if name.contains('/') { + return Err(anyhow!("invalid plugin name")); + } + let name = if let Some((base, extension)) = name.split_once('.') { + if extension.contains('.') { + return Err(anyhow!("invalid plugin name, too many `.`")); + } + // We don't want file extensions in the name but we also had plugin names + // like go-lnmetrics.reporter + if extension.len() > 4 { + name + } else { + base.to_owned() + } + } else { + name + }; + Ok((name, git_ref)) +} + +pub fn validate_path(input: &str) -> Result { + let path = PathBuf::from(input); + + if !path.exists() { + return Err(anyhow!("path does not exist")); + } + + path.canonicalize() + .map_err(|e| anyhow!("invalid path: {e}"))?; + + Ok(path) +} + +pub async fn parse_install_target( + logger: &mut RecklessLogger<'_>, + target: &str, + search_results: &mut HashMap, + reckless_dir: &Path, +) -> Result<(String, Option), anyhow::Error> { + let (name, git_ref) = match parse_target(target) { + Ok(o) => o, + Err(e1) => match validate_path(target.trim()) { + Ok(local_path) => { + let plugin_name = local_path + .file_name() + .ok_or_else(|| anyhow!("local_dir has no final component"))? + .to_str() + .ok_or_else(|| anyhow!("not a valid path"))? + .to_owned(); + search_results.insert( + plugin_name.clone(), + RecklessPlugin::new( + local_path + .to_str() + .ok_or_else(|| anyhow!("not a valid path"))? + .to_owned(), + local_path.clone(), + local_path, + &plugin_name, + reckless_dir, + true, + ), + ); + (plugin_name, None) + } + Err(e2) => { + logger.log(&e1.to_string(), LogLevel::BROKEN).await?; + logger.log(&e2.to_string(), LogLevel::BROKEN).await?; + return Err(anyhow!("neither a valid target or path")); + } + }, + }; + Ok((name, git_ref)) +} + +pub async fn read_sources_file( + plugin: Plugin, +) -> Result<(Vec, Vec, PathBuf), anyhow::Error> { + if !plugin.state().reckless_dir.exists() { + fs::create_dir_all(&plugin.state().reckless_dir).await?; + } + + let source_file = plugin.state().reckless_dir.join(".sources"); + + if !source_file.exists() { + fs::write(&source_file, format!("{DEFAULT_REPO}\n")).await?; + } + + let contents = fs::read_to_string(&source_file).await?; + let lines = contents.lines().collect::>(); + + let mut urls: Vec = Vec::new(); + let mut paths: Vec = Vec::new(); + + for line in &lines { + if let Ok(url) = Url::from_str(line) { + urls.push(url); + } else { + paths.push(PathBuf::from_str(line)?); + } + } + + Ok((urls, paths, source_file)) +} + +pub async fn find_entryfile(path: &Path, plugin_name: &str) -> Result { + if !path.exists() { + return Err(anyhow!("{} not found", path.display())); + } + + let reckless_manifest = read_reckless_manifest(path).await?; + let entrypoint = reckless_manifest.and_then(|m| m.entrypoint); + if let Some(entrypoint) = entrypoint { + return Ok(entrypoint); + } + + let mut entries = fs::read_dir(&path).await?; + + let guesses = vec![format!("{plugin_name}.py"), format!("{plugin_name}.js")]; + + let mut python_candidates = Vec::new(); + let mut js_candidates = Vec::new(); + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if file_type.is_dir() { + continue; + } + if let Some(file_name) = entry.file_name().to_str() { + if normalized_eq(file_name, plugin_name) { + return Ok(file_name.to_owned()); + } + + for guess in &guesses { + if normalized_eq(file_name, guess) { + return Ok(file_name.to_owned()); + } + } + + if file_name.starts_with("test") { + continue; + } + + let fn_path = Path::new(file_name); + if fn_path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("py")) + { + python_candidates.push(file_name.to_owned()); + } else if fn_path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("js")) + { + js_candidates.push(file_name.to_owned()); + } + } + } + + if !python_candidates.is_empty() { + for file in &python_candidates { + let file_path = path.join(file); + let content = fs::read_to_string(&file_path).await?; + if content.contains("plugin.run()") { + return Ok(file.to_owned()); + } + } + } + + if js_candidates.len() == 1 { + return Ok(js_candidates.first().unwrap().to_owned()); + } + + if js_candidates.is_empty() && python_candidates.len() == 1 { + return Ok(python_candidates.first().unwrap().to_owned()); + } + + Err(anyhow!( + "{plugin_name} entryfile not found in {}", + path.display() + )) +} + +pub async fn detect_installer(plugin_path: &Path) -> Result { + let mut entries = fs::read_dir(plugin_path).await?; + let mut files = Vec::new(); + + while let Some(entry) = entries.next_entry().await? { + if let Ok(name) = entry.file_name().into_string() { + files.push(name); + } + } + + let has = |name: &str| files.iter().find(|f| normalized_eq(f, name)); + + let plugin_name = plugin_path + .file_name() + .ok_or_else(|| anyhow!("no filename"))? + .to_str() + .ok_or_else(|| anyhow!("not a valid path"))?; + + let reckless_manifest = read_reckless_manifest(plugin_path).await?; + let entrypoint = reckless_manifest + .as_ref() + .and_then(|m| m.entrypoint.clone()); + let install_cmd = reckless_manifest + .as_ref() + .and_then(|m| m.install_cmd.clone()); + + if install_cmd.is_some() && entrypoint.is_some() { + return Ok(Installer::Custom); + } + + let entry_file = if let Some(et) = entrypoint { + Some(et) + } else { + find_entryfile(plugin_path, plugin_name).await.ok() + }; + + if has("Cargo.toml").is_some() { + if which("cargo").is_ok() { + return Ok(Installer::Rust); + } + return Err(anyhow!("rust plugin requires cargo")); + } + + if has("go.mod").is_some() { + if which("go").is_ok() { + return Ok(Installer::Go); + } + return Err(anyhow!("go plugin requires go")); + } + + if has("package.json").is_some() { + if entry_file.is_none() { + return Err(anyhow!("node plugin entrypoint not found")); + } + if which("npm").is_ok() { + return Ok(Installer::Nodejs); + } + return Err(anyhow!("node plugin requires npm")); + } + + let Some(actual_python_entry) = entry_file.and_then(|ef| has(&ef)) else { + return Err(anyhow!("python entry file not found")); + }; + + if !Path::new(actual_python_entry) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("py")) + { + return Err(anyhow!("python entry file must end with .py")); + } + + if has_uv_shebang(plugin_path.join(actual_python_entry)).await? { + if which("uv").is_ok() { + return Ok(Installer::PythonUvShebang); + } + return Err(anyhow!("python plugin uses uv shebang but uv not found")); + } + + if has("uv.lock").is_some() { + if which("uv").is_ok() { + return Ok(Installer::PythonUv); + } + return Err(anyhow!("python plugin uses uv but uv not found")); + } + + if has("poetry.lock").is_some() { + if which("poetry").is_ok() { + return Ok(Installer::PoetryVenv); + } + return Err(anyhow!("python plugin uses poetry but poetry not found")); + } + + if has("pyproject.toml").is_some() { + if which("pip").is_ok() { + return Ok(Installer::PyprojectViaPip); + } + return Err(anyhow!("python pyproject.toml detected but no pip found")); + } + + if has("requirements.txt").is_some() { + if which("uv").is_ok() { + return Ok(Installer::PythonUvLegacy); + } + if which("pip").is_ok() { + return Ok(Installer::Python); + } + return Err(anyhow!("python requirements.txt detected but no pip found")); + } + + Err(anyhow!( + "plugin language could not be detected or is unsupported" + )) +} + +async fn has_uv_shebang(path: PathBuf) -> Result { + let file = fs::File::open(path).await?; + let mut reader = BufReader::new(file); + + let mut line = String::new(); + + loop { + line.clear(); + + let bytes_read = reader.read_line(&mut line).await?; + + if bytes_read == 0 { + return Ok(false); + } + + let trimmed = line.trim(); + + if trimmed.is_empty() { + continue; + } + + if !trimmed.starts_with("#!") { + return Ok(false); + } + + let shebang = &trimmed[2..]; + + let has_uv = shebang.split_whitespace().any(|part| part == "uv"); + + return Ok(has_uv); + } +} + +pub fn create_symlink(src: &Path, dst: &Path) -> std::io::Result<()> { + if let Ok(meta) = std::fs::symlink_metadata(dst) { + if meta.is_dir() { + std::fs::remove_dir_all(dst)?; + } else { + std::fs::remove_file(dst)?; + } + } + + #[cfg(unix)] + { + std::os::unix::fs::symlink(src, dst) + } + + #[cfg(windows)] + { + use std::fs as stdfs; + use std::os::windows::fs; + + let metadata = stdfs::metadata(src); + + match metadata { + Ok(m) if m.is_dir() => fs::symlink_dir(src, dst), + _ => fs::symlink_file(src, dst), + } + } +} + +pub fn normalize_plugin_name(name: &str) -> String { + name.to_ascii_lowercase() +} + +pub fn normalized_eq(a: &str, b: &str) -> bool { + a.replace('-', "_").eq(&b.replace('-', "_")) +} + +pub async fn update_config_file( + path: &Path, + plugin_line: &str, + removing: bool, + option_lines: &mut Vec<(String, String)>, + remove_options: &mut HashSet, +) -> anyhow::Result { + if !path.exists() { + return Ok(false); + } + + let contents = fs::read_to_string(path).await?; + let mut lines: Vec = contents.lines().map(str::to_owned).collect(); + + let mut changed = false; + let mut plugin_enabled = false; + + for line in &mut lines { + let normalized_line = normalized_line(line); + + if normalized_line == plugin_line { + if removing && !line.starts_with('#') { + *line = format!("#{normalized_line}"); + changed = true; + } else if !removing && line.starts_with('#') { + plugin_line.clone_into(line); + changed = true; + } + + plugin_enabled = true; + + continue; + } + + if let Some(pos) = option_lines + .iter() + .position(|(name, _)| normalized_line.starts_with(name)) + { + let (name, expected) = option_lines[pos].clone(); + + if *line != expected { + *line = expected; + changed = true; + } + + // remove ONLY this occurrence (supports multi) + option_lines.remove(pos); + remove_options.insert(name); + continue; + } + + for name in remove_options.iter() { + if line.starts_with(name) { + *line = format!("#{line}"); + changed = true; + break; + } + } + } + + if changed { + fs::write(path, format!("{}\n", lines.join("\n"))).await?; + } + + Ok(plugin_enabled) +} + +fn normalized_line(line: &str) -> &str { + line.trim_start_matches('#').trim_start() +} + +pub async fn read_reckless_manifest( + source: &Path, +) -> Result, anyhow::Error> { + let manifest_path = source.join("manifest.json"); + + if manifest_path.exists() { + let contents = fs::read_to_string(&manifest_path).await?; + Ok(serde_json::from_str(&contents)?) + } else { + Ok(None) + } +} + +pub async fn cln_list_plugins( + plugin: Plugin, + logger: &mut RecklessLogger<'_>, +) -> Result, anyhow::Error> { + let mut rpc = plugin.state().rpc.lock().await; + let plugins = rpc + .call_typed(&PluginRequest { + directory: None, + plugin: None, + options: None, + subcommand: PluginSubcommand::LIST, + }) + .await?; + + let mut plugin_names = Vec::new(); + + for plugin in plugins + .plugins + .ok_or_else(|| anyhow!("empty plugin list response"))? + { + let Some(name) = Path::new(&plugin.name).file_name() else { + let line = format!("plugin entry path has no filename: {}", plugin.name); + logger.log(&line, LogLevel::UNUSUAL).await?; + continue; + }; + plugin_names.push(name.to_string_lossy().to_string()); + } + + Ok(plugin_names) +} + +pub async fn cln_start_plugin( + plugin: Plugin, + plugin_name: &str, + plugin_entry: &Path, + options: Vec<(String, Option)>, + logger: &mut RecklessLogger<'_>, +) -> Result<(), anyhow::Error> { + let options_str = options + .iter() + .map(|(k, v)| match v { + Some(v) => format!("{k}={v}"), + None => k.clone(), + }) + .collect::>() + .join(", "); + let line = if options.is_empty() { + format!("Starting {plugin_name}") + } else { + format!("Starting {plugin_name} with options: {options_str}") + }; + logger.log(&line, LogLevel::INFO).await?; + + // Can not pass options with cln-rpc because of + // + // so we use .call_raw + let mut obj = serde_json::Map::new(); + + obj.insert( + "subcommand".to_owned(), + serde_json::Value::String("start".to_owned()), + ); + obj.insert( + "plugin".to_owned(), + serde_json::Value::String( + plugin_entry + .to_str() + .ok_or_else(|| anyhow!("plugin path invalid: {}", plugin_entry.display()))? + .to_string(), + ), + ); + + let options_val = options + .iter() + .map(|(k, v)| Ok((k.clone(), serde_json::to_value(v)?))) + .collect::, serde_json::Error>>()?; + + obj.extend(options_val); + + let line = format!("{obj:#?}"); + logger.log(&line, LogLevel::TRACE).await?; + + let mut rpc = plugin.state().rpc.lock().await; + match rpc + // .call_typed(&PluginRequest { + // directory: None, + // plugin: Some( + // path.to_str() + // .ok_or_else(|| anyhow!("plugin path invalid: {}", path.display()))? + // .to_string(), + // ), + // options: Some(options), + // subcommand: PluginSubcommand::START, + // }) + .call_raw::("plugin", &serde_json::Value::Object(obj)) + .await + { + Ok(_) => { + let line = format!("{plugin_name} started"); + logger.log(&line, LogLevel::INFO).await?; + } + Err(e) => { + return Err(anyhow!("{plugin_name} failed to start: {}", e.message)); + } + } + + Ok(()) +} + +pub async fn cln_stop_plugin( + plugin: Plugin, + plugin_name: &str, + plugin_entry: &Path, + logger: &mut RecklessLogger<'_>, +) -> Result<(), anyhow::Error> { + let mut rpc = plugin.state().rpc.lock().await; + + match rpc + .call_typed(&PluginRequest { + directory: None, + plugin: Some( + plugin_entry + .to_str() + .ok_or_else(|| anyhow!("plugin path invalid: {}", plugin_entry.display()))? + .to_string(), + ), + options: None, + subcommand: PluginSubcommand::STOP, + }) + .await + { + Ok(_) => { + let line = format!("{plugin_name} stopped"); + logger.log(&line, LogLevel::INFO).await?; + } + Err(e) => { + let line = format!("{plugin_name} NOT stopped: {e}"); + logger.log(&line, LogLevel::UNUSUAL).await?; + } + } + + Ok(()) +} + +pub async fn search_sources( + plugin: Plugin, + search_name: Option, + logger: &mut RecklessLogger<'_>, +) -> Result, anyhow::Error> { + let (urls, paths, _source_file) = read_sources_file(plugin.clone()).await?; + let mut rl_plugins = HashMap::new(); + + for url in &urls { + let repo_dir = init_plugin_repo(plugin.clone(), url, logger).await?; + rl_plugins.extend( + find_plugin_locs( + &plugin.state().reckless_dir, + url.as_str().to_owned(), + repo_dir, + 5, + false, + logger, + ) + .await?, + ); + } + + for path in paths { + rl_plugins.extend( + find_plugin_locs( + &plugin.state().reckless_dir, + path.to_str().unwrap().to_owned(), + path, + 3, + true, + logger, + ) + .await?, + ); + } + + let mut exact_matches = HashMap::new(); + + let mut found_match = false; + for (plugin_name, rl_plugin) in &rl_plugins { + if search_name + .as_ref() + .is_some_and(|search| plugin_name.contains(search)) + { + if !found_match { + let line = format!("Plugins matching '{}':", search_name.as_ref().unwrap()); + logger.log(&line, LogLevel::INFO).await?; + } + let line = format!(" {plugin_name} ({})", rl_plugin.origin()); + logger.log(&line, LogLevel::INFO).await?; + found_match = true; + } + } + + for (plugin_name, rl_plugin) in rl_plugins { + if search_name + .as_ref() + .is_none_or(|search| normalized_eq(&plugin_name, search)) + { + let line = format!("found {plugin_name} in source: {}", rl_plugin.origin()); + logger.log(&line, LogLevel::INFO).await?; + + exact_matches.insert(plugin_name, rl_plugin); + found_match = true; + } + } + + if !found_match { + let line = "Search exhausted all sources"; + logger.log(line, LogLevel::INFO).await?; + } + + Ok(exact_matches) +} + +pub async fn add_plugin_to_config( + plugin: Plugin, + path: PathBuf, + options: Vec<(String, Option)>, + manifest: GetManifestResponse, +) -> Result<(), anyhow::Error> { + include_reckless_config(plugin.clone()).await?; + + let plugin_line = format!("plugin={}", path.display()); + + let mut option_lines: Vec<(String, String)> = options + .iter() + .map(|(name, value)| { + let line = match value { + Some(v) => format!("{name}={}", serde_json::to_string_pretty(v)?), + None => name.clone(), + }; + Ok((name.clone(), line)) + }) + .collect::>()?; + + let mut remove_options: HashSet<_> = manifest + .options + .into_iter() + .map(|o| o.name) + .filter(|name| !option_lines.iter().any(|(n, _)| n == name)) + .collect(); + + let mut plugin_enabled = false; + + for config in plugin.state().get_cln_configs() { + plugin_enabled |= update_config_file( + config, + &plugin_line, + false, + &mut option_lines, + &mut remove_options, + ) + .await?; + } + + if !plugin.state().reckless_conf.exists() { + fs::write(&plugin.state().reckless_conf, RECKLESS_CONFIG_HEADER).await?; + } + + plugin_enabled |= update_config_file( + &plugin.state().reckless_conf, + &plugin_line, + false, + &mut option_lines, + &mut remove_options, + ) + .await?; + + let mut file = OpenOptions::new() + .append(true) + .open(&plugin.state().reckless_conf) + .await?; + + if !plugin_enabled { + file.write_all(format!("{plugin_line}\n").as_bytes()) + .await?; + } + + for (_name, line) in option_lines { + file.write_all(format!("{line}\n").as_bytes()).await?; + } + + Ok(()) +} + +pub async fn remove_plugin_from_config( + plugin: Plugin, + plugin_entry: PathBuf, + manifest: GetManifestResponse, +) -> Result<(), anyhow::Error> { + include_reckless_config(plugin.clone()).await?; + + let plugin_line = format!("plugin={}", plugin_entry.display()); + + let mut option_lines: Vec<(String, String)> = Vec::new(); + + let mut remove_options: HashSet<_> = manifest.options.into_iter().map(|o| o.name).collect(); + + for config in &plugin.state().get_cln_configs() { + update_config_file( + config, + &plugin_line, + true, + &mut option_lines, + &mut remove_options, + ) + .await?; + } + + if !plugin.state().reckless_conf.exists() { + fs::write(&plugin.state().reckless_conf, RECKLESS_CONFIG_HEADER).await?; + } + + update_config_file( + &plugin.state().reckless_conf, + &plugin_line, + true, + &mut option_lines, + &mut remove_options, + ) + .await?; + + Ok(()) +} + +async fn include_reckless_config(plugin: Plugin) -> Result<(), anyhow::Error> { + let rl_conf_path_str = plugin + .state() + .reckless_conf + .to_str() + .ok_or_else(|| anyhow!("path to reckless config contains invalid utf-8"))?; + + if !plugin.state().cln_conf.exists() { + fs::write( + &plugin.state().cln_conf, + format!("# This config was autopopulated by reckless\n\ninclude {rl_conf_path_str}\n"), + ) + .await?; + return Ok(()); + } + + let contents = fs::read_to_string(&plugin.state().cln_conf).await?; + let lines = contents.lines().collect::>(); + + let include_line = format!("include {rl_conf_path_str}"); + + for line in lines { + if line.trim() == include_line { + return Ok(()); + } + } + + let mut file_handle = OpenOptions::new() + .append(true) + .open(&plugin.state().cln_conf) + .await?; + + file_handle + .write_all(format!("\ninclude {rl_conf_path_str}\n").as_bytes()) + .await?; + + Ok(()) +} diff --git a/plugins/recklessrpc.c b/plugins/recklessrpc.c deleted file mode 100644 index 5d3ebb5b2525..000000000000 --- a/plugins/recklessrpc.c +++ /dev/null @@ -1,334 +0,0 @@ -/* This plugin provides RPC access to the reckless standalone utility. - */ - -#include "config.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -static struct plugin *plugin; - -struct reckless { - struct command *cmd; - int stdinfd; - int stdoutfd; - int stderrfd; - char *stdoutbuf; - char *stderrbuf; - size_t stdout_read; /* running total */ - size_t stdout_new; /* new since last read */ - size_t stderr_read; - size_t stderr_new; - pid_t pid; - char *process_failed; -}; - -struct lconfig { - char *lightningdir; - char *config; - char *network; -} lconfig; - -static struct io_plan *reckless_in_init(struct io_conn *conn, - struct reckless *reckless) -{ - return io_write(conn, "Y", 1, io_close_cb, NULL); -} - -static void reckless_send_yes(struct reckless *reckless) -{ - io_new_conn(reckless, reckless->stdinfd, reckless_in_init, reckless); -} - -static struct io_plan *read_more(struct io_conn *conn, struct reckless *rkls) -{ - rkls->stdout_read += rkls->stdout_new; - if (rkls->stdout_read == tal_count(rkls->stdoutbuf)) - tal_resize(&rkls->stdoutbuf, rkls->stdout_read * 2); - return io_read_partial(conn, rkls->stdoutbuf + rkls->stdout_read, - tal_count(rkls->stdoutbuf) - rkls->stdout_read, - &rkls->stdout_new, read_more, rkls); -} - -static struct command_result *reckless_result(struct io_conn *conn, - struct reckless *reckless) -{ - struct json_stream *response; - if (reckless->process_failed) { - response = jsonrpc_stream_fail(reckless->cmd, - PLUGIN_ERROR, - reckless->process_failed); - return command_finished(reckless->cmd, response); - } - const jsmntok_t *results, *result, *logs, *log; - size_t i; - jsmn_parser parser; - jsmntok_t *toks; - toks = tal_arr(reckless, jsmntok_t, 5000); - jsmn_init(&parser); - int res; - res = jsmn_parse(&parser, reckless->stdoutbuf, - strlen(reckless->stdoutbuf), toks, tal_count(toks)); - const char *err; - if (res == JSMN_ERROR_INVAL) - err = tal_fmt(tmpctx, "reckless returned invalid character in json " - "output"); - else if (res == JSMN_ERROR_PART) - err = tal_fmt(tmpctx, "reckless returned partial output"); - else if (res == JSMN_ERROR_NOMEM ) - err = tal_fmt(tmpctx, "insufficient tokens to parse " - "reckless output."); - else - err = NULL; - - if (err) { - plugin_log(plugin, LOG_UNUSUAL, "failed to parse json: %s", err); - response = jsonrpc_stream_fail(reckless->cmd, PLUGIN_ERROR, - err); - return command_finished(reckless->cmd, response); - } - - response = jsonrpc_stream_success(reckless->cmd); - json_array_start(response, "result"); - results = json_get_member(reckless->stdoutbuf, toks, "result"); - json_for_each_arr(i, result, results) { - json_add_string(response, - NULL, - json_strdup(reckless, reckless->stdoutbuf, - result)); - } - json_array_end(response); - json_array_start(response, "log"); - logs = json_get_member(reckless->stdoutbuf, toks, "log"); - json_for_each_arr(i, log, logs) { - json_add_string(response, - NULL, - json_strdup(reckless, reckless->stdoutbuf, - log)); - } - json_array_end(response); - - return command_finished(reckless->cmd, response); -} - -static struct command_result *reckless_fail(struct reckless *reckless, - char *err) -{ - struct json_stream *resp; - resp = jsonrpc_stream_fail(reckless->cmd, PLUGIN_ERROR, err); - return command_finished(reckless->cmd, resp); -} - -static void reckless_conn_finish(struct io_conn *conn, - struct reckless *reckless) -{ - /* FIXME: avoid EBADFD - leave stdin fd open? */ - if (errno && errno != 9) - plugin_log(plugin, LOG_DBG, "err: %s", strerror(errno)); - if (reckless->pid > 0) { - int status = 0; - pid_t p; - p = waitpid(reckless->pid, &status, WNOHANG); - /* Did the reckless process exit? */ - if (p != reckless->pid && reckless->pid) { - plugin_log(plugin, LOG_DBG, "reckless failed to exit, " - "killing now."); - kill(reckless->pid, SIGKILL); - reckless_fail(reckless, "reckless process hung"); - /* Reckless process exited and with normal status? */ - } else if (WIFEXITED(status) && !WEXITSTATUS(status)) { - plugin_log(plugin, LOG_DBG, - "Reckless subprocess complete: %s", - reckless->stdoutbuf); - reckless_result(conn, reckless); - /* Don't try to process json if python raised an error. */ - } else { - plugin_log(plugin, LOG_DBG, - "Reckless process has crashed (%i).", - WEXITSTATUS(status)); - char * err; - if (reckless->process_failed) - err = reckless->process_failed; - else - err = tal_strdup(tmpctx, "the reckless process " - "has crashed"); - reckless_fail(reckless, err); - plugin_log(plugin, LOG_UNUSUAL, - "The reckless subprocess has failed."); - } - } - io_close(conn); - tal_free(reckless); -} - -static struct io_plan *conn_init(struct io_conn *conn, struct reckless *rkls) -{ - io_set_finish(conn, reckless_conn_finish, rkls); - return read_more(conn, rkls); -} - -static void stderr_conn_finish(struct io_conn *conn, void *reckless UNUSED) -{ - io_close(conn); -} - -static struct io_plan *stderr_read_more(struct io_conn *conn, - struct reckless *rkls) -{ - rkls->stderr_read += rkls->stderr_new; - if (rkls->stderr_read == tal_count(rkls->stderrbuf)) - tal_resize(&rkls->stderrbuf, rkls->stderr_read * 2); - if (strends(rkls->stderrbuf, "[Y] to create one now.\n")) { - plugin_log(plugin, LOG_DBG, "confirming config creation"); - reckless_send_yes(rkls); - } - /* Old version of reckless installed? */ - if (strstr(rkls->stderrbuf, "error: unrecognized arguments: --json")) { - plugin_log(plugin, LOG_DBG, "Reckless call failed due to old " - "installed version."); - rkls->process_failed = tal_strdup(plugin, "The installed " - "reckless utility is out of " - "date. Please update to use " - "the RPC plugin."); - } - return io_read_partial(conn, rkls->stderrbuf + rkls->stderr_read, - tal_count(rkls->stderrbuf) - rkls->stderr_read, - &rkls->stderr_new, stderr_read_more, rkls); -} - -static struct io_plan *stderr_conn_init(struct io_conn *conn, - struct reckless *reckless) -{ - io_set_finish(conn, stderr_conn_finish, NULL); - return stderr_read_more(conn, reckless); -} - -static struct command_result *reckless_call(struct command *cmd, - const char *subcommand, - const char *target, - const char *target2) -{ - if (!subcommand || !target) - return command_fail(cmd, PLUGIN_ERROR, "invalid reckless call"); - char **my_call; - my_call = tal_arrz(tmpctx, char *, 0); - tal_arr_expand(&my_call, "reckless"); - tal_arr_expand(&my_call, "-v"); - tal_arr_expand(&my_call, "--json"); - tal_arr_expand(&my_call, "-l"); - tal_arr_expand(&my_call, lconfig.lightningdir); - tal_arr_expand(&my_call, "--network"); - tal_arr_expand(&my_call, lconfig.network); - if (lconfig.config) { - tal_arr_expand(&my_call, "--conf"); - tal_arr_expand(&my_call, lconfig.config); - } - tal_arr_expand(&my_call, (char *) subcommand); - tal_arr_expand(&my_call, (char *) target); - if (target2) - tal_arr_expand(&my_call, (char *) target2); - tal_arr_expand(&my_call, NULL); - struct reckless *reckless; - reckless = tal(NULL, struct reckless); - reckless->cmd = cmd; - reckless->stdoutbuf = tal_arrz(reckless, char, 4096); - reckless->stderrbuf = tal_arrz(reckless, char, 4096); - reckless->stdout_read = 0; - reckless->stdout_new = 0; - reckless->stderr_read = 0; - reckless->stderr_new = 0; - reckless->process_failed = NULL; - char * full_cmd; - full_cmd = tal_fmt(tmpctx, "calling:"); - for (int i=0; ipid = pipecmdarr(&reckless->stdinfd, &reckless->stdoutfd, - &reckless->stderrfd, my_call); - - if (reckless->pid < 0) { - return command_fail(cmd, LIGHTNINGD, "reckless failed: %s", - strerror(errno)); - } - - io_new_conn(reckless, reckless->stdoutfd, conn_init, reckless); - io_new_conn(reckless, reckless->stderrfd, stderr_conn_init, reckless); - tal_free(my_call); - return command_still_pending(cmd); -} - -static struct command_result *json_reckless(struct command *cmd, - const char *buf, - const jsmntok_t *params) -{ - const char *command; - const char *target; - const char *target2; - /* Allow check command to evaluate. */ - if (!param(cmd, buf, params, - p_req("command", param_string, &command), - p_req("target/subcommand", param_string, &target), - p_opt("target", param_string, &target2), - NULL)) - return command_param_failed(); - return reckless_call(cmd, command, target, target2); -} - -static const char *init(struct command *init_cmd, - const char *buf UNUSED, - const jsmntok_t *config UNUSED) -{ - plugin = init_cmd->plugin; - rpc_scan(init_cmd, "listconfigs", - take(json_out_obj(NULL, NULL, NULL)), - "{configs:{" - "conf?:{value_str:%}," - "lightning-dir:{value_str:%}," - "network:{value_str:%}" - "}}", - JSON_SCAN_TAL(plugin, json_strdup, &lconfig.config), - JSON_SCAN_TAL(plugin, json_strdup, &lconfig.lightningdir), - JSON_SCAN_TAL(plugin, json_strdup, &lconfig.network)); - /* These lightning config parameters need to stick around for each - * reckless call. */ - if (lconfig.config) - notleak(lconfig.config); - notleak(lconfig.lightningdir); - notleak(lconfig.network); - plugin_log(plugin, LOG_DBG, "plugin initialized!"); - plugin_log(plugin, LOG_DBG, "lightning-dir: %s", lconfig.lightningdir); - return NULL; -} - -static const struct plugin_command commands[] = { - { - "reckless", - json_reckless, - }, -}; - -int main(int argc, char **argv) -{ - setup_locale(); - - plugin_main(argv, init, NULL, PLUGIN_RESTARTABLE, true, - NULL, - commands, ARRAY_SIZE(commands), - NULL, 0, /* Notifications */ - NULL, 0, /* Hooks */ - NULL, 0, /* Notification topics */ - NULL); /* plugin options */ - - return 0; -} - diff --git a/plugins/src/options.rs b/plugins/src/options.rs index cfe8d12f39e5..6c78db44727a 100644 --- a/plugins/src/options.rs +++ b/plugins/src/options.rs @@ -132,8 +132,7 @@ //! Ok(()) //! } //! ``` -use serde::Serialize; -use serde::ser::{SerializeSeq, Serializer}; +use serde::{Deserialize, Serialize}; pub mod config_type { #[derive(Clone, Debug)] @@ -435,7 +434,7 @@ impl<'a> OptionType<'a> for config_type::Boolean { } } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub enum ValueType { #[serde(rename = "string")] String, @@ -447,7 +446,8 @@ pub enum ValueType { Flag, } -#[derive(Clone, Debug)] +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(untagged)] pub enum Value { String(String), Integer(i64), @@ -456,33 +456,6 @@ pub enum Value { IntegerArray(Vec), } -impl Serialize for Value { - fn serialize(&self, serializer: S) -> std::prelude::v1::Result - where - S: Serializer, - { - match self { - Value::String(s) => serializer.serialize_str(s), - Value::Integer(i) => serializer.serialize_i64(*i), - Value::Boolean(b) => serializer.serialize_bool(*b), - Value::StringArray(sa) => { - let mut seq = serializer.serialize_seq(Some(sa.len()))?; - for element in sa { - seq.serialize_element(element)?; - } - seq.end() - } - Value::IntegerArray(sa) => { - let mut seq = serializer.serialize_seq(Some(sa.len()))?; - for element in sa { - seq.serialize_element(element)?; - } - seq.end() - } - } - } -} - impl Value { /// Returns true if the `Value` is a String. Returns false otherwise. /// diff --git a/tests/data/recklessrepo/lightningd/testpluginvaliddeps/requirements.txt b/tests/data/recklessrepo/lightningd/testpluginvaliddeps/requirements.txt new file mode 100644 index 000000000000..9b0af6de3e96 --- /dev/null +++ b/tests/data/recklessrepo/lightningd/testpluginvaliddeps/requirements.txt @@ -0,0 +1 @@ +sofijowesifjwoiefjow diff --git a/tests/data/recklessrepo/lightningd/testpluginvaliddeps/testpluginvaliddeps.py b/tests/data/recklessrepo/lightningd/testpluginvaliddeps/testpluginvaliddeps.py new file mode 100644 index 000000000000..19ea984d11fe --- /dev/null +++ b/tests/data/recklessrepo/lightningd/testpluginvaliddeps/testpluginvaliddeps.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +from pyln.client import Plugin +import sofijowesifjwoiefjow # noqa: F401 + +plugin = Plugin() + +__version__ = "v1" + + +@plugin.init() +def init(options, configuration, plugin, **kwargs): + plugin.log("testplug initialized") + + +@plugin.method("testmethod") +def testmethod(plugin): + return "I live." + + +@plugin.method("gettestplugversion") +def gettestplugversion(plugin): + "to test commit/tag checkout" + return __version__ + + +plugin.run() diff --git a/tests/data/recklessrepo/lightningd/testplugmani/manifest.json b/tests/data/recklessrepo/lightningd/testplugmani/manifest.json new file mode 100644 index 000000000000..ca297a3fa27e --- /dev/null +++ b/tests/data/recklessrepo/lightningd/testplugmani/manifest.json @@ -0,0 +1,8 @@ +{ + "entrypoint": "run.py", + "install_cmd": [ + "uv venv --clear", + "uv pip install --python .venv -r reqs.txt" + ], + "offer": "" +} diff --git a/tests/data/recklessrepo/lightningd/testplugmani/reqs.txt b/tests/data/recklessrepo/lightningd/testplugmani/reqs.txt new file mode 100644 index 000000000000..7b19e677138d --- /dev/null +++ b/tests/data/recklessrepo/lightningd/testplugmani/reqs.txt @@ -0,0 +1,2 @@ +pyln-client + diff --git a/tests/data/recklessrepo/lightningd/testplugmani/run.py b/tests/data/recklessrepo/lightningd/testplugmani/run.py new file mode 100755 index 000000000000..41f41d4d1969 --- /dev/null +++ b/tests/data/recklessrepo/lightningd/testplugmani/run.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python + +import os +from pathlib import Path + +TARGET_SCRIPT = "testplugpass.py" + + +def get_venv_python(): + base = Path(__file__).resolve().parent + + if os.name == "nt": + return base / ".venv" / "Scripts" / "python.exe" + return base / ".venv" / "bin" / "python" + + +def main(): + base = Path(__file__).resolve().parent + venv_python = get_venv_python() + target = base / TARGET_SCRIPT + + if not venv_python.exists(): + raise SystemExit(f"Missing venv python: {venv_python}") + + if not target.exists(): + raise SystemExit(f"Missing target script: {target}") + + # Replace current process (clean execution) + os.execv(str(venv_python), [str(venv_python), str(target)]) + + +if __name__ == "__main__": + main() diff --git a/tests/data/recklessrepo/lightningd/testplugmani/testplugpass.py b/tests/data/recklessrepo/lightningd/testplugmani/testplugpass.py new file mode 100755 index 000000000000..435f979f23a0 --- /dev/null +++ b/tests/data/recklessrepo/lightningd/testplugmani/testplugpass.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +from pyln.client import Plugin + +plugin = Plugin() + +__version__ = 'v1' + + +@plugin.init() +def init(options, configuration, plugin, **kwargs): + plugin.log("testplug initialized") + + +@plugin.method("testmethod") +def testmethod(plugin): + return ("I live.") + + +@plugin.method("gettestplugversion") +def gettestplugversion(plugin): + "to test commit/tag checkout" + return __version__ + + +plugin.run() diff --git a/tests/data/recklessrepo/lightningd/testplugpyproj/poetry.lock b/tests/data/recklessrepo/lightningd/testplugpyproj/poetry.lock new file mode 100644 index 000000000000..56f93ea8f081 --- /dev/null +++ b/tests/data/recklessrepo/lightningd/testplugpyproj/poetry.lock @@ -0,0 +1,523 @@ +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. + +[[package]] +name = "asn1crypto" +version = "1.5.1" +description = "Fast ASN.1 parser and serializer with definitions for private keys, public keys, certificates, CRL, OCSP, CMS, PKCS#3, PKCS#7, PKCS#8, PKCS#12, PKCS#5, X.509 and TSP" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67"}, + {file = "asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c"}, +] + +[[package]] +name = "base58" +version = "2.1.1" +description = "Base58 and Base58Check implementation." +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2"}, + {file = "base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c"}, +] + +[package.extras] +tests = ["PyHamcrest (>=2.0.2)", "mypy", "pytest (>=4.6)", "pytest-benchmark", "pytest-cov", "pytest-flake8"] + +[[package]] +name = "bitarray" +version = "3.8.1" +description = "efficient arrays of booleans -- C extension" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "bitarray-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30d42c34da2974a5e2e0b51c57ecf89892c1e83ed67e1084d1e27eefc27add91"}, + {file = "bitarray-3.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0793c51d3b1c7410bde1f7254fff71fabff1bc0cdeba1fa51319ac4e7931df3d"}, + {file = "bitarray-3.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133648c3405564e7fef9103f1768cb018de1b4976f3d8beff09cd4acea73bfe4"}, + {file = "bitarray-3.8.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4fd3399eaf6f1c77ea3132611efbc3d7a8c0eb899793387b3266be221dc75fd"}, + {file = "bitarray-3.8.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3b9790ae107fc8648155f120e80a58ef8e94424efefff5b355de84061de6a18b"}, + {file = "bitarray-3.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af01133e78e5528ee282ceb1cf4bc54aecb937c2001913e751452ad7dffbbeb1"}, + {file = "bitarray-3.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2da2ca9495668ab77132a911f6bd530d2bfe686d10467584894efc3b66e9ffb5"}, + {file = "bitarray-3.8.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:72a0e87b2196120523fc6194ca6b580fcffa12d7daa4d57a16d7838e60f82d0e"}, + {file = "bitarray-3.8.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:defa3c12cb06b2fd2066a9e21bf00aab96465be84d9585c8c05195f080510506"}, + {file = "bitarray-3.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7eae9e763fbd32f19f2a66dfc2e37906f8422e0c4ad4a6c9dcf9d3246740812e"}, + {file = "bitarray-3.8.1-cp310-cp310-win32.whl", hash = "sha256:3b9358f6437a5fa0c765ffae5810c9830547baf4bcf469438b82845c3f33f998"}, + {file = "bitarray-3.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:6f92d12a46b2a67d56194bb5d226dabf586b386d1f1a5e25be5b745a3080dbba"}, + {file = "bitarray-3.8.1-cp310-cp310-win_arm64.whl", hash = "sha256:8e12d50d4d65c74bd877e15c276992263b878456a7cfcf72521e7205a553557f"}, + {file = "bitarray-3.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:660e11b9932f58f10151d0febd11f77d3b0d48d6fa4dd4686d8983f40187101e"}, + {file = "bitarray-3.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fb1df55f5700187c6db4b47dbdaf8a0653a111341ac7fccc596b397aa3399e65"}, + {file = "bitarray-3.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:838fd67b3d00c5a64181073282a2c0bf8f76465da4844d5e79d2dbbc64c987dc"}, + {file = "bitarray-3.8.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5743f532e408cfd716fa16776b5a6447b83ff2cf39021fb5f8d052aa0f331508"}, + {file = "bitarray-3.8.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c8c66f5d8055cb84ad0ea14af57b3579cb0b6db589f2086f5e33f0922cf2354"}, + {file = "bitarray-3.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c3fe25871f1758519a3ad8dcafb1bd95c5d1aaeb122e6492ac739ab11fa5907"}, + {file = "bitarray-3.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e9ff57452fcadfd1a379314234657b8f4e9967ae64480ddf7c2fd82139bc8cf8"}, + {file = "bitarray-3.8.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4e34f1cb6cdb036c5f4a839a2b74419f75fa36177a70c4bab2867f48973cbe44"}, + {file = "bitarray-3.8.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:698c37fca3761af69a09a1d39cc0492f7e8cb9e263af39a288dce8f3b8a9e2bc"}, + {file = "bitarray-3.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:81ede1f094f26eeaff62e029ff1bc4e84e9d568f20d4669f64dcf7c7b18a28fc"}, + {file = "bitarray-3.8.1-cp311-cp311-win32.whl", hash = "sha256:8a345b5dc8ab8cafdf338e08530d48fe3f73df27f4ff569be793c7a7e7bb6b6b"}, + {file = "bitarray-3.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:ddcd25a1f72b2b545fb27e17882046a6c161f3f24514b2e028c00c58ed73a2dd"}, + {file = "bitarray-3.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:dc2cab92c42991b711132bc52405680e075d1505d4356c4468bc6e9c93d49137"}, + {file = "bitarray-3.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4494c599effa16064f2b600f6eb28115182d6826847d795a55691339788d8a4d"}, + {file = "bitarray-3.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ff2ca039a161d49a8c713f5380def315c6f793df5fe348b94782b1dbee37a644"}, + {file = "bitarray-3.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df3ffa6ef88166bb36f5d1492e71e664868b9b8b6afd55821e0ac0cb96625441"}, + {file = "bitarray-3.8.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:478b9f0ea86f957624dd2b159066855716f78db94666e9b04babe85fc013e01b"}, + {file = "bitarray-3.8.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e127b2e7fc533728295196f9265d12834530f475bc6cd6f74619df415d04b8b1"}, + {file = "bitarray-3.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ef49462a615de062dcac8281944d0b036fe1e9c96a6c690bf6cf5e4b5488f0e"}, + {file = "bitarray-3.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4da256fc567a57ded2a4aa962fc9e9d430ab740e5c67be9e98a63ef4eb467f2f"}, + {file = "bitarray-3.8.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b46b7aec9272fd81c984e723e599957629a91204120b3e7f0933f138e0792fdf"}, + {file = "bitarray-3.8.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2dc07dab252c63c4f6600e200b26fa05207db6b650d41ae88ab0cec4d6c59459"}, + {file = "bitarray-3.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:29c8c10a49d6a9586f592116618b99c3dabcb24d881b7a649e0691ef87f314c4"}, + {file = "bitarray-3.8.1-cp312-cp312-win32.whl", hash = "sha256:67125404d12547443d74113862a80c10310cf875aff8dbfc5548fee1d9737123"}, + {file = "bitarray-3.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ba0339d6aa80615a17f47fabc5700485e9469121d658458f95cdd2003288c28b"}, + {file = "bitarray-3.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:c0b367a00e8c88a714b2384c97dedcc85340547b3a54b6037a42fca5554d0576"}, + {file = "bitarray-3.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:55f4b105a1686eb486069a9e578d502d1998e890d8144012225de9e0450aeabd"}, + {file = "bitarray-3.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b3118ec012a799456f7fca6cc002c078590578b7640fbaab52d8ecb9a651f1c1"}, + {file = "bitarray-3.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2762db8049b230520358ac742cbc57bceaacebe34e5d25c096f2b4bc3887a3a8"}, + {file = "bitarray-3.8.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b67b869f860eb19055e2560844d8c7d0935245938935bdb764b3e683e2014e2"}, + {file = "bitarray-3.8.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a661f3492462e7adf8a054fb7414a22fc8251f1e18b9d8cbcf008d2dc85f012"}, + {file = "bitarray-3.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:300e3026d17ae3328320ba78d3165bdb1c43d0dfdbc461a69ebbdc005d9ce0b3"}, + {file = "bitarray-3.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ad5a71c1ef4a2e404c2c888db09226c821d9d14eff8813e1da873572f5fbb89d"}, + {file = "bitarray-3.8.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:78cbda57a2808d994517b53571eaa2d9299359f63aa71cf4bc94210169aad8b1"}, + {file = "bitarray-3.8.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:89c7c125a0913d71ba9cc1fa8e14c7cfe1517b1c1f45416e1f9babcedd3b545d"}, + {file = "bitarray-3.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7875abfd90f2ae3aa22d50f3fa1c93bbae456458cc73d3179b838f07bed1fc10"}, + {file = "bitarray-3.8.1-cp313-cp313-win32.whl", hash = "sha256:21add0aa968496a2bd8341d85720d09808e22e0adc7dbefc1e0f8f67c4b83f36"}, + {file = "bitarray-3.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:40d1b57012bf9b4fefd25345aaa95aab3ca510cc693f33c2cb02a4b771d8e51a"}, + {file = "bitarray-3.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:72b32d8c471930c95d49640ec99f7694f9b040ca1342ff03ed69d3aea90f9339"}, + {file = "bitarray-3.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fe989bbed9d6f332c1e24d333936f3fa1375f380cd8028da0b985dcdefa6015a"}, + {file = "bitarray-3.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:75e33c9187da271d1dbeb2582ab2df2e441346492098f67559b09173ea4edde4"}, + {file = "bitarray-3.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd7e3158be382f8f140caccc0dc7742a7553ce4bf2978982abe3054d2cedd705"}, + {file = "bitarray-3.8.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9fa5620f7f352f9706924c0e2071a212be36421f09ee064b0fd7e1128289fcdb"}, + {file = "bitarray-3.8.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:190b20cbffc9cd7f308f7a57d406119c3af3ae197613325fd2d92d99c8882ad6"}, + {file = "bitarray-3.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec3d0a6c37a816ea6e3550697c60d90861c9b0f982a98a40b59ac1f7a360bfa9"}, + {file = "bitarray-3.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:746e25f17ba4203b5933773782cf2d30bca5cdb66a9ba5d48a53a6c795aedc57"}, + {file = "bitarray-3.8.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ab363a5baae965fb3438f2137583853ad9c77d7e45f2a62ba63e609a34d792ea"}, + {file = "bitarray-3.8.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5e30d8e399f38ae1ec86aa9be76d20ba15872dd0c41b4b46d1b78905857363b9"}, + {file = "bitarray-3.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0f099a4a77daf9bb99787070854894fe588c7d6988ea729f970ba2b3b82c7559"}, + {file = "bitarray-3.8.1-cp314-cp314-win32.whl", hash = "sha256:539880ddf9a8cc54c9e6126e7d072c991563f0c90ef73b3519a783d53df00352"}, + {file = "bitarray-3.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:c08cd5b19c570e1e9e094a6ce70d35bb39d12360e0763474ed9374229f174fcc"}, + {file = "bitarray-3.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0da5f17bed67ffe1d72f79fbf98403513a6e51a4f9b8293c1ff8a64e121242be"}, + {file = "bitarray-3.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:154a19e1dcd430494fdad7d1a0fb36383baaa363e1cb9d5a7b744cd2418c44d2"}, + {file = "bitarray-3.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:814bb54db2a016026efc055a3527461e5eb551c0d91b32eeade003829ff84311"}, + {file = "bitarray-3.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac49519fcfeb4a7ecdf6b7d0ec6cac409e59f94c1bb54630db577a97893b6e38"}, + {file = "bitarray-3.8.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:329b994944993c45c3845047476ef4f231fe1a53972f18f8d005fd12fac163e1"}, + {file = "bitarray-3.8.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1d7b786a1ddd9b8dda17c445060a94a465cba2e113603ae7bdc5364efc1efd11"}, + {file = "bitarray-3.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd9b848c17ef034f2ae31b2a1bd9276710c2baf03509f1f3fa4dc4382b0a1b53"}, + {file = "bitarray-3.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0a33f8931ac91ebc23ce4decb99ed8fdddba2bafd2af3bb2781bcfd9878d4822"}, + {file = "bitarray-3.8.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07626f76a248fce5ebbb10fb0d4899d3c7f908ba21cb2fb4f5a7a9daf24c20cd"}, + {file = "bitarray-3.8.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:18f3a2c8908e63a66d3994808254397a5f989b1fb91087c33739f62bf1a1a064"}, + {file = "bitarray-3.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ced27af6aee28782260bfa5643797937e96a6489bca972202834017208cf74f5"}, + {file = "bitarray-3.8.1-cp314-cp314t-win32.whl", hash = "sha256:cf99e36c0f6ae5643ecef7ad7e1194aeb4a9798d9cff60b20ac041533fa6db0a"}, + {file = "bitarray-3.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9befda0dbd27ed95fba1c26be4bf98a49ba166b3c91beb5fc04364c130ce950c"}, + {file = "bitarray-3.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:4b7d7d10a1c82050efbb9a83d7a43974f70cf8f021afb86463b42e4ac4e5a46b"}, + {file = "bitarray-3.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fd6b5b6df14f98b2e7e474c1c7ea55fc32dcab038b3b34b76a591dec8ba50915"}, + {file = "bitarray-3.8.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:eb9fa02b9f5bbdb1d036a0c68999337793fa244528e0ce825e4b97cb7f7db99f"}, + {file = "bitarray-3.8.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6956ef0259a037f10da767741aca82925f6f9978bb6dceb5344e56ce0629ab07"}, + {file = "bitarray-3.8.1-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:78ab0d4166cf35c73054d1e04f224af1edc3cb4d75da8b6f74f4cff7c300f358"}, + {file = "bitarray-3.8.1-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10c0caabff00ab0631d1e4fd25f56c7a5cf0f068426e5860d28dbbb972b509bf"}, + {file = "bitarray-3.8.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:430fe5150816445c8294a36ce2612360037342d750cea179efe5de38c66670a8"}, + {file = "bitarray-3.8.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:c3387c314695f9790dce12fcf44357197ebf773651b6a4195f5e091cf500ae73"}, + {file = "bitarray-3.8.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:a681bbf9f94027d66e15974cd207cec1a2993837b9c45acf5f6b22a67632b1c2"}, + {file = "bitarray-3.8.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:4c7ce072191ba23a4a4876452ccd5f2a67b926e66a248d052d39e9969cd3ab47"}, + {file = "bitarray-3.8.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:03fe327549f177040b32f7faa736dc152be936d8b264d8b84f94c75f1379bfa1"}, + {file = "bitarray-3.8.1-cp38-cp38-win32.whl", hash = "sha256:2b9916867fa1ed815739e3e37dda458f397dee25a0e293b808839cfc2a396ca0"}, + {file = "bitarray-3.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:69c8298e8197b113f765a2ea60f49ceb8e1ea9eb308140b3cdc611e0d1de70b8"}, + {file = "bitarray-3.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cac0145491619287ff893853bf3ca4d98d5ef94b617271184a5af68a06ac301a"}, + {file = "bitarray-3.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fd68db1a0f5d9374a7b735414efe48d2b3ecbf0adea39299bb48030988f16149"}, + {file = "bitarray-3.8.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9adacf6fdadeeb96e6c902aef08d02d2f45429fdbf0a75b80307e435156066f8"}, + {file = "bitarray-3.8.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7d5f7f6f80388ce94849775da5f4082ab5e123e259972961970e190d60f5d2b"}, + {file = "bitarray-3.8.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3a5e594b4be2dbfe021cee8d6d7d96e9bb19dee7ed7be351f43bca7a0619b978"}, + {file = "bitarray-3.8.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:190a3482818d69faef176171c7cae10d55cb4dd0c686b5aced7f592b5e5591c1"}, + {file = "bitarray-3.8.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4fb869faf4b484cb213199ced1e2732091559107637d429fc25d0a9731f5f630"}, + {file = "bitarray-3.8.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:70f70ea138e69ec3159e4a38fef52443cb8eb81388aeb241b273265ea16387c5"}, + {file = "bitarray-3.8.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0256d57e294414bfe4fec4f852fd1d9ae361228c71b082332bf81c8b8fc69f80"}, + {file = "bitarray-3.8.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ef123b6aead12e0784f72970e8d94a96ac0d0aa4438c7ab9235e2f8669a0a5ae"}, + {file = "bitarray-3.8.1-cp39-cp39-win32.whl", hash = "sha256:c263ed9922942353a954cfbcd5f81b7626c0e20dc7f3e53d4926e8bc560ab845"}, + {file = "bitarray-3.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:7c133052737c7c75bfa49f5ba71918166fe988995b26a0d2f263a79bf8fed58a"}, + {file = "bitarray-3.8.1-cp39-cp39-win_arm64.whl", hash = "sha256:20e412527ec1aac7e3a6542b32a9c34bb852c954676b05008f0e3d58c390a0ac"}, + {file = "bitarray-3.8.1.tar.gz", hash = "sha256:f90bb3c680804ec9630bcf8c0965e54b4de84d33b17d7da57c87c30f0c64c6f5"}, +] + +[[package]] +name = "bitstring" +version = "4.4.0" +description = "Simple construction, analysis and modification of binary data." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "bitstring-4.4.0-py3-none-any.whl", hash = "sha256:feac49524fcf3ef27e6081e86f02b10d2adf6c3773bf22fbe0e7eea9534bc737"}, + {file = "bitstring-4.4.0.tar.gz", hash = "sha256:e682ac522bb63e041d16cbc9d0ca86a4f00194db16d0847c7efe066f836b2e37"}, +] + +[package.dependencies] +bitarray = ">=3.0.0,<4.0" +tibs = ">=0.5.6,<0.6" + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "coincurve" +version = "20.0.0" +description = "Cross-platform Python CFFI bindings for libsecp256k1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "coincurve-20.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d559b22828638390118cae9372a1bb6f6594f5584c311deb1de6a83163a0919b"}, + {file = "coincurve-20.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33d7f6ebd90fcc550f819f7f2cce2af525c342aac07f0ccda46ad8956ad9d99b"}, + {file = "coincurve-20.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22d70dd55d13fd427418eb41c20fde0a20a5e5f016e2b1bb94710701e759e7e0"}, + {file = "coincurve-20.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46f18d481eaae72c169f334cde1fd22011a884e0c9c6adc3fdc1fd13df8236a3"}, + {file = "coincurve-20.0.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9de1ec57f43c3526bc462be58fb97910dc1fdd5acab6c71eda9f9719a5bd7489"}, + {file = "coincurve-20.0.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a6f007c44c726b5c0b3724093c0d4fb8e294f6b6869beb02d7473b21777473a3"}, + {file = "coincurve-20.0.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0ff1f3b81330db5092c24da2102e4fcba5094f14945b3eb40746456ceabdd6d9"}, + {file = "coincurve-20.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:82f7de97694d9343f26bd1c8e081b168e5f525894c12445548ce458af227f536"}, + {file = "coincurve-20.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:e905b4b084b4f3b61e5a5d58ac2632fd1d07b7b13b4c6d778335a6ca1dafd7a3"}, + {file = "coincurve-20.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:3657bb5ed0baf1cf8cf356e7d44aa90a7902cc3dd4a435c6d4d0bed0553ad4f7"}, + {file = "coincurve-20.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:44087d1126d43925bf9a2391ce5601bf30ce0dba4466c239172dc43226696018"}, + {file = "coincurve-20.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ccf0ba38b0f307a9b3ce28933f6c71dc12ef3a0985712ca09f48591afd597c8"}, + {file = "coincurve-20.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:566bc5986debdf8572b6be824fd4de03d533c49f3de778e29f69017ae3fe82d8"}, + {file = "coincurve-20.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4d70283168e146f025005c15406086513d5d35e89a60cf4326025930d45013a"}, + {file = "coincurve-20.0.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:763c6122dd7d5e7a81c86414ce360dbe9a2d4afa1ca6c853ee03d63820b3d0c5"}, + {file = "coincurve-20.0.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f00c361c356bcea386d47a191bb8ac60429f4b51c188966a201bfecaf306ff7f"}, + {file = "coincurve-20.0.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4af57bdadd2e64d117dd0b33cfefe76e90c7a6c496a7b034fc65fd01ec249b15"}, + {file = "coincurve-20.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a26437b7cbde13fb6e09261610b788ca2a0ca2195c62030afd1e1e0d1a62e035"}, + {file = "coincurve-20.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:ed51f8bba35e6c7676ad65539c3dbc35acf014fc402101fa24f6b0a15a74ab9e"}, + {file = "coincurve-20.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:594b840fc25d74118407edbbbc754b815f1bba9759dbf4f67f1c2b78396df2d3"}, + {file = "coincurve-20.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4df4416a6c0370d777aa725a25b14b04e45aa228da1251c258ff91444643f688"}, + {file = "coincurve-20.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1ccc3e4db55abf3fc0e604a187fdb05f0702bc5952e503d9a75f4ae6eeb4cb3a"}, + {file = "coincurve-20.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac8335b1658a2ef5b3eb66d52647742fe8c6f413ad5b9d5310d7ea6d8060d40f"}, + {file = "coincurve-20.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7ac025e485a0229fd5394e0bf6b4a75f8a4f6cee0dcf6f0b01a2ef05c5210ff"}, + {file = "coincurve-20.0.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e46e3f1c21b3330857bcb1a3a5b942f645c8bce912a8a2b252216f34acfe4195"}, + {file = "coincurve-20.0.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:df9ff9b17a1d27271bf476cf3fa92df4c151663b11a55d8cea838b8f88d83624"}, + {file = "coincurve-20.0.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4155759f071375699282e03b3d95fb473ee05c022641c077533e0d906311e57a"}, + {file = "coincurve-20.0.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0530b9dd02fc6f6c2916716974b79bdab874227f560c422801ade290e3fc5013"}, + {file = "coincurve-20.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:eacf9c0ce8739c84549a89c083b1f3526c8780b84517ee75d6b43d276e55f8a0"}, + {file = "coincurve-20.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:52a67bfddbd6224dfa42085c88ad176559801b57d6a8bd30d92ee040de88b7b3"}, + {file = "coincurve-20.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:61e951b1d695b62376f60519a84c4facaf756eeb9c5aff975bea0942833f185d"}, + {file = "coincurve-20.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e9e548db77f4ea34c0d748dddefc698adb0ee3fab23ed19f80fb2118dac70f6"}, + {file = "coincurve-20.0.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cdbf0da0e0809366fdfff236b7eb6e663669c7b1f46361a4c4d05f5b7e94c57"}, + {file = "coincurve-20.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72222b4ecd3952e8ffcbf59bc7e0d1b181161ba170b60e5c8e1f359a43bbe7e"}, + {file = "coincurve-20.0.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9add43c4807f0c17a940ce4076334c28f51d09c145cd478400e89dcfb83fb59d"}, + {file = "coincurve-20.0.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bcc94cceea6ec8863815134083e6221a034b1ecef822d0277cf6ad2e70009b7f"}, + {file = "coincurve-20.0.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ffbdfef6a6d147988eabaed681287a9a7e6ba45ecc0a8b94ba62ad0a7656d97"}, + {file = "coincurve-20.0.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:13335c19c7e5f36eaba2a53c68073d981980d7dc7abfee68d29f2da887ccd24e"}, + {file = "coincurve-20.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:7fbfb8d16cf2bea2cf48fc5246d4cb0a06607d73bb5c57c007c9aed7509f855e"}, + {file = "coincurve-20.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4870047704cddaae7f0266a549c927407c2ba0ec92d689e3d2b511736812a905"}, + {file = "coincurve-20.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:81ce41263517b0a9f43cd570c87720b3c13324929584fa28d2e4095969b6015d"}, + {file = "coincurve-20.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:572083ccce6c7b514d482f25f394368f4ae888f478bd0b067519d33160ea2fcc"}, + {file = "coincurve-20.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee5bc78a31a2f1370baf28aaff3949bc48f940a12b0359d1cd2c4115742874e6"}, + {file = "coincurve-20.0.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2895d032e281c4e747947aae4bcfeef7c57eabfd9be22886c0ca4e1365c7c1f"}, + {file = "coincurve-20.0.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d3e2f21957ada0e1742edbde117bb41758fa8691b69c8d186c23e9e522ea71cd"}, + {file = "coincurve-20.0.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c2baa26b1aad1947ca07b3aa9e6a98940c5141c6bdd0f9b44d89e36da7282ffa"}, + {file = "coincurve-20.0.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7eacc7944ddf9e2b7448ecbe84753841ab9874b8c332a4f5cc3b2f184db9f4a2"}, + {file = "coincurve-20.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:c293c095dc690178b822cadaaeb81de3cc0d28f8bdf8216ed23551dcce153a26"}, + {file = "coincurve-20.0.0-cp39-cp39-win_arm64.whl", hash = "sha256:11a47083a0b7092d3eb50929f74ffd947c4a5e7035796b81310ea85289088c7a"}, + {file = "coincurve-20.0.0.tar.gz", hash = "sha256:872419e404300302e938849b6b92a196fabdad651060b559dc310e52f8392829"}, +] + +[package.dependencies] +asn1crypto = "*" +cffi = ">=1.3.0" + +[package.extras] +dev = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "cryptography" +version = "48.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.9" +groups = ["main"] +files = [ + {file = "cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5"}, + {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321"}, + {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74"}, + {file = "cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4"}, + {file = "cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7"}, + {file = "cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057"}, + {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae"}, + {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c"}, + {file = "cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f"}, + {file = "cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12"}, + {file = "cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239"}, + {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c"}, + {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4"}, + {file = "cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd"}, + {file = "cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a"}, + {file = "cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} + +[package.extras] +ssh = ["bcrypt (>=3.1.5)"] + +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + +[[package]] +name = "pyln-bolt7" +version = "1.0.246" +description = "BOLT7" +optional = false +python-versions = ">=3.7,<4.0" +groups = ["main"] +files = [ + {file = "pyln-bolt7-1.0.246.tar.gz", hash = "sha256:2b53744fa21c1b12d2c9c9df153651b122e38fa65d4a5c3f2957317ee148e089"}, + {file = "pyln_bolt7-1.0.246-py3-none-any.whl", hash = "sha256:54d48ec27fdc8751762cb068b0a9f2757a58fb57933c6d8f8255d02c27eb63c5"}, +] + +[[package]] +name = "pyln-client" +version = "23.11" +description = "Client library and plugin library for Core Lightning" +optional = false +python-versions = ">=3.8,<4.0" +groups = ["main"] +files = [ + {file = "pyln_client-23.11-py3-none-any.whl", hash = "sha256:98ff5a24232e7041970e7f1d631fd1e5044b3bb91c7cfae6f66d3894c82db2f0"}, + {file = "pyln_client-23.11.tar.gz", hash = "sha256:4102792bbdca545d5fbfa1fb1371222744e7157ec3d2c162edb03e8786db9823"}, +] + +[package.dependencies] +pyln-bolt7 = ">=1.0" +pyln-proto = ">=23" + +[[package]] +name = "pyln-proto" +version = "26.6.1" +description = "This package implements some of the Lightning Network protocol in pure python. It is intended for protocol testing and some minor tooling only. It is not deemed secure enough to handle any amount of real funds (you have been warned!)." +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "pyln_proto-26.6.1-py3-none-any.whl", hash = "sha256:834ee3b7c68604cb2a95baef45e6fd470f4ede1d6d1c6172e761d5658a5517c9"}, + {file = "pyln_proto-26.6.1.tar.gz", hash = "sha256:b56b3577e5b6b3488cd232b6191116323758aa51835fe925853f0a69576d51b7"}, +] + +[package.dependencies] +base58 = ">=2.1.1" +bitstring = ">=4.3.0" +coincurve = "20.0.0" +cryptography = ">=46" +pysocks = ">=1" + +[[package]] +name = "pysocks" +version = "1.7.1" +description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +files = [ + {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, + {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, + {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, +] + +[[package]] +name = "tibs" +version = "0.5.7" +description = "A sleek Python library for binary data." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "tibs-0.5.7-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a"}, + {file = "tibs-0.5.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f5eea45851c960628a2bd29847765d55e19a687c5374456ad2c8cf6410eb1efa"}, + {file = "tibs-0.5.7-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a9feed5931b881809a950eca0e01e757113e2383a2af06a3e6982f110c869e2"}, + {file = "tibs-0.5.7-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:501728d096e10d9a165aa526743d47418a6bbfd7b084fa47ecb22be7641d3edb"}, + {file = "tibs-0.5.7-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77103a9f1af72ac4cf5006828d0fb21578d19ce55fd990e9a1c8e46fd549561f"}, + {file = "tibs-0.5.7-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f95d5db62960205a1e9eba73ce67dc14e7366ae080cd4e5b6f005ebd90faf02"}, + {file = "tibs-0.5.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ace018a057459e3dccd06a4aae1c5c8cd57e352b263dcef534ae39bf3e03b5cf"}, + {file = "tibs-0.5.7-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2a618de62004d9217d2d2ab0f7f9bbdd098c12642dc01f07b3fb00f0b5f3131a"}, + {file = "tibs-0.5.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42725200f1b02687ed6e6a1c01e0ec150dc829d21d901ffc74cc0ac4d821f57f"}, + {file = "tibs-0.5.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:63255749f937c5e6fedcc7d54e7bd359aef711017e6855f373b0510a14ee2215"}, + {file = "tibs-0.5.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4b7510235379368b7523f624d46e0680f3706e3a3965877a6583cdcb598b8bac"}, + {file = "tibs-0.5.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29480bf03e3372a5f9cc59ea0541f76f8efd696d4f0d214715e94247c342a037"}, + {file = "tibs-0.5.7-cp314-cp314t-win32.whl", hash = "sha256:b9535dc7b7484904a58b51bd8e64da7efbf1d8466ff7e84ed1d78f4ddc561c99"}, + {file = "tibs-0.5.7-cp314-cp314t-win_amd64.whl", hash = "sha256:1906729038b85c3b4c040aa28a456d85bc976d0c5007177350eb73374ffa0fd0"}, + {file = "tibs-0.5.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7d6592ed93c6748acd39df484c1ee24d40ee247c2a20ca38ba03363506fd24f3"}, + {file = "tibs-0.5.7-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:859f05315ffb307d3474c505d694f3a547f00730a024c982f5f60316a5505b3c"}, + {file = "tibs-0.5.7-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:a883ca13a922a66b2c1326a9c188123a574741a72510a4bf52fd6f97db191e44"}, + {file = "tibs-0.5.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f70bd250769381c73110d6f24feaf8b6fcd44f680b3cb28a20ea06db3d04fb6f"}, + {file = "tibs-0.5.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76746f01b3db9dbd802f5e615f11f68df7a29ecef521b082dca53f3fa7d0084f"}, + {file = "tibs-0.5.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:847709c108800ad6a45efaf9a040628278956938a4897f7427a2587013dc3b98"}, + {file = "tibs-0.5.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad61df93b50f875b277ab736c5d37b6bce56f9abce489a22f4e02d9daa2966e3"}, + {file = "tibs-0.5.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e13b9c7ff2604b0146772025e1ac6f85c8c625bf6ac73736ff671eaf357dda41"}, + {file = "tibs-0.5.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a7ce857ef05c59dc61abadc31c4b9b1e3c62f9e5fb29217988c308936aea71e"}, + {file = "tibs-0.5.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1d5521cc6768bfa6282a0c591ba06b079ab91b5c7d5696925ad2abac59779a54"}, + {file = "tibs-0.5.7-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:477608f9b87e24a22ab6d50b81da04a5cb59bfa49598ff7ec5165035a18fb392"}, + {file = "tibs-0.5.7-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:ac0aa2aae38f7325c91c261ce1d18f769c4c7033c98d6ea3ea5534585cf16452"}, + {file = "tibs-0.5.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b56583db148e5094d781c3d746815dbcbb6378c6f813c8ce291efd4ab21da8b"}, + {file = "tibs-0.5.7-cp38-abi3-win32.whl", hash = "sha256:d4f3ff613d486650816bc5516760c0382a2cc0ca8aeddd8914d011bc3b81d9a2"}, + {file = "tibs-0.5.7-cp38-abi3-win_amd64.whl", hash = "sha256:a61d36155f8ab8642e1b6744e13822f72050fc7ec4f86ec6965295afa04949e2"}, + {file = "tibs-0.5.7-cp38-abi3-win_arm64.whl", hash = "sha256:130bc68ff500fc8185677df7a97350b5d5339e6ba7e325bc3031337f6424ede7"}, + {file = "tibs-0.5.7.tar.gz", hash = "sha256:173dfbecb2309edd9771f453580c88cf251e775613461566b23dbd756b3d54cb"}, +] + +[package.extras] +dev = ["build", "hypothesis (>=6.151.0)", "pyright (>=1.1.389)", "pytest (>=9.0.0)", "pytest-benchmark (>=5.2.0)"] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version == \"3.10\"" +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[metadata] +lock-version = "2.1" +python-versions = "^3.10" +content-hash = "b48950ea4d9650c3a44225fb9c3a1678fcb8e2e8fb1a2a4f3319483634a36cbc" diff --git a/tests/data/recklessrepo/lightningd/testplugreqopts/requirements.txt b/tests/data/recklessrepo/lightningd/testplugreqopts/requirements.txt new file mode 100644 index 000000000000..0096004b4a8d --- /dev/null +++ b/tests/data/recklessrepo/lightningd/testplugreqopts/requirements.txt @@ -0,0 +1 @@ +pyln-client diff --git a/tests/data/recklessrepo/lightningd/testplugreqopts/testplugreqopts.py b/tests/data/recklessrepo/lightningd/testplugreqopts/testplugreqopts.py new file mode 100644 index 000000000000..959a7e70f9fc --- /dev/null +++ b/tests/data/recklessrepo/lightningd/testplugreqopts/testplugreqopts.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Plugin that requires a mandatory option. Exits immediately when the +option is absent - as happens when reckless runs the plugin standalone +outside of a CLN connection.""" + +import sys + +from pyln.client import Plugin + +plugin = Plugin() + + +@plugin.init() +def init(options, configuration, plugin, **kwargs): + if "required-opt" not in options: + plugin.log("required option 'required-opt' is not configured") + sys.exit(1) + plugin.log("testplugreqopts initialized") + + +plugin.add_option("required-opt", None, "required option") +plugin.run() diff --git a/tests/test_reckless.py b/tests/test_reckless.py index d294b79a50ef..afa5fae17cb7 100644 --- a/tests/test_reckless.py +++ b/tests/test_reckless.py @@ -1,95 +1,95 @@ +import json + from fixtures import * # noqa: F401,F403 import subprocess -from pathlib import PosixPath, Path -import socket +from pathlib import Path from pyln.testing.utils import VALGRIND -import pytest import os import re -import shutil import time import unittest +import pytest @pytest.fixture(autouse=True) def canned_github_server(directory): global NETWORK - NETWORK = os.environ.get('TEST_NETWORK') + NETWORK = os.environ.get("TEST_NETWORK") if NETWORK is None: - NETWORK = 'regtest' + NETWORK = "regtest" FILE_PATH = Path(os.path.dirname(os.path.realpath(__file__))) - if os.environ.get('LIGHTNING_CLI') is None: - os.environ['LIGHTNING_CLI'] = str(FILE_PATH.parent / 'cli/lightning-cli') - print('LIGHTNING_CALL: ', os.environ.get('LIGHTNING_CLI')) - # Use socket to provision a random free port - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.bind(('localhost', 0)) - free_port = str(sock.getsockname()[1]) - sock.close() + if os.environ.get("LIGHTNING_CLI") is None: + os.environ["LIGHTNING_CLI"] = str(FILE_PATH.parent / "cli/lightning-cli") + print("LIGHTNING_CALL: ", os.environ.get("LIGHTNING_CLI")) global my_env my_env = os.environ.copy() # This tells reckless to redirect to the canned server rather than github. - my_env['REDIR_GITHUB_API'] = f'http://127.0.0.1:{free_port}/api' - my_env['REDIR_GITHUB'] = directory - my_env['FLASK_RUN_PORT'] = free_port - my_env['FLASK_APP'] = str(FILE_PATH / 'rkls_github_canned_server') - server = subprocess.Popen(["python3", "-m", "flask", "run"], - env=my_env) + os.environ["REDIR_GITHUB"] = os.path.join(directory, "lightningd") # Generate test plugin repository to test reckless against. repo_dir = os.path.join(directory, "lightningd") os.mkdir(repo_dir, 0o777) - plugins_path = str(FILE_PATH / 'data/recklessrepo/lightningd') + plugins_path = str(FILE_PATH / "data/recklessrepo/lightningd") # Create requirements.txt file for the testpluginpass # with pyln-client installed from the local source - requirements_file_path = os.path.join(plugins_path, 'testplugpass', 'requirements.txt') - with open(requirements_file_path, 'w') as f: - pyln_client_path = os.path.abspath(os.path.join(FILE_PATH, '..', 'contrib', 'pyln-client')) + requirements_file_path = os.path.join( + plugins_path, "testplugpass", "requirements.txt" + ) + with open(requirements_file_path, "w") as f: + pyln_client_path = os.path.abspath( + os.path.join(FILE_PATH, "..", "contrib", "pyln-client") + ) f.write(f"pyln-client @ file://{pyln_client_path}\n") # This lets us temporarily set .gitconfig user info in order to commit - my_env['HOME'] = directory - with open(os.path.join(directory, '.gitconfig'), 'w') as conf: - conf.write(("[user]\n" - "\temail = reckless@example.com\n" - "\tname = reckless CI\n" - "\t[init]\n" - "\tdefaultBranch = master")) - - with open(os.path.join(directory, '.gitconfig'), 'r') as conf: + my_env["HOME"] = directory + with open(os.path.join(directory, ".gitconfig"), "w") as conf: + conf.write( + ( + "[user]\n" + "\temail = reckless@example.com\n" + "\tname = reckless CI\n" + "\t[init]\n" + "\tdefaultBranch = master" + ) + ) + + with open(os.path.join(directory, ".gitconfig"), "r") as conf: print(conf.readlines()) # Bare repository must be initialized prior to setting other git env vars - subprocess.check_output(['git', 'init', '--bare', 'plugins'], cwd=repo_dir, - env=my_env) - - my_env['GIT_DIR'] = os.path.join(repo_dir, 'plugins') - my_env['GIT_WORK_TREE'] = repo_dir - my_env['GIT_INDEX_FILE'] = os.path.join(repo_dir, 'scratch-index') - repo_initialization = (f'cp -r {plugins_path}/* .;' - 'git add --all;' - 'git commit -m "initial commit - autogenerated by test_reckless.py";') - tag_and_update = ('git tag v1;' - "sed -i 's/v1/v2/g' testplugpass/testplugpass.py;" - 'git add testplugpass/testplugpass.py;' - 'git commit -m "update to v2";' - 'git tag v2;') - subprocess.check_output([repo_initialization], env=my_env, shell=True, - cwd=repo_dir) - subprocess.check_output([tag_and_update], env=my_env, - shell=True, cwd=repo_dir) - del my_env['HOME'] - del my_env['GIT_DIR'] - del my_env['GIT_WORK_TREE'] - del my_env['GIT_INDEX_FILE'] - # We also need the github api data for the repo which will be served via http - shutil.copyfile(str(FILE_PATH / 'data/recklessrepo/rkls_api_lightningd_plugins.json'), os.path.join(directory, 'rkls_api_lightningd_plugins.json')) + subprocess.check_output(["git", "init"], cwd=repo_dir, env=my_env) + + # my_env["GIT_DIR"] = os.path.join(repo_dir, "plugins") + # my_env["GIT_WORK_TREE"] = repo_dir + # my_env["GIT_INDEX_FILE"] = os.path.join(repo_dir, "scratch-index") + repo_initialization = ( + f"cp -r {plugins_path}/* .;" + "git add --all;" + 'git commit -m "initial commit - autogenerated by test_reckless.py";' + "git remote add origin ./;" + "git fetch origin;" + "git checkout -B master origin/master;" + ) + tag_and_update = ( + "git tag v1;" + "sed -i.bak 's/v1/v2/g' testplugpass/testplugpass.py;" + "rm -f testplugpass/testplugpass.py.bak;" + "git add testplugpass/testplugpass.py;" + 'git commit -m "update to v2";' + "git tag v2;" + ) + subprocess.check_output([repo_initialization], env=my_env, shell=True, cwd=repo_dir) + subprocess.check_output([tag_and_update], env=my_env, shell=True, cwd=repo_dir) + del my_env["HOME"] + # del my_env["GIT_DIR"] + # del my_env["GIT_WORK_TREE"] + # del my_env["GIT_INDEX_FILE"] yield # Delete requirements.txt from the testplugpass directory - with open(requirements_file_path, 'w') as f: - f.write(f"pyln-client\n\n") - server.terminate() + with open(requirements_file_path, "w") as f: + f.write("pyln-client\n\n") class RecklessResult: @@ -100,7 +100,7 @@ def __init__(self, process, returncode, stdout, stderr): self.stderr = stderr def __repr__(self): - return f'self.returncode, self.stdout, self.stderr' + return "self.returncode, self.stdout, self.stderr" def search_stdout(self, regex): """return the matching regex line from reckless output.""" @@ -111,286 +111,368 @@ def search_stdout(self, regex): matching.append(line) return matching - def check_stderr(self): - def output_okay(out): - for warning in ['[notice]', 'WARNING:', 'npm WARN', - 'npm notice', 'DEPRECATION:', 'Creating virtualenv', - 'config file not found:', 'press [Y]']: - if out.startswith(warning): - return True - return False - for e in self.stderr: - if len(e) < 1: - continue - # Don't err on verbosity from pip, npm - if not output_okay(e): - raise Exception(f'reckless stderr contains `{e}`') - - -def reckless(cmds: list, dir: PosixPath = None, - autoconfirm=True, timeout: int = 60): - '''Call the reckless executable, optionally with a directory.''' - if dir is not None: - cmds.insert(0, "-l") - cmds.insert(1, str(dir)) - cmds.insert(0, "tools/reckless") - if autoconfirm: - process_input = 'Y\n' - else: - process_input = None - r = subprocess.run(cmds, capture_output=True, encoding='utf-8', env=my_env, - input=process_input, timeout=timeout) - stdout = r.stdout.splitlines() - stderr = r.stderr.splitlines() - print(" ".join(r.args), "\n") - print("***RECKLESS STDOUT***") - for l in stdout: - print(l) - print('\n') - print("***RECKLESS STDERR***") - for l in stderr: - print(l) - print('\n') - return RecklessResult(r, r.returncode, stdout, stderr) - - -def get_reckless_node(node_factory): - '''This may be unnecessary, but a preconfigured lightning dir - is useful for reckless testing.''' - node = node_factory.get_node(options={}, start=False) - return node - - -def test_basic_help(): - '''Validate that argparse provides basic help info. - This requires no config options passed to reckless.''' - r = reckless(["-h"]) - assert r.returncode == 0 - assert r.search_stdout("positional arguments:") - assert r.search_stdout("options:") or r.search_stdout("optional arguments:") - - -def test_contextual_help(node_factory): - n = get_reckless_node(node_factory) - for subcmd in ['install', 'uninstall', 'search', - 'enable', 'disable', 'source']: - r = reckless([subcmd, "-h"], dir=n.lightning_dir) - assert r.returncode == 0 - assert r.search_stdout("positional arguments:") + +def check_stderr(logs): + def output_okay(out): + for warning in [ + "[notice]", + "WARNING:", + "npm WARN", + "npm notice", + "DEPRECATION:", + "Creating virtualenv", + "config file not found:", + "press [Y]", + ]: + if out.startswith(warning): + return True + return False + + for e in logs: + if len(e) < 1: + continue + # Don't err on verbosity from pip, npm + if not output_okay(e): + raise Exception(f"reckless stderr contains `{e}`") + + +SUBCOMMANDS = [ + "install", + "uninstall", + "search", + "listavailable", + "enable", + "disable", + "source", + "update", + "listinstalled", +] + + +def test_help(node_factory): + """Validate that reckless provides overall and subcommand help.""" + node = node_factory.get_node(options={}) + + r = node.rpc.call("reckless", ["help"], filter={"format-hint": True}) + assert "Usage: reckless [OPTIONS] [COMMAND]" in r["result"] + for subcommand in SUBCOMMANDS: + assert subcommand in r["result"] + + r = node.rpc.call("reckless", [], filter={"format-hint": True}) + assert "Usage: reckless [OPTIONS] [COMMAND]" in r["result"] + for subcommand in SUBCOMMANDS: + assert subcommand in r["result"] + + for subcmd in SUBCOMMANDS: + r = node.rpc.call("reckless", ["help", subcmd], filter={"format-hint": True}) + assert f"Usage: {subcmd}" in r["result"] + + +def test_reckless_version(node_factory): + """Version should be reported without loading config and should advance + with lightningd.""" + node = node_factory.get_node(options={}) + r = node.rpc.call("reckless", ["-V", "-v"]) + with open(".version", "r") as f: + version = f.readlines()[0].strip() + assert r["result"][0] == version def test_sources(node_factory): """add additional sources and search through them""" - n = get_reckless_node(node_factory) - r = reckless(["source", "-h"], dir=n.lightning_dir) - assert r.returncode == 0 - r = reckless(["source", "list"], dir=n.lightning_dir) - print(r.stdout) - assert r.returncode == 0 + n = node_factory.get_node(options={}) + r = n.rpc.call("reckless", ["source", "list", "-v"]) + print(r) + assert "https://github.com/lightningd/plugins" in r["result"] print(n.lightning_dir) - reckless_dir = Path(n.lightning_dir) / 'reckless' + reckless_dir = Path(n.lightning_dir) / "reckless" print(dir(reckless_dir)) - assert (reckless_dir / '.sources').exists() + assert (reckless_dir / ".sources").exists() print(os.listdir(reckless_dir)) - print(reckless_dir / '.sources') - plugin_dir = str(os.path.join(n.lightning_dir, '..', 'lightningd')) - r = reckless([f"--network={NETWORK}", "-v", "source", "add", - f'{plugin_dir}/testplugfail'], - dir=n.lightning_dir) - r = reckless([f"--network={NETWORK}", "-v", "source", "add", - f'{plugin_dir}/testplugpass'], - dir=n.lightning_dir) - with open(reckless_dir / '.sources') as sources: + print(reckless_dir / ".sources") + plugin_dir = str(os.path.join(n.lightning_dir, "..", "lightningd")) + r = n.rpc.call("reckless", ["source", "-v", "add", f"{plugin_dir}/testplugfail"]) + r = n.rpc.call("reckless", ["source", "-v", "add", f"{plugin_dir}/testplugpass"]) + with open(reckless_dir / ".sources") as sources: contents = [c.strip() for c in sources.readlines()] - print('contents:', contents) - assert 'https://github.com/lightningd/plugins' in contents - assert f'{plugin_dir}/testplugfail' in contents - assert f'{plugin_dir}/testplugpass' in contents - r = reckless([f"--network={NETWORK}", "-v", "source", "remove", - f'{plugin_dir}/testplugfail'], - dir=n.lightning_dir) - with open(reckless_dir / '.sources') as sources: + print("contents:", contents) + assert "https://github.com/lightningd/plugins" in contents + assert f"{plugin_dir}/testplugfail" in contents + assert f"{plugin_dir}/testplugpass" in contents + r = n.rpc.call( + "reckless", + [ + "source", + "-v", + "remove", + f"{plugin_dir}/testplugfail", + ], + ) + print(r) + with open(reckless_dir / ".sources") as sources: contents = [c.strip() for c in sources.readlines()] - print('contents:', contents) - assert f'{plugin_dir}/testplugfail' not in contents - assert f'{plugin_dir}/testplugpass' in contents + print("contents:", contents) + assert f"{plugin_dir}/testplugfail" not in contents + assert f"{plugin_dir}/testplugpass" in contents def test_search(node_factory): """add additional sources and search through them""" - n = get_reckless_node(node_factory) - r = reckless([f"--network={NETWORK}", "search", "testplugpass"], dir=n.lightning_dir) - assert r.returncode == 0 - assert r.search_stdout('found testplugpass in source: https://github.com/lightningd/plugins') + n = node_factory.get_node(options={}) + r = n.rpc.call("reckless", ["search", "-v", "testplugpass"]) + assert "https://github.com/lightningd/plugins" in r["result"] + assert ( + "INFO: found testplugpass in source: https://github.com/lightningd/plugins" + in r["log"] + ) def test_search_partial_match(node_factory): """test that partial/substring search returns multiple matches""" - n = get_reckless_node(node_factory) + n = node_factory.get_node(options={}) # Search for partial name "testplug" - should find all test plugins - r = reckless([f"--network={NETWORK}", "search", "testplug"], dir=n.lightning_dir) + r = n.rpc.call("reckless", ["search", "-v", "testplug"]) # Should show the "Plugins matching" header - assert r.search_stdout("Plugins matching 'testplug':") + assert "INFO: Plugins matching 'testplug':" in r["log"] # Should list multiple plugins (all start with "testplug") - assert r.search_stdout('testplugpass') - assert r.search_stdout('testplugfail') - assert r.search_stdout('testplugpyproj') - assert r.search_stdout('testpluguv') + assert any("testplugpass" in line for line in r["log"]) + assert any("testplugfail" in line for line in r["log"]) + assert any("testplugpyproj" in line for line in r["log"]) + assert any("testpluguv" in line for line in r["log"]) # Search for "pass" - should find testplugpass - r = reckless([f"--network={NETWORK}", "search", "pass"], dir=n.lightning_dir) - assert r.search_stdout("Plugins matching 'pass':") - assert r.search_stdout('testplugpass') + r = n.rpc.call("reckless", ["search", "-v", "pass"]) + assert "INFO: Plugins matching 'pass':" in r["log"] + assert any("testplugpass" in line for line in r["log"]) # Should not find plugins without "pass" in name - assert not r.search_stdout('testplugfail') + assert all("testplugfail" not in line for line in r["log"]) # Search for something that doesn't exist - r = reckless([f"--network={NETWORK}", "search", "nonexistent"], dir=n.lightning_dir) - assert r.search_stdout("Search exhausted all sources") + r = n.rpc.call("reckless", ["search", "-v", "nonexistent"]) + assert "INFO: Search exhausted all sources" in r["log"] def test_install(node_factory): """test search, git clone, and installation to folder.""" - n = get_reckless_node(node_factory) - r = reckless([f"--network={NETWORK}", "-v", "install", "testplugpass"], dir=n.lightning_dir) - assert r.returncode == 0 - assert r.search_stdout('dependencies installed successfully') - assert r.search_stdout('plugin installed:') - assert r.search_stdout('testplugpass enabled') - r.check_stderr() - plugin_path = Path(n.lightning_dir) / 'reckless/testplugpass' + n = node_factory.get_node(options={}) + r = n.rpc.call("reckless", ["install", "-v", "testplugpass"]) + assert "testplugpass" in r["result"] + print(r) + assert any("dependencies installed successfully" in line for line in r["log"]) + assert any("plugin installed:" in line for line in r["log"]) + assert any("testplugpass enabled" in line for line in r["log"]) + # check_stderr(r["log"]) TODO: check logs + plugin_path = Path(n.lightning_dir) / "reckless/testplugpass" print(plugin_path) assert os.path.exists(plugin_path) + # Try to install again - should result in a warning. + r = n.rpc.call("reckless", ["install", "-v", "testplugpass"]) + assert "testplugpass" not in r["result"] + assert any("already installed" in line for line in r["log"]) + + +def test_install_cleanup(node_factory): + """test failed start which should give a chance to enable with options and""" + """a failed installation which should cleanup the plugin folder""" + n = node_factory.get_node( + options={}, + broken_log=".*uv failed.*|.*sofijowesifjwoiefjow.*|.*unsatisfiable.*", + ) + r = n.rpc.call("reckless", ["install", "-v", "testplugreqopts"]) + assert "testplugreqopts" in r["result"] + assert any("testplugreqopts failed to start" in line for line in r["log"]) + + plugin_path = Path(n.lightning_dir) / "reckless/testplugreqopts" + assert os.path.exists(plugin_path) + + r = n.rpc.call("reckless", ["install", "-v", "testpluginvaliddeps"]) + assert "testpluginvaliddeps" not in r["result"] + assert all("dependencies installed successfully" not in line for line in r["log"]) + + plugin_path = Path(n.lightning_dir) / "reckless/testpluginvaliddeps" + assert not os.path.exists(plugin_path) + @unittest.skipIf(VALGRIND, "virtual environment triggers memleak detection") def test_poetry_install(node_factory): """test search, git clone, and installation to folder.""" - n = get_reckless_node(node_factory) - r = reckless([f"--network={NETWORK}", "-v", "install", "testplugpyproj"], dir=n.lightning_dir) - assert r.returncode == 0 - assert r.search_stdout('dependencies installed successfully') - assert r.search_stdout('plugin installed:') - assert r.search_stdout('testplugpyproj enabled') - r.check_stderr() - plugin_path = Path(n.lightning_dir) / 'reckless/testplugpyproj' + n = node_factory.get_node(options={}) + r = n.rpc.call("reckless", ["install", "-v", "testplugpyproj"]) + assert any("dependencies installed successfully" in line for line in r["log"]) + assert any("plugin installed:" in line for line in r["log"]) + # time.sleep(120) + assert any("testplugpyproj enabled" in line for line in r["log"]) + # check_stderr(r["log"]) TODO: check logs + plugin_path = Path(n.lightning_dir) / "reckless/testplugpyproj" print(plugin_path) assert os.path.exists(plugin_path) - n.start() print(n.rpc.testmethod()) - assert n.daemon.is_in_log(r'plugin-manager: started\([0-9].*\) /tmp/ltests-[a-z0-9_].*/test_poetry_install_1/lightning-1/reckless/testplugpyproj/testplugpyproj.py') - assert n.rpc.testmethod() == 'I live.' + assert n.daemon.is_in_log( + r"plugin-manager: started\([0-9].*\) /tmp/ltests-[a-z0-9_].*/test_poetry_install_1/lightning-1/reckless/testplugpyproj/testplugpyproj.py" + ) + assert n.rpc.testmethod() == "I live." @unittest.skipIf(VALGRIND, "virtual environment triggers memleak detection") def test_local_dir_install(node_factory): """Test search and install from local directory source.""" - n = get_reckless_node(node_factory) - n.start() - source_dir = str(Path(n.lightning_dir / '..' / 'lightningd' / 'testplugpass').resolve()) - r = reckless([f"--network={NETWORK}", "-v", "source", "add", source_dir], dir=n.lightning_dir) - assert r.returncode == 0 - r = reckless([f"--network={NETWORK}", "-v", "install", "testplugpass"], dir=n.lightning_dir) - assert r.returncode == 0 - assert r.search_stdout('testplugpass enabled') - plugin_path = Path(n.lightning_dir) / 'reckless/testplugpass' + n = node_factory.get_node(options={}) + source_dir = str( + Path(n.lightning_dir / ".." / "lightningd" / "testplugpass").resolve() + ) + r = n.rpc.call("reckless", ["source", "-v", "add", source_dir]) + r = n.rpc.call("reckless", ["install", "-v", "testplugpass"]) + assert any("testplugpass enabled" in line for line in r["log"]) + plugin_path = Path(n.lightning_dir) / "reckless/testplugpass" print(plugin_path) assert os.path.exists(plugin_path) # Retry with a direct install passing the full path to the local source - r = reckless(['uninstall', 'testplugpass', '-v'], dir=n.lightning_dir) + r = n.rpc.call("reckless", ["uninstall", "-v", "testplugpass"]) assert not os.path.exists(plugin_path) - r = reckless(['source', 'remove', source_dir], dir=n.lightning_dir) - assert r.search_stdout('plugin source removed') - r = reckless(['install', '-v', source_dir], dir=n.lightning_dir) - assert r.search_stdout('testplugpass enabled') + r = n.rpc.call("reckless", ["source", "-v", "remove", source_dir]) + assert any("plugin source removed" in line for line in r["log"]) + r = n.rpc.call("reckless", ["install", "-v", source_dir]) + print(r) + assert any("testplugpass enabled" in line for line in r["log"]) assert os.path.exists(plugin_path) @unittest.skipIf(VALGRIND, "virtual environment triggers memleak detection") def test_disable_enable(node_factory): """test search, git clone, and installation to folder.""" - n = get_reckless_node(node_factory) + n = node_factory.get_node(options={}) # Test case-insensitive search as well - r = reckless([f"--network={NETWORK}", "-v", "install", "testPlugPass"], - dir=n.lightning_dir) - assert r.returncode == 0 - assert r.search_stdout('dependencies installed successfully') - assert r.search_stdout('plugin installed:') - assert r.search_stdout('testplugpass enabled') - r.check_stderr() - plugin_path = Path(n.lightning_dir) / 'reckless/testplugpass' + r = n.rpc.call("reckless", ["install", "-v", "testPlugPass"]) + assert "testplugpass" in r["result"] + assert any("dependencies installed successfully" in line for line in r["log"]) + assert any("plugin installed:" in line for line in r["log"]) + assert any("testplugpass enabled" in line for line in r["log"]) + # r.check_stderr() TODO: check logs + plugin_path = Path(n.lightning_dir) / "reckless/testplugpass" print(plugin_path) assert os.path.exists(plugin_path) - r = reckless([f"--network={NETWORK}", "-v", "disable", "testPlugPass"], - dir=n.lightning_dir) - assert r.returncode == 0 - n.start() + r = n.rpc.call("reckless", ["disable", "-v", "testPlugPass"]) + assert "testplugpass" in r["result"] + # Should find it with or without the file extension - r = reckless([f"--network={NETWORK}", "-v", "enable", "testplugpass.py"], - dir=n.lightning_dir) - assert r.returncode == 0 - assert r.search_stdout('testplugpass enabled') - test_plugin = {'name': str(plugin_path / 'testplugpass.py'), - 'active': True, 'dynamic': True} + r = n.rpc.call("reckless", ["enable", "-v", "testplugpass.py"]) + assert "testplugpass" in r["result"] + assert any("testplugpass enabled" in line for line in r["log"]) + test_plugin = { + "name": str((plugin_path / "testplugpass.py").resolve()), + "active": True, + "dynamic": True, + } time.sleep(1) - print(n.rpc.plugin_list()['plugins']) - assert test_plugin in n.rpc.plugin_list()['plugins'] + print(n.rpc.plugin_list()["plugins"]) + assert test_plugin in n.rpc.plugin_list()["plugins"] @unittest.skipIf(VALGRIND, "virtual environment triggers memleak detection") def test_tag_install(node_factory): "install a plugin from a specific commit hash or tag" - node = get_reckless_node(node_factory) - node.start() - r = reckless([f"--network={NETWORK}", "-v", "install", "testPlugPass"], - dir=node.lightning_dir) - assert r.returncode == 0 + node = node_factory.get_node(options={}) + r = node.rpc.call("reckless", ["install", "-v", "testPlugPass"]) + assert "testplugpass" in r["result"] metadata = node.lightning_dir / "reckless/testplugpass/.metadata" with open(metadata, "r") as md: - header = '' + header = "" for line in md.readlines(): line = line.strip() - if header == 'requested commit': - assert line == 'None' + if header == "requested commit": + assert line == "None" header = line # should install v2 (latest) without specifying version = node.rpc.gettestplugversion() - assert version == 'v2' - r = reckless([f"--network={NETWORK}", "-v", "uninstall", "testplugpass"], - dir=node.lightning_dir) - r = reckless([f"--network={NETWORK}", "-v", "install", "testplugpass@v1"], - dir=node.lightning_dir) - assert r.returncode == 0 + assert version == "v2" + r = node.rpc.call("reckless", ["uninstall", "-v", "testplugpass"]) + assert "testplugpass" in r["result"] + r = node.rpc.call("reckless", ["install", "-v", "testplugpass@v1"]) + assert "testplugpass" in r["result"] # v1 should now be checked out. version = node.rpc.gettestplugversion() - assert version == 'v1' - installed_path = Path(node.lightning_dir) / 'reckless/testplugpass' + assert version == "v1" + installed_path = Path(node.lightning_dir) / "reckless/testplugpass" assert installed_path.is_dir() with open(metadata, "r") as md: - header = '' + header = "" for line in md.readlines(): line = line.strip() - if header == 'requested commit': - assert line == 'v1' + if header == "requested commit": + assert line == "v1" header = line +def test_install_plugin_requiring_opts(node_factory): + """A plugin that exits non-zero when run standalone (e.g. because a + required option is not yet configured) should still install successfully.""" + node = node_factory.get_node(options={}) + r = node.rpc.call("reckless", ["install", "-v", "testplugreqopts"]) + assert "testplugreqopts" in r["result"] + assert any("plugin installed:" in line for line in r["log"]) + assert any("testplugreqopts failed to start" in line for line in r["log"]) + assert all("testplugreqopts enabled" not in line for line in r["log"]) + assert any("may require options" in line for line in r["log"]) + plugin_path = Path(node.lightning_dir) / "reckless/testplugreqopts" + assert plugin_path.exists() + + r = node.rpc.call( + "reckless", ["enable", "-v", "testplugreqopts", "required-opt=foo"] + ) + assert "testplugreqopts" in r["result"] + assert any("testplugreqopts enabled" in line for line in r["log"]) + + r = node.rpc.call("reckless", ["uninstall", "-v", "testplugreqopts"]) + assert "testplugreqopts" in r["result"] + assert not plugin_path.exists() + + r = node.rpc.call( + "reckless", ["install", "-v", "testplugreqopts", "required-opt=foo"] + ) + assert "testplugreqopts" in r["result"] + assert any("plugin installed:" in line for line in r["log"]) + assert any("testplugreqopts enabled" in line for line in r["log"]) + + # Note: uv timeouts from the GH network seem to happen? @pytest.mark.slow_test -@pytest.mark.flaky(reruns=3) +@pytest.mark.flaky(max_runs=3, min_passes=1) def test_reckless_uv_install(node_factory): - node = get_reckless_node(node_factory) - node.start() - r = reckless([f"--network={NETWORK}", "-v", "install", "testpluguv"], - dir=node.lightning_dir) - assert r.returncode == 0 - installed_path = Path(node.lightning_dir) / 'reckless/testpluguv' + node = node_factory.get_node(options={}) + r = node.rpc.call("reckless", ["install", "-v", "testpluguv"]) + assert "testpluguv" in r["result"] + installed_path = Path(node.lightning_dir) / "reckless/testpluguv" assert installed_path.is_dir() - assert node.rpc.uvplugintest() == 'I live.' + assert node.rpc.uvplugintest() == "I live." version = node.rpc.getuvpluginversion() - assert version == 'v1' + assert version == "v1" + + assert any("using installer pythonuv" in line for line in r["log"]) + # r.check_stderr() TODO: check logs + + +def test_reckless_manifest(node_factory): + node, l2 = node_factory.line_graph(2, wait_for_announce=True) + + bolt12 = l2.rpc.call("offer", ["any"])["bolt12"] + manifest = node.lightning_dir / ".." / "lightningd/testplugmani/manifest.json" + with open(manifest, "r") as f: + data = json.load(f) + + data["offer"] = bolt12 + + with open(manifest, "w") as f: + json.dump(data, f, indent=4) + + r = node.rpc.call("reckless", ["install", "-v", "testplugmani"]) + assert "testplugmani" in r["result"] + assert node.rpc.testmethod() == "I live." - assert r.search_stdout('using installer pythonuv') - r.check_stderr() + r = node.rpc.call("reckless", ["tip", "-v", "testplugmani", 1000]) + assert any( + "Successfully sent 1000msat to testplugmani author!" in line + for line in r["log"] + ) diff --git a/tools/reckless b/tools/reckless deleted file mode 100755 index 529579f3ab92..000000000000 --- a/tools/reckless +++ /dev/null @@ -1,2145 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import argparse -import copy -import datetime -from enum import Enum -import json -import logging -import os -from pathlib import Path, PosixPath -import shutil -from subprocess import Popen, PIPE, TimeoutExpired, run -import tempfile -import time -import types -from typing import Union -from urllib.parse import urlparse -from urllib.request import urlopen -from urllib.error import HTTPError -import venv - - -__VERSION__ = 'v26.06' - -logging.basicConfig( - level=logging.INFO, - format='[%(asctime)s] %(levelname)s: %(message)s', - handlers=[logging.StreamHandler(stream=sys.stdout)], -) - -LAST_FOUND = None - - -def chunk_string(string: str, size: int): - for i in range(0, len(string), size): - yield string[i: i + size] - - -def ratelimit_output(output: str): - sys.stdout.reconfigure(encoding='utf-8') - for i in chunk_string(output, 1024): - sys.stdout.write(i) - sys.stdout.flush() - time.sleep(0.01) - - -class Logger: - """Redirect logging output to a json object or stdout as appropriate.""" - def __init__(self, capture: bool = False): - self.json_output = {"result": [], - "log": []} - self.capture = capture - - def str_esc(self, raw_string: str) -> str: - assert isinstance(raw_string, str) - return json.dumps(raw_string)[1:-1] - - def debug(self, to_log: str): - assert isinstance(to_log, str) or hasattr(to_log, "__repr__") - if logging.root.level > logging.DEBUG: - return - if self.capture: - self.json_output['log'].append(self.str_esc(f"DEBUG: {to_log}")) - else: - logging.debug(to_log) - - def info(self, to_log: str): - assert isinstance(to_log, str) or hasattr(to_log, "__repr__") - if logging.root.level > logging.INFO: - return - if self.capture: - self.json_output['log'].append(self.str_esc(f"INFO: {to_log}")) - else: - print(to_log) - - def warning(self, to_log: str): - assert isinstance(to_log, str) or hasattr(to_log, "__repr__") - if logging.root.level > logging.WARNING: - return - if self.capture: - self.json_output['log'].append(self.str_esc(f"WARNING: {to_log}")) - else: - logging.warning(to_log) - - def error(self, to_log: str): - assert isinstance(to_log, str) or hasattr(to_log, "__repr__") - if logging.root.level > logging.ERROR: - return - if self.capture: - self.json_output['log'].append(self.str_esc(f"ERROR: {to_log}")) - else: - logging.error(to_log) - - def add_result(self, result: Union[str, None]): - assert json.dumps(result), "result must be json serializable" - self.json_output["result"].append(result) - - def reply_json(self): - """json output to stdout with accumulated result.""" - if len(log.json_output["result"]) == 1 and \ - isinstance(log.json_output["result"][0], list): - # unpack sources output - log.json_output["result"] = log.json_output["result"][0] - output = json.dumps(log.json_output, indent=3) + '\n' - ratelimit_output(output) - - -log = Logger() - -repos = ['https://github.com/lightningd/plugins'] - - -def reckless_abort(err: str): - log.error(err) - log.add_result(None) - log.reply_json() - sys.exit(1) - - -def py_entry_guesses(name) -> list: - return [name, f'{name}.py', '__init__.py'] - - -def unsupported_entry(name) -> list: - return [f'{name}.go', f'{name}.sh'] - - -def entry_guesses(name: str) -> list: - guesses = [] - for inst in INSTALLERS: - for entry in inst.entries: - guesses.append(entry.format(name=name)) - return guesses - - -class Installer: - ''' - The identification of a plugin language, compiler or interpreter - availability, and the install procedures. - ''' - def __init__(self, name: str, - exe: Union[str, None] = None, - compiler: Union[str, None] = None, - manager: Union[str, None] = None, - entry: Union[str, None] = None): - self.name = name - self.entries = [] - if entry: - self.entries.append(entry) - self.exe = exe # interpreter (if required) - self.compiler = compiler # compiler bin (if required) - self.manager = manager # dependency manager (if required) - self.dependency_file = None - self.dependency_call = None - - def __repr__(self): - return (f'') - - def executable(self) -> bool: - '''Validate the necessary bins are available to execute the plugin.''' - if self.exe: - if shutil.which(self.exe): - # This should arguably not be checked here. - if self.manager: - if shutil.which(self.manager): - return True - return False - return True - return False - return True - - def installable(self) -> bool: - '''Validate the necessary compiler and package manager executables are - available to install. If these are defined, they are considered - mandatory even though the user may have the requisite packages already - installed.''' - if self.compiler and not shutil.which(self.compiler): - return False - if self.manager and not shutil.which(self.manager): - return False - return True - - def add_entrypoint(self, entry: str): - assert isinstance(entry, str) - self.entries.append(entry) - - def get_entrypoints(self, name: str): - guesses = [] - for entry in self.entries: - guesses.append(entry.format(name=name)) - return guesses - - def add_dependency_file(self, dep: str): - assert isinstance(dep, str) - self.dependency_file = dep - - def add_dependency_call(self, call: list): - if self.dependency_call is None: - self.dependency_call = [] - self.dependency_call.append(call) - - def copy(self): - return copy.deepcopy(self) - - -class InstInfo: - def __init__(self, name: str, location: str, git_url: str): - self.name = name - self.source_loc = str(location) # Used for 'git clone' - self.git_url: str = git_url # API access for github repos - self.srctype: Source = Source.get_type(location) - self.entry: SourceFile = None # relative to source_loc or subdir - self.deps: str = None - self.subdir: str = None - self.commit: str = None - - def __repr__(self): - return (f'InstInfo({self.name}, {self.source_loc}, {self.git_url}, ' - f'{self.entry}, {self.deps}, {self.subdir})') - - def get_repo_commit(self) -> Union[str, None]: - """The latest commit from a remote repo or the HEAD of a local repo.""" - if self.srctype in [Source.LOCAL_REPO, Source.GIT_LOCAL_CLONE]: - git = run(['git', 'rev-parse', 'HEAD'], cwd=str(self.source_loc), - stdout=PIPE, stderr=PIPE, text=True, check=False, timeout=10) - if git.returncode != 0: - return None - return git.stdout.splitlines()[0] - - if self.srctype == Source.GITHUB_REPO: - parsed_url = urlparse(self.source_loc) - if 'github.com' not in parsed_url.netloc: - return None - if len(parsed_url.path.split('/')) < 2: - return None - start = 1 - # Maybe we were passed an api.github.com/repo/ url - if 'api' in parsed_url.netloc: - start += 1 - repo_user = parsed_url.path.split('/')[start] - repo_name = parsed_url.path.split('/')[start + 1] - api_url = f'{API_GITHUB_COM}/repos/{repo_user}/{repo_name}/commits?ref=HEAD' - r = urlopen(api_url, timeout=5) - if r.status != 200: - return None - try: - return json.loads(r.read().decode())['0']['sha'] - except: - return None - - def get_inst_details(self) -> bool: - """Search the source_loc for plugin install details. - This may be necessary if a contents api is unavailable. - Extracts entrypoint and dependencies if searchable, otherwise - matches a directory to the plugin name and stops.""" - if self.srctype == Source.DIRECTORY: - assert Path(self.source_loc).exists() - assert os.path.isdir(self.source_loc) - target = SourceDir(self.source_loc, srctype=self.srctype) - # Set recursion for how many directories deep we should search - depth = 0 - if self.srctype in [Source.DIRECTORY, Source.LOCAL_REPO, - Source.GIT_LOCAL_CLONE]: - depth = 5 - elif self.srctype == Source.GITHUB_REPO: - depth = 1 - - def search_dir(self, sub: SourceDir, subdir: bool, - recursion: int) -> Union[SourceDir, None]: - assert isinstance(recursion, int) - # carveout for archived plugins in lightningd/plugins. Other repos - # are only searched by API at the top level. - if recursion == 0 and 'archive' in sub.name.lower(): - pass - # If unable to search deeper, resort to matching directory name - elif recursion < 1: - if sub.name.lower() == self.name.lower(): - # Partial success (can't check for entrypoint) - self.name = sub.name - return sub - return None - sub.populate() - - if sub.name.lower() == self.name.lower(): - # Directory matches the name we're trying to install, so check - # for entrypoint and dependencies. - for inst in INSTALLERS: - for g in inst.get_entrypoints(self.name): - found_entry = sub.find(g, ftype=SourceFile) - if found_entry: - break - # FIXME: handle a list of dependencies - found_dep = sub.find(inst.dependency_file, - ftype=SourceFile) - if found_entry: - # Success! - if found_dep: - self.name = sub.name - self.entry = found_entry.name - self.deps = found_dep.name - return sub - log.debug(f"missing dependency for {self}") - found_entry = None - for file in sub.contents: - if isinstance(file, SourceDir): - assert file.relative - success = search_dir(self, file, True, recursion - 1) - if success: - return success - return None - - try: - result = search_dir(self, target, False, depth) - # Using the rest API of github.com may result in a - # "Error 403: rate limit exceeded" or other access issues. - # Fall back to cloning and searching the local copy instead. - except HTTPError: - result = None - if self.srctype == Source.GITHUB_REPO: - # clone source to reckless dir - target = copy_remote_git_source(self) - if not target: - log.warning(f"could not clone github source {self}") - return False - log.debug(f"falling back to cloning remote repo {self}") - # Update to reflect use of a local clone - self.source_loc = str(target.location) - self.srctype = target.srctype - result = search_dir(self, target, False, 5) - - if not result: - return False - - if result: - if result != target: - if result.relative: - self.subdir = result.relative - else: - # populate() should always assign a relative path - # if not in the top-level source directory - assert self.subdir == result.name - return True - return False - - -def create_dir(directory: PosixPath) -> bool: - try: - Path(directory).mkdir(parents=False, exist_ok=True) - return True - # Okay if directory already exists - except FileExistsError: - return True - # Parent directory missing - except FileNotFoundError: - return False - - -def remove_dir(directory: str) -> bool: - try: - shutil.rmtree(directory) - return True - except NotADirectoryError: - log.warning(f"Tried to remove directory {directory} that " - "does not exist.") - except PermissionError: - log.warning(f"Permission denied removing dir: {directory}") - return False - - -class Source(Enum): - DIRECTORY = 1 - LOCAL_REPO = 2 - GITHUB_REPO = 3 - OTHER_URL = 4 - UNKNOWN = 5 - # Cloned from remote source before searching (rather than github API) - GIT_LOCAL_CLONE = 6 - - @classmethod - def get_type(cls, source: str): - if Path(os.path.realpath(source)).exists(): - if os.path.isdir(os.path.realpath(source)): - # returns 0 if git repository - proc = run(['git', '-C', source, 'rev-parse'], - cwd=os.path.realpath(source), stdout=PIPE, - stderr=PIPE, text=True, timeout=5) - if proc.returncode == 0: - return cls(2) - return cls(1) - if 'github.com' in source.lower(): - return cls(3) - if 'http://' in source.lower() or 'https://' in source.lower(): - return cls(4) - return cls(5) - - @classmethod - def get_github_user_repo(cls, source: str) -> (str, str): - 'extract a github username and repository name' - if 'github.com/' not in source.lower(): - return None, None - trailing = Path(source.lower().partition('github.com/')[2]).parts - if len(trailing) < 2: - return None, None - return trailing[0], trailing[1] - - -class SourceDir(): - """Structure to search source contents.""" - def __init__(self, location: str, srctype: Source = None, name: str = None, - relative: str = None): - self.location = str(location) - if name: - self.name = name - else: - self.name = Path(location).name - self.contents = [] - self.srctype = srctype - self.prepopulated = False - self.relative = relative # location relative to source - - def populate(self): - """populates contents of the directory at least one level""" - if self.prepopulated: - return - if not self.srctype: - self.srctype = Source.get_type(self.location) - if self.srctype == Source.DIRECTORY: - self.contents = populate_local_dir(self.location) - elif self.srctype in [Source.LOCAL_REPO, Source.GIT_LOCAL_CLONE]: - self.contents = populate_local_repo(self.location) - elif self.srctype == Source.GITHUB_REPO: - self.contents = populate_github_repo(self.location) - else: - raise Exception("populate method undefined for {self.srctype}") - # Ensure the relative path of the contents is inherited. - for c in self.contents: - if self.relative is None: - c.relative = c.name - else: - c.relative = str(Path(self.relative) / c.name) - - def find(self, name: str, ftype: type = None) -> str: - """Match a SourceFile or SourceDir to the provided name - (case insentive) and return its filename.""" - assert isinstance(name, str) - if len(self.contents) == 0: - return None - for c in self.contents: - if ftype and not isinstance(c, ftype): - continue - if c.name.lower() == name.lower(): - return c - return None - - def __repr__(self): - return f"" - - def __eq__(self, compared): - if isinstance(compared, str): - return self.name == compared - if isinstance(compared, SourceDir): - return (self.name == compared.name and - self.location == compared.location) - return False - - -class SourceFile(): - def __init__(self, location: str): - self.location = str(location) - self.name = Path(location).name - - def __repr__(self): - return f"" - - def __eq__(self, compared): - if isinstance(compared, str): - return self.name == compared - if isinstance(compared, SourceFile): - return (self.name == compared.name and - self.location == compared.location) - return False - - -def populate_local_dir(path: str) -> list: - assert Path(os.path.realpath(path)).exists() - contents = [] - for c in os.listdir(path): - fullpath = Path(path) / c - if os.path.isdir(fullpath): - # Inheriting type saves a call to test if it's a git repo - contents.append(SourceDir(fullpath, srctype=Source.DIRECTORY)) - else: - contents.append(SourceFile(fullpath)) - return contents - - -def populate_local_repo(path: str, parent=None) -> list: - assert Path(os.path.realpath(path)).exists() - if parent is None: - basedir = SourceDir('base') - else: - assert isinstance(parent, SourceDir) - basedir = parent - - def populate_source_path(parent: SourceDir, mypath: PosixPath, - relative: str = None): - """`git ls-tree` lists all files with their full path. - This populates all intermediate directories and the file.""" - parentdir = parent - if mypath == '.': - log.debug(' asked to populate root dir') - return - # reverse the parents - pdirs = mypath - revpath = [] - child = parentdir - while pdirs.parent.name != '': - revpath.append(pdirs.parent.name) - pdirs = pdirs.parent - for p in reversed(revpath): - child = parentdir.find(p) - if child: - parentdir = child - else: - if p == revpath[-1]: - relative_path = None - if parentdir.relative: - relative_path = parentdir.relative - elif parentdir.relative: - relative_path = str(Path(parentdir.relative) / - parentdir.name) - else: - relative_path = parentdir.name - child = SourceDir(p, srctype=Source.LOCAL_REPO, - relative=relative_path) - # ls-tree lists every file in the repo with full path. - # No need to populate each directory individually. - child.prepopulated = True - parentdir.contents.append(child) - parentdir = child - newfile = SourceFile(mypath.name) - child.contents.append(newfile) - - # Submodules contents are populated separately - proc = run(['git', '-C', path, 'submodule', 'status'], - stdout=PIPE, stderr=PIPE, text=True, timeout=5) - if proc.returncode != 0: - log.debug(f"'git submodule status' of repo {path} failed") - return None - submodules = [] - for sub in proc.stdout.splitlines(): - submodules.append(sub.split()[1]) - - # FIXME: Pass in tag or commit hash - ver = 'HEAD' - git_call = ['git', '-C', path, 'ls-tree', '--full-tree', '-r', - '--name-only', ver] - proc = run(git_call, stdout=PIPE, stderr=PIPE, text=True, timeout=5) - if proc.returncode != 0: - log.debug(f'ls-tree of repo {path} failed') - return None - - for filepath in proc.stdout.splitlines(): - if filepath in submodules: - if parent is None: - relative_path = filepath - elif basedir.relative: - relative_path = str(Path(basedir.relative) / filepath) - assert relative_path - submodule_dir = SourceDir(filepath, srctype=Source.LOCAL_REPO, - relative=relative_path) - populate_local_repo(Path(path) / filepath, parent=submodule_dir) - submodule_dir.prepopulated = True - basedir.contents.append(submodule_dir) - else: - populate_source_path(basedir, Path(filepath)) - return basedir.contents - - -def source_element_from_repo_api(member: dict): - # api accessed via /contents/ - if 'type' in member and 'name' in member and 'git_url' in member: - if member['type'] == 'dir': - return SourceDir(member['git_url'], srctype=Source.GITHUB_REPO, - name=member['name']) - elif member['type'] == 'file': - # Likely a submodule - if member['size'] == 0: - return SourceDir(None, srctype=Source.GITHUB_REPO, - name=member['name']) - return SourceFile(member['name']) - elif member['type'] == 'commit': - # No path is given by the api here - return SourceDir(None, srctype=Source.GITHUB_REPO, - name=member['name']) - # git_url with /tree/ presents results a little differently - elif 'type' in member and 'path' in member and 'url' in member: - if member['type'] not in ['tree', 'blob']: - log.debug(f' skipping {member["path"]} type={member["type"]}') - if member['type'] == 'tree': - return SourceDir(member['url'], srctype=Source.GITHUB_REPO, - name=member['path']) - elif member['type'] == 'blob': - # This can be a submodule - if member['size'] == 0: - return SourceDir(member['git_url'], srctype=Source.GITHUB_REPO, - name=member['name']) - return SourceFile(member['path']) - elif member['type'] == 'commit': - # No path is given by the api here - return SourceDir(None, srctype=Source.GITHUB_REPO, - name=member['name']) - return None - - -def populate_github_repo(url: str) -> list: - """populate one level of a github repository via REST API""" - # Forces search to clone remote repos (for blackbox testing) - if GITHUB_API_FALLBACK: - with tempfile.NamedTemporaryFile() as tmp: - raise HTTPError(url, 403, 'simulated ratelimit', {}, tmp) - # FIXME: This probably contains leftover cruft. - repo = url.split('/') - while '' in repo: - repo.remove('') - repo_name = None - parsed_url = urlparse(url) - if 'github.com' not in parsed_url.netloc: - return None - if len(parsed_url.path.split('/')) < 2: - return None - start = 1 - # Maybe we were passed an api.github.com/repo/ url - if 'api' in parsed_url.netloc: - start += 1 - repo_user = parsed_url.path.split('/')[start] - repo_name = parsed_url.path.split('/')[start + 1] - - # Get details from the github API. - if API_GITHUB_COM in url: - api_url = url - else: - api_url = f'{API_GITHUB_COM}/repos/{repo_user}/{repo_name}/contents/' - - git_url = api_url - if "api.github.com" in git_url: - # This lets us redirect to handle blackbox testing - log.debug(f'fetching from gh API: {git_url}') - git_url = (API_GITHUB_COM + git_url.split("api.github.com")[-1]) - # Ratelimiting occurs for non-authenticated GH API calls at 60 in 1 hour. - r = urlopen(git_url, timeout=5) - if r.status != 200: - return False - if 'git/tree' in git_url: - tree = json.loads(r.read().decode())['tree'] - else: - tree = json.loads(r.read().decode()) - contents = [] - for sub in tree: - if source_element_from_repo_api(sub): - contents.append(source_element_from_repo_api(sub)) - return contents - - -def copy_remote_git_source(github_source: InstInfo): - """clone or fetch & checkout a local copy of a remote git repo""" - user, repo = Source.get_github_user_repo(github_source.source_loc) - if not user or not repo: - log.warning('could not extract github user and repo ' - f'name for {github_source.source_loc}') - return None - local_path = RECKLESS_DIR / '.remote_sources' / user - create_dir(RECKLESS_DIR / '.remote_sources') - if not create_dir(local_path): - log.warning(f'could not provision dir {local_path} to ' - f'clone remote source {github_source.source_loc}') - return None - local_path = local_path / repo - if local_path.exists(): - # Fetch the latest - assert _git_update(github_source, local_path) - else: - _git_clone(github_source, local_path) - return SourceDir(local_path, srctype=Source.GIT_LOCAL_CLONE) - - -class Config(): - """A generic class for procuring, reading and editing config files""" - def obtain_config(self, - config_path: str, - default_text: str, - warn: bool = False) -> str: - """Return a config file from the desired location. Create one with - default_text if it cannot be found.""" - if isinstance(config_path, type(None)): - raise Exception("Generic config must be passed a config_path.") - assert isinstance(config_path, str) - # FIXME: warn if reckless dir exists, but conf not found - if Path(config_path).exists(): - with open(config_path, 'r+') as f: - config_content = f.readlines() - return config_content - # redirecting the prompts to stderr is kinder for json consumers - tmp = sys.stdout - sys.stdout = sys.stderr - print(f'config file not found: {config_path}') - if warn: - confirm = input('press [Y] to create one now.\n').upper() == 'Y' - else: - confirm = True - sys.stdout = tmp - if not confirm: - reckless_abort(f"config file required: {config_path}") - parent_path = Path(config_path).parent - # Create up to one parent in the directory tree. - if create_dir(parent_path): - with open(self.conf_fp, 'w') as f: - f.write(default_text) - # FIXME: Handle write failure - return default_text - else: - log.warning('could not create the parent directory ' + - parent_path) - raise FileNotFoundError('invalid parent directory') - - def editConfigFile(self, addline: Union[str, None], - removeline: Union[str, None]): - """Idempotent function to add and/or remove a single line each.""" - remove_these_lines = [] - with open(self.conf_fp, 'r') as reckless_conf: - original = reckless_conf.readlines() - empty_lines = [] - write_required = False - for n, l in enumerate(original): - if removeline and l.strip() == removeline.strip(): - write_required = True - remove_these_lines.append(n) - continue - if l.strip() == '': - empty_lines.append(n) - if n-1 in empty_lines: - # The white space is getting excessive. - remove_these_lines.append(n) - continue - if not addline and not write_required: - return - # No write necessary if addline is already in config. - if addline and not write_required: - for line in original: - if line.strip() == addline.strip(): - return - with open(self.conf_fp, 'w') as conf_write: - # no need to write if passed 'None' - line_exists = not bool(addline) - for n, l in enumerate(original): - if n not in remove_these_lines: - if n > 0: - conf_write.write(f'\n{l.strip()}') - else: - conf_write.write(l.strip()) - if addline and addline.strip() == l.strip(): - # addline is idempotent - line_exists = True - if not line_exists: - conf_write.write(f'\n{addline}') - - def __init__(self, path: Union[str, None] = None, - default_text: Union[str, None] = None, - warn: bool = False): - assert path is not None - assert default_text is not None - self.conf_fp = path - self.content = self.obtain_config(self.conf_fp, default_text, - warn=warn) - - -class RecklessConfig(Config): - """Reckless config (by default, specific to the bitcoin network only.) - This is inherited by the lightningd config and contains all reckless - maintained plugins.""" - - def enable_plugin(self, plugin_path: str): - """Handle persistent plugin loading via config""" - self.editConfigFile(f'plugin={plugin_path}', - f'disable-plugin={plugin_path}') - - def disable_plugin(self, plugin_path: str): - """Handle persistent plugin disabling via config""" - self.editConfigFile(f'disable-plugin={plugin_path}', - f'plugin={plugin_path}') - - def __init__(self, path: Union[str, None] = None, - default_text: Union[str, None] = None): - if path is None: - path = Path(LIGHTNING_DIR) / 'reckless' / 'bitcoin-reckless.conf' - if default_text is None: - default_text = ( - '# This configuration file is managed by reckless to activate ' - 'and disable\n# reckless-installed plugins\n\n' - ) - Config.__init__(self, path=str(path), default_text=default_text) - self.reckless_dir = Path(path).parent - - -class LightningBitcoinConfig(Config): - """lightningd config specific to the bitcoin network. This is inherited by - the main lightningd config and in turn, inherits bitcoin-reckless.conf.""" - - def __init__(self, path: Union[str, None] = None, - default_text: Union[str, None] = None, - warn: bool = True): - if path is None: - path = Path(LIGHTNING_DIR).joinpath('bitcoin', 'config') - if default_text is None: - default_text = "# This config was autopopulated by reckless\n\n" - Config.__init__(self, path=str(path), - default_text=default_text, warn=warn) - - -class NotFoundError(Exception): - """Raised by InferInstall when a source/entrypoint cannot be located.""" - - -class InferInstall(): - """Once a plugin is installed, we may need its directory and entrypoint""" - def __init__(self, name: str): - reck_contents = os.listdir(RECKLESS_CONFIG.reckless_dir) - reck_contents_lower = {} - for f in reck_contents: - reck_contents_lower.update({f.lower(): f}) - - def match_name(name) -> str: - for tier in range(0, 10): - # Look for each installers preferred entrypoint format first - for inst in INSTALLERS: - # All of this installer's entrypoint options exhausted. - if tier >= len(inst.entries): - continue - fmt = inst.entries[tier] - if '{name}' in fmt: - pre = fmt.split('{name}')[0] - post = fmt.split('{name}')[-1] - if name.startswith(pre) and name.endswith(post): - return name.lstrip(pre).rstrip(post) - else: - if fmt == name: - return name - return name - - name = match_name(name) - if name.lower() in reck_contents_lower.keys(): - actual_name = reck_contents_lower[name.lower()] - self.dir = Path(RECKLESS_CONFIG.reckless_dir).joinpath(actual_name) - else: - raise NotFoundError("Could not find a reckless directory " - f"for {name}") - plug_dir = Path(RECKLESS_CONFIG.reckless_dir).joinpath(actual_name) - for guess in entry_guesses(actual_name): - for content in plug_dir.iterdir(): - if content.name == guess: - self.entry = str(content) - self.name = actual_name - return - raise NotFoundError(f'plugin entrypoint not found in {self.dir}') - - -class InstallationFailure(Exception): - "raised when pip fails to complete dependency installation" - - -def create_python3_venv(staged_plugin: InstInfo) -> InstInfo: - "Create a virtual environment, install dependencies and test plugin." - env_path = Path('.venv') - env_path_full = Path(staged_plugin.source_loc) / env_path - assert staged_plugin.subdir # relative dir of original source - plugin_path = Path(staged_plugin.source_loc) / staged_plugin.subdir - - if shutil.which('poetry') and staged_plugin.deps == 'pyproject.toml': - log.debug('configuring a python virtual environment (poetry) in ' - f'{env_path_full}') - # The virtual environment should be located with the plugin. - # This installs it to .venv instead of in the global location. - mod_poetry_env = os.environ - mod_poetry_env['POETRY_VIRTUALENVS_IN_PROJECT'] = 'true' - # This ensures poetry installs to a new venv even though one may - # already be active (i.e., under CI) - if 'VIRTUAL_ENV' in mod_poetry_env: - del mod_poetry_env['VIRTUAL_ENV'] - # to avoid relocating and breaking the venv, symlink pyroject.toml - # to the location of poetry's .venv dir - (Path(staged_plugin.source_loc) / 'pyproject.toml') \ - .symlink_to(plugin_path / 'pyproject.toml') - (Path(staged_plugin.source_loc) / 'poetry.lock') \ - .symlink_to(plugin_path / 'poetry.lock') - - # Avoid redirecting stdout in order to stream progress. - # Timeout excluded as armv7 grpcio build/install can take 1hr. - pip = run(['poetry', 'install', '--no-root'], check=False, - cwd=staged_plugin.source_loc, env=mod_poetry_env, - stdout=stdout_redirect, stderr=stderr_redirect) - - (Path(staged_plugin.source_loc) / 'pyproject.toml').unlink() - (Path(staged_plugin.source_loc) / 'poetry.lock').unlink() - - else: - builder = venv.EnvBuilder(with_pip=True) - builder.create(env_path_full) - log.debug('configuring a python virtual environment (pip) in ' - f'{env_path_full}') - log.debug(f'virtual environment created in {env_path_full}.') - if staged_plugin.deps == 'pyproject.toml': - pip = run(['bin/pip', 'install', str(plugin_path)], - check=False, cwd=plugin_path) - elif staged_plugin.deps == 'requirements.txt': - pip = run([str(env_path_full / 'bin/pip'), 'install', '-r', - str(plugin_path / 'requirements.txt')], - check=False, cwd=plugin_path, - stdout=stdout_redirect, stderr=stderr_redirect) - else: - log.debug("no python dependency file") - if pip and pip.returncode != 0: - log.error('error encountered installing dependencies') - raise InstallationFailure - - staged_plugin.venv = env_path - log.info('dependencies installed successfully') - return staged_plugin - - -def create_wrapper(plugin: InstInfo): - '''The wrapper will activate the virtual environment for this plugin and - then run the plugin from within the same process.''' - assert hasattr(plugin, 'venv') - venv_full_path = Path(plugin.source_loc) / plugin.venv - with open(Path(plugin.source_loc) / plugin.entry, 'w') as wrapper: - wrapper.write((f"#!{venv_full_path}/bin/python\n" - "import sys\n" - "import runpy\n\n" - f"if '{plugin.source_loc}/{plugin.subdir}' not in " - "sys.path:\n" - f" sys.path.append('{plugin.source_loc}/" - f"{plugin.subdir}')\n" - f"if '{plugin.source_loc}' in sys.path:\n" - f" sys.path.remove('{plugin.source_loc}')\n" - f"runpy.run_module(\"{plugin.name}\", " - "{}, \"__main__\")")) - wrapper_file = Path(plugin.source_loc) / plugin.entry - wrapper_file.chmod(0o755) - - -def install_to_python_virtual_environment(cloned_plugin: InstInfo): - '''Called during install in place of a subprocess.run list''' - # Delete symlink so that a venv wrapper can take it's place - (Path(cloned_plugin.source_loc) / cloned_plugin.entry).unlink() - create_python3_venv(cloned_plugin) - if not hasattr(cloned_plugin, 'venv'): - raise InstallationFailure - log.debug('virtual environment for cloned plugin: ' - f'{cloned_plugin.venv}') - create_wrapper(cloned_plugin) - return cloned_plugin - - -def cargo_installation(cloned_plugin: InstInfo): - call = ['cargo', 'build', '--release', '-vv'] - # FIXME: the symlinked Cargo.toml allows the installer to identify a valid - # plugin directory, but is unneeded, and actually confuses cargo if not - # removed prior to installing. - cargo_toml_path = Path(cloned_plugin.source_loc) / 'Cargo.toml' - if cargo_toml_path.exists(): - cargo_toml_path.unlink() - - # source_loc now contains a symlink to the entrypoint and 'source/plugin/' - source = Path(cloned_plugin.source_loc) / 'source' / cloned_plugin.name - log.debug(f'cargo installing from {source}') - if logging.root.level < logging.INFO and not log.capture: - cargo = run(call, cwd=str(source), text=True) - else: - cargo = run(call, cwd=str(source), stdout=PIPE, - stderr=PIPE, text=True) - - if cargo.returncode == 0: - log.debug('rust project compiled successfully') - else: - log.error(cargo.stderr if cargo.stderr else - 'error encountered during build, cargo exited with return ' - f'code {cargo.returncode}') - - log.debug(f'removing {cloned_plugin.source_loc}') - remove_dir(cloned_plugin.source_loc) - raise InstallationFailure - - # We do need to symlink to the executable binary though. - (Path(cloned_plugin.source_loc) / cloned_plugin.name).\ - symlink_to(source / f'target/release/{cloned_plugin.name}') - cloned_plugin.entry = cloned_plugin.name - - return cloned_plugin - - -def install_python_uv(cloned_plugin: InstInfo): - """This uses the rust-based python plugin manager uv to manage the python - installation and create a virtual environment.""" - - source = Path(cloned_plugin.source_loc) / 'source' / cloned_plugin.name - # This virtual env path matches the other python installations and allows - # creating the wrapper in the same manner. Otherwise uv would build it in - # the source/{name} subdirectory. - cloned_plugin.venv = Path('.venv') - - # We want the virtual env at the head of our directory structure and uv - # will need a pyproject.toml there in order to get started. - (Path(cloned_plugin.source_loc) / 'pyproject.toml').\ - symlink_to(source / 'pyproject.toml') - - call = ['uv', '-v', 'sync'] - uv = run(call, cwd=str(cloned_plugin.source_loc), stdout=PIPE, stderr=PIPE, - text=True, check=False) - if uv.returncode != 0: - for line in uv.stderr.splitlines(): - log.debug(line) - log.error('Failed to install virtual environment') - raise InstallationFailure('Failed to create virtual environment!') - - # Delete entrypoint symlink so that a venv wrapper can take it's place - (Path(cloned_plugin.source_loc) / cloned_plugin.entry).unlink() - - create_wrapper(cloned_plugin) - return cloned_plugin - - -def install_python_uv_legacy(cloned_plugin: InstInfo): - """Install a python plugin with uv that was created with a requirements.txt. - This requires creating a bare virtual environment with uv first.""" - source = Path(cloned_plugin.source_loc) / 'source' / cloned_plugin.name - cloned_plugin.venv = Path('.venv') - (Path(cloned_plugin.source_loc) / 'pyproject.toml').\ - symlink_to(source / 'pyproject.toml') - (Path(cloned_plugin.source_loc) / 'requirements.txt').\ - symlink_to(source / 'requirements.txt') - - venv = run(['uv', 'venv'], cwd=str(cloned_plugin.source_loc), - stdout=PIPE, stderr=PIPE, text=True, check=False) - if venv.returncode != 0: - for line in venv.stderr.splitlines(): - log.debug(line) - log.error('Failed to create virtual environment') - raise InstallationFailure('Failed to create virtual environment!') - for line in venv.stdout.splitlines(): - log.debug(line) - for line in venv.stderr.splitlines(): - log.debug(line) - # Running this as a shell allows overriding any active virtual environment - # which would make uv skip installing packages already present in the - # current env. - call = ['. .venv/bin/activate; uv pip install -r requirements.txt'] - uv = run(call, shell=True, cwd=str(cloned_plugin.source_loc), - stdout=PIPE, stderr=PIPE, text=True, check=False) - if uv.returncode != 0: - for line in uv.stderr.splitlines(): - log.debug(line) - log.error('Failed to install virtual environment') - raise InstallationFailure('Failed to create virtual environment!') - for line in uv.stdout.splitlines(): - log.debug(line) - for line in uv.stderr.splitlines(): - log.debug(line) - - # Delete entrypoint symlink so that a venv wrapper can take it's place - (Path(cloned_plugin.source_loc) / cloned_plugin.entry).unlink() - - create_wrapper(cloned_plugin) - log.info('dependencies installed successfully') - return cloned_plugin - - -python3venv = Installer('python3venv', exe='python3', - manager='pip', entry='{name}.py') -python3venv.add_entrypoint('{name}') -python3venv.add_entrypoint('__init__.py') -python3venv.add_dependency_file('requirements.txt') -python3venv.dependency_call = install_to_python_virtual_environment - -poetryvenv = Installer('poetryvenv', exe='python3', - manager='poetry', entry='{name}.py') -poetryvenv.add_entrypoint('{name}') -poetryvenv.add_entrypoint('__init__.py') -poetryvenv.add_dependency_file('poetry.lock') -poetryvenv.add_dependency_file('pyproject.toml') -poetryvenv.dependency_call = install_to_python_virtual_environment - -pyprojectViaPip = Installer('pyprojectViaPip', exe='python3', - manager='pip', entry='{name}.py') -pyprojectViaPip.add_entrypoint('{name}') -pyprojectViaPip.add_entrypoint('__init__.py') -pyprojectViaPip.add_dependency_file('pyproject.toml') -pyprojectViaPip.dependency_call = install_to_python_virtual_environment - -pythonuv = Installer('pythonuv', exe='python3', manager='uv', entry="{name}.py") -pythonuv.add_dependency_file('uv.lock') -pythonuv.dependency_call = install_python_uv - -pythonuvlegacy = Installer('pythonuvlegacy', exe='python3', manager='uv', entry='{name}.py') -pythonuvlegacy.add_dependency_file('requirements.txt') -pythonuvlegacy.dependency_call = install_python_uv_legacy - -# Nodejs plugin installer -nodejs = Installer('nodejs', exe='node', - manager='npm', entry='{name}.js') -nodejs.add_entrypoint('{name}') -nodejs.add_dependency_call(['npm', 'install', '--omit=dev']) -nodejs.add_dependency_file('package.json') - -# This entrypoint is used to identify a candidate directory, don't call it. -rust_cargo = Installer('rust', manager='cargo', entry='Cargo.toml') -rust_cargo.add_dependency_file('Cargo.toml') -rust_cargo.dependency_call = cargo_installation - -INSTALLERS = [pythonuv, pythonuvlegacy, python3venv, poetryvenv, - pyprojectViaPip, nodejs, rust_cargo] - - -def help_alias(targets: list): - if len(targets) == 0: - parser.print_help(sys.stdout) - else: - log.info('try "reckless {} -h"'.format(' '.join(targets))) - sys.exit(1) - - -def _source_search(name: str, src: str) -> Union[InstInfo, None]: - """Identify source type, retrieve contents, and populate InstInfo - if the relevant contents are found.""" - root_dir = SourceDir(src) - source = InstInfo(name, root_dir.location, None) - - # If a local clone of a github source already exists, prefer searching - # that instead of accessing the github API. - if source.srctype == Source.GITHUB_REPO: - # Do we have a local copy already? Use that. - user, repo = Source.get_github_user_repo(src) - assert user - assert repo - local_clone_location = RECKLESS_DIR / '.remote_sources' / user / repo - if local_clone_location.exists(): - # Make sure it's the correct remote source and fetch any updates. - if _git_update(source, local_clone_location): - log.debug(f"Using local clone of {src}: " - f"{local_clone_location}") - source.source_loc = str(local_clone_location) - source.srctype = Source.GIT_LOCAL_CLONE - - if source.get_inst_details(): - return source - return None - - -def _git_clone(src: InstInfo, dest: Union[PosixPath, str]) -> bool: - log.info(f'cloning {src.srctype} {src}') - if src.srctype == Source.GITHUB_REPO: - assert 'github.com' in src.source_loc - source = f"{GITHUB_COM}" + src.source_loc.split("github.com")[-1] - elif src.srctype in [Source.LOCAL_REPO, Source.OTHER_URL, - Source.GIT_LOCAL_CLONE]: - source = src.source_loc - else: - return False - git = run(['git', 'clone', '--recurse-submodules', source, str(dest)], - stdout=PIPE, stderr=PIPE, text=True, check=False, timeout=180) - if git.returncode != 0: - for line in git.stderr.splitlines(): - log.debug(line) - if Path(dest).exists(): - remove_dir(str(dest)) - log.error('Failed to clone repo') - return False - return True - - -def _git_update(github_source: InstInfo, local_copy: PosixPath): - # Ensure this is the correct source - git = run(['git', 'remote', 'set-url', 'origin', github_source.source_loc], - cwd=str(local_copy), stdout=PIPE, stderr=PIPE, text=True, - check=False, timeout=60) - assert git.returncode == 0 - if git.returncode != 0: - return False - - # Fetch the latest from the remote - git = run(['git', 'fetch', 'origin', '--recurse-submodules=on-demand'], - cwd=str(local_copy), stdout=PIPE, stderr=PIPE, text=True, - check=False, timeout=60) - assert git.returncode == 0 - if git.returncode != 0: - return False - - # Find default branch - git = run(['git', 'symbolic-ref', 'refs/remotes/origin/HEAD', '--short'], - cwd=str(local_copy), stdout=PIPE, stderr=PIPE, text=True, - check=False, timeout=60) - assert git.returncode == 0 - if git.returncode != 0: - return False - default_branch = git.stdout.splitlines()[0] - if default_branch != 'origin/master': - log.debug(f'UNUSUAL: fetched default branch {default_branch} for ' - f'{github_source.source_loc}') - - # Checkout default branch - git = run(['git', 'checkout', default_branch], - cwd=str(local_copy), stdout=PIPE, stderr=PIPE, text=True, - check=False, timeout=60) - assert git.returncode == 0 - if git.returncode != 0: - return False - - return True - - -def get_temp_reckless_dir() -> PosixPath: - random_dir = 'reckless-{}'.format(str(hash(os.times()))[-9:]) - new_path = Path(tempfile.mkdtemp(prefix=random_dir)) - return new_path - - -def add_installation_metadata(installed: InstInfo, - original_request: InstInfo): - """Document the install request and installation details for use when - updating the plugin.""" - install_dir = Path(installed.source_loc) - assert install_dir.is_dir() - if urlparse(original_request.source_loc).scheme in ['http', 'https']: - abs_source_path = original_request.source_loc - else: - abs_source_path = Path(original_request.source_loc).resolve() - data = ('installation date\n' - f'{datetime.date.today().isoformat()}\n' - 'installation time\n' - f'{int(time.time())}\n' - 'original source\n' - f'{abs_source_path}\n' - 'requested commit\n' - f'{original_request.commit}\n' - 'installed commit\n' - f'{installed.commit}\n') - with open(install_dir / '.metadata', 'w') as metadata: - metadata.write(data) - - -def _checkout_commit(orig_src: InstInfo, - cloned_src: InstInfo, - cloned_path: PosixPath): - # Check out and verify commit/tag if source was a repository - if orig_src.srctype in [Source.LOCAL_REPO, Source.GITHUB_REPO, - Source.OTHER_URL, Source.GIT_LOCAL_CLONE]: - if orig_src.commit: - log.debug(f"Checking out {orig_src.commit}") - checkout = Popen(['git', 'checkout', orig_src.commit], - cwd=str(cloned_path), - stdout=PIPE, stderr=PIPE) - checkout.wait() - if checkout.returncode != 0: - log.warning('failed to checkout referenced ' - f'commit {orig_src.commit}') - return None - else: - log.debug("using latest commit of default branch") - - # Log the commit we actually used (for installation metadata) - git = run(['git', 'rev-parse', 'HEAD'], cwd=str(cloned_path), - stdout=PIPE, stderr=PIPE, text=True, check=False, timeout=60) - if git.returncode == 0: - head_commit = git.stdout.splitlines()[0] - log.debug(f'checked out HEAD: {head_commit}') - cloned_src.commit = head_commit - else: - log.debug(f'unable to collect commit: {git.stderr}') - else: - if orig_src.commit: - log.warning("unable to checkout commit/tag on non-repository " - "source") - return cloned_path - - if cloned_src.subdir is not None: - return Path(cloned_src.source_loc) / cloned_src.subdir - return cloned_path - - -def _install_plugin(src: InstInfo) -> Union[InstInfo, None]: - """make sure the repo exists and clone it.""" - log.debug(f'Install requested from {src}.') - if RECKLESS_CONFIG is None: - log.error('reckless install directory unavailable') - return None - - # Use a unique directory for each cloned repo. - tmp_path = get_temp_reckless_dir() - if not create_dir(tmp_path): - log.debug(f'failed to create {tmp_path}') - return None - clone_path = tmp_path / 'clone' - if not create_dir(tmp_path): - log.debug(f'failed to create {clone_path}') - return None - # we rename the original repo here. - plugin_path = clone_path / src.name - inst_path = Path(RECKLESS_CONFIG.reckless_dir) / src.name - if Path(clone_path).exists(): - log.debug(f'{clone_path} already exists - deleting') - shutil.rmtree(clone_path) - if src.srctype == Source.DIRECTORY: - full_source_path = Path(src.source_loc) - if src.subdir: - full_source_path /= src.subdir - log.debug(("copying local directory contents from" - f" {full_source_path}")) - create_dir(clone_path) - shutil.copytree(full_source_path, plugin_path) - elif src.srctype in [Source.LOCAL_REPO, Source.GITHUB_REPO, - Source.OTHER_URL, Source.GIT_LOCAL_CLONE]: - # clone git repository to /tmp/reckless-... - if not _git_clone(src, plugin_path): - return None - # FIXME: Validate path was cloned successfully. - # Depending on how we accessed the original source, there may be install - # details missing. Searching the cloned repo makes sure we have it. - cloned_src = _source_search(src.name, str(clone_path)) - log.debug(f'cloned_src: {cloned_src}') - if not cloned_src: - log.warning('failed to find plugin after cloning repo.') - return None - - # If a specific commit or tag was requested, check it out now. - plugin_path = _checkout_commit(src, cloned_src, plugin_path) - if not plugin_path: - return None - - # Find a suitable installer - INSTALLER = None - for inst_method in INSTALLERS: - if not (inst_method.installable() and inst_method.executable()): - continue - if inst_method.dependency_file is not None: - if inst_method.dependency_file not in os.listdir(plugin_path): - continue - log.debug(f"using installer {inst_method.name}") - INSTALLER = inst_method - break - if not INSTALLER: - log.warning('Could not find a suitable installer method for ' - f'{src.name}') - return None - if not cloned_src.entry: - # The plugin entrypoint may not be discernable prior to cloning. - # Need to search the newly cloned directory, not the original - cloned_src.source_loc = str(plugin_path) - - # Relocate plugin to a staging directory prior to testing - if not Path(inst_path).exists(): - log.debug(f'creating {inst_path}') - create_dir(inst_path) - if not Path(inst_path / 'source').exists(): - log.debug(f'creating {inst_path / "source"}') - create_dir(inst_path / 'source') - staging_path = inst_path / 'source' / src.name - log.debug(f'copying {plugin_path} tree to {staging_path}') - shutil.copytree(str(plugin_path), staging_path) - staged_src = cloned_src - # Because the source files are copied to a 'source' directory, the - # get_inst_details function no longer works. (dir must match plugin name) - # Set these manually instead. - staged_src.source_loc = str(inst_path) - staged_src.srctype = Source.DIRECTORY - # Use subdir to redirect the symlink to the actual executable location - staged_src.subdir = f'source/{src.name}' - # Create symlink in staging tree to redirect to the plugins entrypoint - log.debug(f"linking source {staging_path / cloned_src.entry} to " - f"{Path(staged_src.source_loc) / cloned_src.entry}") - log.debug(staged_src) - (Path(staged_src.source_loc) / cloned_src.entry).\ - symlink_to(staging_path / cloned_src.entry) - - # try it out - if INSTALLER.dependency_call: - if isinstance(INSTALLER.dependency_call, types.FunctionType): - try: - staged_src = INSTALLER.dependency_call(staged_src) - except InstallationFailure: - return None - else: - for call in INSTALLER.dependency_call: - log.debug(f"Install: invoking '{' '.join(call)}'") - if logging.root.level < logging.INFO: - pip = Popen(call, cwd=staging_path, text=True) - else: - pip = Popen(call, cwd=staging_path, stdout=PIPE, - stderr=PIPE, text=True) - pip.wait() - # FIXME: handle output of multiple calls - - if pip.returncode == 0: - log.info('dependencies installed successfully') - else: - log.error('error encountered installing dependencies') - if pip.stdout: - log.debug(pip.stdout.read()) - remove_dir(clone_path) - remove_dir(inst_path) - return None - staged_src.subdir = None - test_log = [] - try: - test = run([Path(staged_src.source_loc).joinpath(staged_src.entry)], - cwd=str(staging_path), stdout=PIPE, stderr=PIPE, - text=True, timeout=10) - for line in test.stderr.splitlines(): - test_log.append(line) - returncode = test.returncode - except TimeoutExpired: - # If the plugin is still running, it's assumed to be okay. - returncode = 0 - if returncode != 0: - log.debug("plugin testing error:") - for line in test_log: - log.debug(f' {line}') - log.error('plugin testing failed') - remove_dir(clone_path) - remove_dir(inst_path) - return None - - add_installation_metadata(staged_src, src) - log.info(f'plugin installed: {inst_path}') - remove_dir(clone_path) - return staged_src - - -def location_from_name(plugin_name: str) -> (str, str): - """Maybe the location was passed in place of the plugin name. Check - if this looks like a filepath or URL and return that as well as the - plugin name.""" - if not Path(plugin_name).exists(): - try: - parsed = urlparse(plugin_name) - if parsed.scheme in ['http', 'https']: - return (plugin_name, Path(plugin_name).with_suffix('').name) - except ValueError: - pass - # No path included, return the name only. - return (None, plugin_name) - - # Directory containing the plugin? The plugin name should match the dir. - if os.path.isdir(plugin_name): - return (Path(plugin_name).parent, Path(plugin_name).name) - - # Possibly the entrypoint itself was passed? - elif os.path.isfile(plugin_name): - if Path(plugin_name).with_suffix('').name != Path(plugin_name).parent.name or \ - not Path(plugin_name).parent.parent.exists(): - # If the directory is not named for the plugin, we can't infer what - # should be done. - # FIXME: return InstInfo with entrypoint rather than source str. - return (None, plugin_name) - # We have to make inferences as to the naming here. - return (Path(plugin_name).parent.parent, Path(plugin_name).with_suffix('').name) - - -def _enable_installed(installed: InstInfo, plugin_name: str) -> Union[str, None]: - """Enable the plugin in the active config file and dynamically activate - if a lightningd rpc is available.""" - if not installed: - log.warning(f'{plugin_name}: installation aborted') - return None - - if enable(installed.name): - return f"{installed.source_loc}" - - log.error(('dynamic activation failed: ' - f'{installed.name} not found in reckless directory')) - return None - -def install(plugin_name: str) -> Union[str, None]: - """Downloads plugin from source repos, installs and activates plugin. - Returns the location of the installed plugin or "None" in the case of - failure.""" - assert isinstance(plugin_name, str) - # Specify a tag or commit to checkout by adding @ to plugin name - if '@' in plugin_name: - log.debug("testing for a commit/tag in plugin name") - name, commit = plugin_name.split('@', 1) - else: - name = plugin_name - commit = None - # Is the install request specifying a path to the plugin? - direct_location, name = location_from_name(name) - src = None - if direct_location: - logging.debug(f"install of {name} requested from {direct_location}") - src = InstInfo(name, direct_location, name) - # Treating a local git repo as a directory allows testing - # uncommitted changes. - if src and src.srctype == Source.LOCAL_REPO: - src.srctype = Source.DIRECTORY - if not src.get_inst_details(): - src = None - if not direct_location or not src: - log.debug(f"Searching for {name}") - if search(name): - global LAST_FOUND - src = LAST_FOUND - LAST_FOUND = None - src.commit = commit - log.debug(f'Retrieving {src.name} from {src.source_loc}') - else: - LAST_FOUND = None - return None - - try: - installed = _install_plugin(src) - except FileExistsError as err: - log.error(f'File exists: {err.filename}') - return None - return _enable_installed(installed, plugin_name) - - - -def uninstall(plugin_name: str) -> str: - """dDisables plugin and deletes the plugin's reckless dir. Returns the - status of the uninstall attempt.""" - assert isinstance(plugin_name, str) - log.debug(f'Uninstalling plugin {plugin_name}') - disable(plugin_name) - try: - inst = InferInstall(plugin_name) - except NotFoundError as err: - log.error(err) - return "uninstall failed" - if not Path(inst.entry).exists(): - log.error("cannot find installed plugin at expected path" - f"{inst.entry}") - return "uninstall failed" - log.debug(f'looking for {str(Path(inst.entry).parent)}') - if remove_dir(str(Path(inst.entry).parent)): - log.info(f"{inst.name} uninstalled successfully.") - else: - return "uninstall failed" - return "uninstalled" - - -def _get_all_plugins_from_source(src: str) -> list: - """Get all plugin directories from a source repository. - Returns a list of (plugin_name, source_url) tuples.""" - plugins = [] - srctype = Source.get_type(src) - if srctype == Source.UNKNOWN: - return plugins - - try: - root = SourceDir(src, srctype=srctype) - root.populate() - except Exception as e: - log.debug(f"Failed to populate source {src}: {e}") - return plugins - - plugins.append((root.name, src)) - - for item in root.contents: - if isinstance(item, SourceDir): - # Skip archive directories - if 'archive' in item.name.lower(): - continue - plugins.append((item.name, src)) - return plugins - - -def search(plugin_name: str) -> Union[InstInfo, None]: - """searches plugin index for plugin""" - ordered_sources = RECKLESS_SOURCES.copy() - - for src in RECKLESS_SOURCES: - # Search repos named after the plugin before collections - if Source.get_type(src) == Source.GITHUB_REPO: - if src.split('/')[-1].lower() == plugin_name.lower(): - ordered_sources.remove(src) - ordered_sources.insert(0, src) - # Check locally before reaching out to remote repositories - for src in RECKLESS_SOURCES: - if Source.get_type(src) in [Source.DIRECTORY, Source.LOCAL_REPO]: - ordered_sources.remove(src) - ordered_sources.insert(0, src) - - # First, collect all partial matches to display to user - partial_matches = [] - for source in ordered_sources: - for plugin_name_found, src_url in _get_all_plugins_from_source(source): - if plugin_name.lower() in plugin_name_found.lower(): - partial_matches.append((plugin_name_found, src_url)) - - # Display all partial matches - if partial_matches: - log.info(f"Plugins matching '{plugin_name}':") - for name, src_url in partial_matches: - log.info(f" {name} ({src_url})") - - # Now try exact match for installation purposes - exact_match = None - for source in ordered_sources: - srctype = Source.get_type(source) - if srctype == Source.UNKNOWN: - log.debug(f'cannot search {srctype} {source}') - continue - if srctype in [Source.DIRECTORY, Source.LOCAL_REPO, - Source.GITHUB_REPO, Source.OTHER_URL]: - found = _source_search(plugin_name, source) - if found: - log.debug(f"{found}, {found.srctype}") - exact_match = found - break - - if exact_match: - log.info(f"found {exact_match.name} in source: {exact_match.source_loc}") - log.debug(f"entry: {exact_match.entry}") - if exact_match.subdir: - log.debug(f'sub-directory: {exact_match.subdir}') - global LAST_FOUND - # Stashing the search result saves install() a call to _source_search. - LAST_FOUND = exact_match - return str(exact_match.source_loc) - - if not partial_matches: - log.info("Search exhausted all sources") - return None - - -class RPCError(Exception): - """lightning-cli fails to connect to lightningd RPC""" - def __init__(self, err): - self.err = err - - def __str__(self): - return 'RPCError({self.err})' - - -class CLIError(Exception): - """lightningd error response""" - def __init__(self, code, message): - self.code = code - self.message = message - - def __str__(self): - return f'CLIError({self.code} {self.message})' - - -def lightning_cli(*cli_args, timeout: int = 15) -> dict: - """Interfaces with Core-Lightning via CLI using any configured options.""" - cmd = LIGHTNING_CLI_CALL.copy() - cmd.extend(cli_args) - clncli = run(cmd, stdout=PIPE, stderr=PIPE, check=False, timeout=timeout) - out = clncli.stdout.decode() - if len(out) > 0 and out[0] == '{': - # If all goes well, a json object is typically returned - out = json.loads(out.replace('\n', '')) - else: - # help, -V, etc. may not return json, so stash it here. - out = {'content': out} - if clncli.returncode == 0: - return out - if clncli.returncode == 1: - # RPC doesn't like our input - # output contains 'code' and 'message' - raise CLIError(out['code'], out['message']) - # RPC may not be available - i.e., lightningd not running, using - # alternate config. - err = clncli.stderr.decode() - raise RPCError(err) - - -def enable(plugin_name: str): - """dynamically activates plugin and adds to config (persistent)""" - assert isinstance(plugin_name, str) - try: - inst = InferInstall(plugin_name) - except NotFoundError as err: - log.error(err) - return None - path = inst.entry - if not Path(path).exists(): - log.error(f'cannot find installed plugin at expected path {path}') - return None - log.debug(f'activating {plugin_name}') - try: - lightning_cli('plugin', 'start', path) - except CLIError as err: - if 'already registered' in err.message: - log.debug(f'{inst.name} is already running') - return None - else: - log.error(f'reckless: {inst.name} failed to start!') - log.error(err) - return None - except RPCError: - log.info(('lightningd rpc unavailable. ' - 'Skipping dynamic activation.')) - RECKLESS_CONFIG.enable_plugin(path) - log.info(f'{inst.name} enabled') - return 'enabled' - - -def disable(plugin_name: str): - """reckless disable - deactivates an installed plugin""" - assert isinstance(plugin_name, str) - try: - inst = InferInstall(plugin_name) - except NotFoundError as err: - log.warning(f'failed to disable: {err}') - return None - path = inst.entry - if not Path(path).exists(): - sys.stderr.write(f'Could not find plugin at {path}\n') - return None - log.debug(f'deactivating {plugin_name}') - try: - lightning_cli('plugin', 'stop', path) - except CLIError as err: - if err.code == -32602: - log.debug('plugin not currently running') - else: - log.error('lightning-cli plugin stop failed') - logging.error(err) - return None - except RPCError: - log.debug(('lightningd rpc unavailable. ' - 'Skipping dynamic deactivation.')) - RECKLESS_CONFIG.disable_plugin(path) - log.info(f'{inst.name} disabled') - return 'disabled' - - -def load_config(reckless_dir: Union[str, None] = None, - network: str = 'bitcoin') -> Config: - """Initial directory discovery and config file creation.""" - net_conf = None - # Does the lightning-cli already reference an explicit config? - try: - active_config = lightning_cli('listconfigs', timeout=10)['configs'] - if 'conf' in active_config: - net_conf = LightningBitcoinConfig(path=active_config['conf'] - ['value_str']) - except RPCError: - pass - if reckless_dir is None: - reckless_dir = Path(LIGHTNING_DIR) / 'reckless' - else: - if not os.path.isabs(reckless_dir): - reckless_dir = Path.cwd() / reckless_dir - if LIGHTNING_CONFIG: - network_path = LIGHTNING_CONFIG - else: - network_path = Path(LIGHTNING_DIR) / network / 'config' - reck_conf_path = Path(reckless_dir) / f'{network}-reckless.conf' - if net_conf: - if str(network_path) != net_conf.conf_fp: - reckless_abort('reckless configuration does not match lightningd:\n' - f'reckless network config path: {network_path}\n' - f'lightningd active config: {net_conf.conf_fp}') - else: - # The network-specific config file (bitcoin by default) - net_conf = LightningBitcoinConfig(path=network_path) - # Reckless manages plugins here. - try: - reckless_conf = RecklessConfig(path=reck_conf_path) - except FileNotFoundError: - reckless_abort('reckless config file could not be written: ' - + str(reck_conf_path)) - if not net_conf: - reckless_abort('Error: could not load or create the network specific lightningd' - ' config (default .lightning/bitcoin)') - net_conf.editConfigFile(f'include {reckless_conf.conf_fp}', None) - return reckless_conf - - -def get_sources_file() -> str: - return str(RECKLESS_DIR / '.sources') - - -def sources_from_file() -> list: - sources_file = get_sources_file() - read_sources = [] - with open(sources_file, 'r') as f: - for src in f.readlines(): - if len(src.strip()) > 0: - read_sources.append(src.strip()) - return read_sources - - -def load_sources() -> list: - """Look for the repo sources file.""" - sources_file = get_sources_file() - # This would have been created if possible - if not Path(sources_file).exists(): - log.debug('Warning: Reckless requires write access') - Config(path=str(sources_file), - default_text='https://github.com/lightningd/plugins') - return ['https://github.com/lightningd/plugins'] - return sources_from_file() - - -def add_source(src: str): - """Additional git repositories, directories, etc. are passed here.""" - assert isinstance(src, str) - # Is it a file? - maybe_path = os.path.realpath(src) - sources = Config(path=str(get_sources_file()), - default_text='https://github.com/lightningd/plugins') - if Path(maybe_path).exists(): - if os.path.isdir(maybe_path): - sources.editConfigFile(src, None) - elif 'github.com' in src or 'http://' in src or 'https://' in src: - sources.editConfigFile(src, None) - else: - log.warning(f'failed to add source {src}') - return None - return sources_from_file() - - -def remove_source(src: str): - """Remove a source from the sources file.""" - assert isinstance(src, str) - if src in sources_from_file(): - my_file = Config(path=get_sources_file(), - default_text='https://github.com/lightningd/plugins') - my_file.editConfigFile(None, src) - log.info('plugin source removed') - else: - log.warning(f'source not found: {src}') - return sources_from_file() - - -def list_source(): - """Provide the user with all stored source repositories.""" - for src in sources_from_file(): - log.info(src) - return sources_from_file() - - -class UpdateStatus(Enum): - SUCCESS = 0 - LATEST = 1 - UNINSTALLED = 2 - ERROR = 3 - METADATA_MISSING = 4 - REFUSING_UPDATE = 5 - - -def update_plugin(plugin_name: str) -> tuple: - """Check for an installed plugin, if metadata for it exists, update - to the latest available while using the same source.""" - log.info(f"updating {plugin_name}") - if not (Path(RECKLESS_CONFIG.reckless_dir) / plugin_name).exists(): - log.error(f'{plugin_name} is not installed') - return (None, UpdateStatus.UNINSTALLED) - metadata_file = Path(RECKLESS_CONFIG.reckless_dir) / plugin_name / '.metadata' - if not metadata_file.exists(): - log.warning(f"no metadata file for {plugin_name}") - return (None, UpdateStatus.METADATA_MISSING) - - metadata = {'installation date': None, - 'installation time': None, - 'original source': None, - 'requested commit': None, - 'installed commit': None, - } - with open(metadata_file, "r") as meta: - metadata_lines = meta.readlines() - for line_no, line in enumerate(metadata_lines): - if line_no > 0 and metadata_lines[line_no - 1].strip() in metadata: - metadata.update({metadata_lines[line_no - 1].strip(): line.strip()}) - for key in metadata: - if metadata[key].lower() == 'none': - metadata[key] = None - log.debug(f'{plugin_name} previous installation metadata: {str(metadata)}') - if metadata['requested commit']: - log.warning(f'refusing to upgrade {plugin_name}@{metadata["requested commit"]} due to previously requested tag/commit') - return (None, UpdateStatus.REFUSING_UPDATE) - - src = InstInfo(plugin_name, - metadata['original source'], None) - if not src.get_inst_details(): - log.error(f'cannot locate {plugin_name} in original source {metadata["original_source"]}') - return (None, UpdateStatus.ERROR) - repo_commit = src.get_repo_commit() - if not repo_commit: - log.debug('source commit not available') - else: - log.debug(f'source commit: {repo_commit}') - if repo_commit and repo_commit == metadata['installed commit']: - log.info(f'Installed {plugin_name} is already latest @{repo_commit}') - return (None, UpdateStatus.LATEST) - uninstall(plugin_name) - try: - installed = _install_plugin(src) - except FileExistsError as err: - log.error(f'File exists: {err.filename}') - return (None, UpdateStatus.ERROR) - result = _enable_installed(installed, plugin_name) - if result: - return (result, UpdateStatus.SUCCESS) - return (result, UpdateStatus.ERROR) - - -def update_plugins(plugin_name: str): - """user requested plugin upgrade(s)""" - if plugin_name: - installed = update_plugin(plugin_name) - if not installed[0] and installed[1] != UpdateStatus.LATEST: - log.error(f'{plugin_name} update aborted') - return installed[0] - - log.info("updating all plugins") - update_results = [] - for plugin in os.listdir(RECKLESS_CONFIG.reckless_dir): - if not (Path(RECKLESS_CONFIG.reckless_dir) / plugin).is_dir(): - continue - if len(plugin) > 0 and plugin[0] == '.': - continue - update_results.append(update_plugin(plugin)[0]) - return update_results - - -def report_version() -> str: - """return reckless version""" - log.info(__VERSION__) - log.add_result(__VERSION__) - - -def unpack_json_arg(json_target: str) -> list: - """validate json for any command line targets passes as a json array""" - try: - targets = json.loads(json_target) - except json.decoder.JSONDecodeError: - return None - if isinstance(targets, list): - return targets - log.warning(f'input {target_list} is not a json array') - return None - - -class StoreIdempotent(argparse.Action): - """Make the option idempotent. This adds a secondary argument that doesn't - get reinitialized. The downside is it""" - def __init__(self, option_strings, dest, nargs=None, **kwargs): - super().__init__(option_strings, dest, **kwargs) - - def __call__(self, parser, namespace, values, option_string=None): - if option_string: - setattr(namespace, self.dest, values) - setattr(namespace, f'{self.dest}_idempotent', values) - - -class StoreTrueIdempotent(argparse._StoreConstAction): - """Make the option idempotent""" - def __init__(self, option_strings, dest, default=False, - required=False, nargs=None, const=None, help=None): - super().__init__(option_strings=option_strings, dest=dest, - const=const, help=help) - - def __call__(self, parser, namespace, values, option_string=None): - if option_string: - setattr(namespace, self.dest, True) - setattr(namespace, f'{self.dest}_idempotent', True) - - -def process_idempotent_args(args): - """Swap idempotently set arguments back in for the default arg names.""" - original_args = dict(vars(args)) - for arg, value in original_args.items(): - if f"{arg}_idempotent" in vars(args): - setattr(args, f"{arg}", vars(args)[f"{arg}_idempotent"]) - delattr(args, f"{arg}_idempotent") - return args - - -if __name__ == '__main__': - parser = argparse.ArgumentParser() - cmd1 = parser.add_subparsers(dest='cmd1', help='command', - required=False) - - install_cmd = cmd1.add_parser('install', help='search for and install a ' - 'plugin, then test and activate') - install_cmd.add_argument('targets', type=str, nargs='*') - install_cmd.set_defaults(func=install) - - uninstall_cmd = cmd1.add_parser('uninstall', help='deactivate a plugin ' - 'and remove it from the directory') - uninstall_cmd.add_argument('targets', type=str, nargs='*') - uninstall_cmd.set_defaults(func=uninstall) - - search_cmd = cmd1.add_parser('search', help='search for a plugin from ' - 'the available source repositories') - search_cmd.add_argument('targets', type=str, nargs='*') - search_cmd.set_defaults(func=search) - - enable_cmd = cmd1.add_parser('enable', help='dynamically enable a plugin ' - 'and update config') - enable_cmd.add_argument('targets', type=str, nargs='*') - enable_cmd.set_defaults(func=enable) - disable_cmd = cmd1.add_parser('disable', help='disable a plugin') - disable_cmd.add_argument('targets', type=str, nargs='*') - disable_cmd.set_defaults(func=disable) - source_parser = cmd1.add_parser('source', help='manage plugin search ' - 'sources') - source_subs = source_parser.add_subparsers(dest='source_subs', - required=True) - list_parse = source_subs.add_parser('list', help='list available plugin ' - 'sources (repositories)') - list_parse.set_defaults(func=list_source) - source_add = source_subs.add_parser('add', help='add a source repository') - source_add.add_argument('targets', type=str, nargs='*') - source_add.set_defaults(func=add_source) - source_rem = source_subs.add_parser('remove', aliases=['rem', 'rm'], - help='remove a plugin source ' - 'repository') - source_rem.add_argument('targets', type=str, nargs='*') - source_rem.set_defaults(func=remove_source) - update = cmd1.add_parser('update', help='update plugins to lastest version') - update.add_argument('targets', type=str, nargs='*') - update.set_defaults(func=update_plugins) - - help_cmd = cmd1.add_parser('help', help='for contextual help, use ' - '"reckless -h"') - help_cmd.add_argument('targets', type=str, nargs='*') - help_cmd.set_defaults(func=help_alias) - parser.add_argument('-V', '--version', - action=StoreTrueIdempotent, const=None, - help='print version and exit') - - all_parsers = [parser, install_cmd, uninstall_cmd, search_cmd, enable_cmd, - disable_cmd, list_parse, source_add, source_rem, help_cmd, - update] - for p in all_parsers: - # This default depends on the .lightning directory - p.add_argument('-d', '--reckless-dir', action=StoreIdempotent, - help='specify a data directory for reckless to use', - type=str, default=None) - p.add_argument('-l', '--lightning', type=str, action=StoreIdempotent, - help='lightning data directory ' - '(default:~/.lightning)', - default=Path.home().joinpath('.lightning')) - p.add_argument('-c', '--conf', action=StoreIdempotent, - help=' config file used by lightningd', - type=str, - default=None) - p.add_argument('-r', '--regtest', action=StoreTrueIdempotent) - p.add_argument('--network', action=StoreIdempotent, - help="specify a network to use (default: bitcoin)", - type=str) - p.add_argument('-v', '--verbose', action=StoreTrueIdempotent, - const=None) - p.add_argument('-j', '--json', action=StoreTrueIdempotent, - help='output in json format') - - args = parser.parse_args() - args = process_idempotent_args(args) - - if args.json: - log.capture = True - stdout_redirect = PIPE - stderr_redirect = PIPE - else: - stdout_redirect = None - stderr_redirect = None - - if args.verbose: - logging.root.setLevel(logging.DEBUG) - else: - logging.root.setLevel(logging.INFO) - - NETWORK = 'regtest' if args.regtest else 'bitcoin' - SUPPORTED_NETWORKS = ['bitcoin', 'regtest', 'liquid', 'liquid-regtest', - 'signet', 'testnet', 'testnet4'] - if args.version: - report_version() - elif args.cmd1 is None: - parser.print_help(sys.stdout) - sys.exit(1) - if args.network: - if args.network in SUPPORTED_NETWORKS: - NETWORK = args.network - else: - log.error(f"{args.network} network not supported") - LIGHTNING_DIR = Path(args.lightning) - # This env variable is set under CI testing - LIGHTNING_CLI_CALL = [os.environ.get('LIGHTNING_CLI')] - if LIGHTNING_CLI_CALL == [None]: - LIGHTNING_CLI_CALL = ['lightning-cli'] - if NETWORK != 'bitcoin': - LIGHTNING_CLI_CALL.append(f'--network={NETWORK}') - if LIGHTNING_DIR != Path.home().joinpath('.lightning'): - LIGHTNING_CLI_CALL.append(f'--lightning-dir={LIGHTNING_DIR}') - if args.reckless_dir: - RECKLESS_DIR = Path(args.reckless_dir) - else: - RECKLESS_DIR = Path(LIGHTNING_DIR) / 'reckless' - LIGHTNING_CONFIG = args.conf - RECKLESS_CONFIG = load_config(reckless_dir=str(RECKLESS_DIR), - network=NETWORK) - RECKLESS_SOURCES = load_sources() - API_GITHUB_COM = 'https://api.github.com' - GITHUB_COM = 'https://github.com' - # Used for blackbox testing to avoid hitting github servers - if 'REDIR_GITHUB_API' in os.environ: - API_GITHUB_COM = os.environ['REDIR_GITHUB_API'] - if 'REDIR_GITHUB' in os.environ: - GITHUB_COM = os.environ['REDIR_GITHUB'] - - GITHUB_API_FALLBACK = False - if 'GITHUB_API_FALLBACK' in os.environ: - GITHUB_API_FALLBACK = os.environ['GITHUB_API_FALLBACK'] - - if 'targets' in args: # and len(args.targets) > 0: - if args.func.__name__ == 'help_alias': - args.func(args.targets) - sys.exit(0) - # Catch a missing argument so that we can overload functions. - if len(args.targets) == 0: - args.targets=[None] - for target in args.targets: - # Accept single item arguments, or a json array - try: - target_list = unpack_json_arg(target) - if target_list: - for tar in target_list: - log.add_result(args.func(tar)) - else: - log.add_result(args.func(target)) - except TypeError: - if len(args.targets) == 1: - log.add_result(args.func(target)) - elif 'func' in args: - log.add_result(args.func()) - - if log.capture: - log.reply_json()