cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 2d190c4e00885cd30953b2646b47d5c4e502cf5b
parent 30d4ea579904adc3aa92d6a9fc4257ad212a96cb
Author: triesap <tyson@radroots.org>
Date:   Sat, 27 Jun 2026 02:25:22 +0000

terminal: add core runtime renderers

- register explicit terminal renderers for workspace, health, config, account, signer, relay, store, and sync operations
- move shared terminal document fallback logic out of main into renderer common helpers
- add terminal registry coverage and no-secret command output checks
- preserve JSON and NDJSON machine output behavior through focused validation

Diffstat:
Msrc/main.rs | 223+++----------------------------------------------------------------------------
Msrc/out/terminal/mod.rs | 1+
Msrc/out/terminal/registry.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/renderers/account.rs | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/renderers/common.rs | 352+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/renderers/config.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/renderers/health.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/renderers/mod.rs | 8++++++++
Asrc/out/terminal/renderers/runtime.rs | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/renderers/store.rs | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/renderers/sync.rs | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/renderers/workspace.rs | 37+++++++++++++++++++++++++++++++++++++
Mtests/target_cli.rs | 39++++++++++++++++++++++++++++++++++++---
13 files changed, 957 insertions(+), 218 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -13,7 +13,6 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use clap::Parser; -use serde_json::Value; use crate::cli::input::runtime_invocation_args_from_target; use crate::cli::{TargetCliArgs, TargetOutputFormat}; @@ -28,15 +27,11 @@ use crate::ops::{ TargetOperationRequest, }; use crate::out::envelope::{CliExitCode, OutputEnvelope, OutputError}; -use crate::out::terminal::actions::terminal_actions_from_next_actions; -use crate::out::terminal::errors::terminal_error_document; -use crate::out::terminal::layout::{ - TerminalDocument, TerminalField, TerminalHeader, TerminalReference, TerminalSymbol, -}; +use crate::out::terminal::registry::terminal_renderer_registry; use crate::out::terminal::renderer::{ TerminalColorPolicy, TerminalRenderContext, TerminalVerbosity, render_terminal_document, }; -use crate::out::terminal::values::{proof_summary, string_path, transport_label}; +use crate::out::terminal::renderers::common::generic_terminal_document; use crate::registry::{NetworkRequirement, network_requirement, requires_local_signer_mode}; use crate::runtime::config::{ OutputFormat as RuntimeOutputFormat, RuntimeConfig, SignerBackend, Verbosity, @@ -640,7 +635,12 @@ fn render_terminal_envelope( envelope: &OutputEnvelope, cx: &TerminalRenderContext, ) -> Result<(), runtime::RuntimeError> { - let rendered = render_terminal_document(&terminal_document_from_envelope(envelope), cx); + let registry = terminal_renderer_registry(); + let document = registry + .get(envelope.operation_id.as_str()) + .map(|renderer| renderer.render(envelope, cx)) + .unwrap_or_else(|| generic_terminal_document(envelope)); + let rendered = render_terminal_document(&document, cx); if envelope.errors.is_empty() { let stdout = std::io::stdout(); let mut handle = stdout.lock(); @@ -653,213 +653,6 @@ fn render_terminal_envelope( Ok(()) } -fn terminal_document_from_envelope(envelope: &OutputEnvelope) -> TerminalDocument { - let display = terminal_display_source(envelope); - let mut document = if envelope.errors.is_empty() { - let status = terminal_envelope_status(envelope); - TerminalDocument::new(TerminalHeader::new( - terminal_status_symbol(status, envelope.dry_run), - terminal_title(envelope.operation_id.as_str(), status), - )) - } else { - let mut document = terminal_error_document(envelope); - add_terminal_display_fields(&mut document, display, false); - document - }; - if envelope.errors.is_empty() { - add_terminal_display_fields(&mut document, display, true); - } - document.warnings = envelope - .warnings - .iter() - .map(|warning| { - crate::out::terminal::layout::TerminalWarning::new( - warning.code.clone(), - warning.message.clone(), - ) - }) - .collect(); - document.next = terminal_actions_from_next_actions(&envelope.next_actions); - document.reference = terminal_reference(envelope); - document -} - -fn add_terminal_display_fields( - document: &mut TerminalDocument, - display: &Value, - include_reason: bool, -) { - if let Some(state) = terminal_state(display) { - push_terminal_field(document, "State", terminal_status_label(state)); - } - if let Some(mode) = terminal_publish_transport(display) { - push_terminal_field(document, "Transport", transport_label(mode)); - } - if let Some(state) = terminal_publish_state(display) { - push_terminal_field(document, "Publish", terminal_status_label(state)); - } - if let Some(proof) = proof_summary(display) { - push_terminal_field(document, "Proof", proof); - } - if include_reason && let Some(reason) = terminal_reason(display) { - push_terminal_field(document, "Reason", reason.to_owned()); - } -} - -fn push_terminal_field( - document: &mut TerminalDocument, - label: impl Into<String>, - value: impl Into<String>, -) { - let label = label.into(); - let value = value.into(); - if value.trim().is_empty() { - return; - } - if document - .fields - .iter() - .any(|field| field.label == label && field.value == value) - { - return; - } - document.fields.push(TerminalField::new(label, value)); -} - -fn terminal_reference(envelope: &OutputEnvelope) -> Option<TerminalReference> { - let reference = TerminalReference { - request_id: Some(envelope.request_id.clone()), - correlation_id: envelope.correlation_id.clone(), - idempotency_key: envelope.idempotency_key.clone(), - event_id: None, - event_addr: None, - job_id: None, - path: None, - source: None, - }; - (!reference.is_empty()).then_some(reference) -} - -fn terminal_display_source(envelope: &OutputEnvelope) -> &Value { - if !envelope.result.is_null() { - return &envelope.result; - } - envelope - .errors - .first() - .and_then(|error| error.detail.as_ref()) - .unwrap_or(&envelope.result) -} - -fn terminal_state(result: &Value) -> Option<&str> { - string_path(result, &["state"]) -} - -fn terminal_publish_transport(result: &Value) -> Option<&str> { - string_path(result, &["publish", "mode"]) - .or_else(|| string_path(result, &["checks", "publish", "mode"])) - .or_else(|| string_path(result, &["publish_transport"])) -} - -fn terminal_publish_state(result: &Value) -> Option<&str> { - string_path(result, &["publish", "state"]) - .or_else(|| string_path(result, &["checks", "publish", "state"])) - .or_else(|| string_path(result, &["publish_state"])) -} - -fn terminal_reason(result: &Value) -> Option<&str> { - string_path(result, &["reason"]) - .or_else(|| string_path(result, &["publish", "reason"])) - .or_else(|| string_path(result, &["checks", "publish", "reason"])) - .or_else(|| string_path(result, &["store", "reason"])) - .or_else(|| string_path(result, &["checks", "store", "reason"])) - .or_else(|| string_path(result, &["checks", "account", "reason"])) -} - -fn terminal_envelope_status(envelope: &OutputEnvelope) -> &str { - if !envelope.errors.is_empty() { - return "error"; - } - if let Some(state) = envelope - .result - .get("state") - .and_then(|value| value.as_str()) - { - return state; - } - if envelope.dry_run { - return "dry_run"; - } - "ok" -} - -fn terminal_status_symbol(status: &str, dry_run: bool) -> TerminalSymbol { - if dry_run || status == "dry_run" { - return TerminalSymbol::Neutral; - } - if terminal_status_needs_attention(status) { - TerminalSymbol::Attention - } else { - TerminalSymbol::Success - } -} - -fn terminal_status_needs_attention(status: &str) -> bool { - matches!( - status, - "needs_attention" - | "unconfigured" - | "unavailable" - | "blocked" - | "invalid" - | "not_ready" - | "partial" - | "degraded" - | "failed" - | "conflict" - ) -} - -fn terminal_title(operation_id: &str, status: &str) -> String { - format!( - "{} {}", - operation_title(operation_id), - terminal_status_label(status) - ) -} - -fn operation_title(operation_id: &str) -> String { - let mut words = operation_id - .split('.') - .flat_map(|part| part.split('_')) - .filter(|part| !part.is_empty()) - .map(str::to_owned) - .collect::<Vec<_>>(); - if let Some(first) = words.first_mut() { - *first = capitalize_ascii_word(first); - } - words.join(" ") -} - -fn terminal_status_label(status: &str) -> String { - status - .split('_') - .filter(|part| !part.is_empty()) - .collect::<Vec<_>>() - .join(" ") -} - -fn capitalize_ascii_word(word: &str) -> String { - let mut chars = word.chars(); - let Some(first) = chars.next() else { - return String::new(); - }; - let mut rendered = String::new(); - rendered.push(first.to_ascii_uppercase()); - rendered.extend(chars); - rendered -} - fn envelope_exit_code(envelope: &OutputEnvelope) -> ExitCode { envelope .errors diff --git a/src/out/terminal/mod.rs b/src/out/terminal/mod.rs @@ -5,5 +5,6 @@ pub mod errors; pub mod layout; pub mod registry; pub mod renderer; +pub mod renderers; pub mod tables; pub mod values; diff --git a/src/out/terminal/registry.rs b/src/out/terminal/registry.rs @@ -56,6 +56,17 @@ impl TerminalRendererRegistry { } } +pub fn terminal_renderer_registry() -> TerminalRendererRegistry { + let registry = TerminalRendererRegistry::new(); + let registry = crate::out::terminal::renderers::workspace::register(registry); + let registry = crate::out::terminal::renderers::health::register(registry); + let registry = crate::out::terminal::renderers::config::register(registry); + let registry = crate::out::terminal::renderers::account::register(registry); + let registry = crate::out::terminal::renderers::runtime::register(registry); + let registry = crate::out::terminal::renderers::store::register(registry); + crate::out::terminal::renderers::sync::register(registry) +} + #[cfg(test)] mod tests { use crate::out::terminal::layout::{TerminalDocument, TerminalHeader, TerminalSymbol}; @@ -87,4 +98,44 @@ mod tests { assert!(registry.get("workspace.get").is_some()); assert!(registry.get("missing").is_none()); } + + #[test] + fn core_runtime_registry_covers_rcld_04_operations() { + let registry = terminal_renderer_registry(); + let expected = [ + "workspace.init", + "workspace.get", + "health.status.get", + "health.check.run", + "config.get", + "account.create", + "account.import", + "account.attach_secret", + "account.get", + "account.list", + "account.remove", + "account.selection.get", + "account.selection.update", + "account.selection.clear", + "signer.status.get", + "relay.list", + "store.init", + "store.status.get", + "store.export", + "store.backup.create", + "store.backup.restore", + "sync.status.get", + "sync.pull", + "sync.push", + "sync.watch", + ]; + + assert_eq!(registry.len(), expected.len()); + for operation_id in expected { + assert!( + registry.contains(operation_id), + "missing terminal renderer for {operation_id}" + ); + } + } } diff --git a/src/out/terminal/renderers/account.rs b/src/out/terminal/renderers/account.rs @@ -0,0 +1,111 @@ +use serde_json::Value; + +use crate::out::envelope::OutputEnvelope; +use crate::out::terminal::layout::{TerminalDocument, TerminalSection}; +use crate::out::terminal::registry::{TerminalOperationRenderer, TerminalRendererRegistry}; +use crate::out::terminal::renderer::TerminalRenderContext; +use crate::out::terminal::tables::{TerminalTableColumn, TerminalTableRow}; + +use super::common; + +pub fn register(registry: TerminalRendererRegistry) -> TerminalRendererRegistry { + registry + .register("account.create", &ACCOUNT_RENDERER) + .register("account.import", &ACCOUNT_RENDERER) + .register("account.attach_secret", &ACCOUNT_RENDERER) + .register("account.get", &ACCOUNT_RENDERER) + .register("account.list", &ACCOUNT_RENDERER) + .register("account.remove", &ACCOUNT_RENDERER) + .register("account.selection.get", &ACCOUNT_RENDERER) + .register("account.selection.update", &ACCOUNT_RENDERER) + .register("account.selection.clear", &ACCOUNT_RENDERER) +} + +struct AccountRenderer; + +static ACCOUNT_RENDERER: AccountRenderer = AccountRenderer; + +impl TerminalOperationRenderer for AccountRenderer { + fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { + if !envelope.errors.is_empty() { + return common::generic_terminal_document(envelope); + } + let result = common::result(envelope); + let mut document = + common::document_with_title(envelope, account_title(envelope.operation_id.as_str())); + match envelope.operation_id.as_str() { + "account.list" => add_account_list(&mut document, result), + _ => add_account_fields(&mut document, result), + } + document + } +} + +fn account_title(operation_id: &str) -> &'static str { + match operation_id { + "account.create" => "Account created", + "account.import" => "Account imported", + "account.attach_secret" => "Account secret attached", + "account.get" => "Account", + "account.list" => "Accounts", + "account.remove" => "Account removed", + "account.selection.get" => "Account selection", + "account.selection.update" => "Account selection updated", + "account.selection.clear" => "Account selection cleared", + _ => "Account", + } +} + +fn add_account_fields(document: &mut TerminalDocument, result: &Value) { + for path in [ + &["account", "id"][..], + &["account_id"][..], + &["resolved_account", "account_id"][..], + &["default_account", "account_id"][..], + &["selected_account", "account_id"][..], + ] { + if let Some(account_id) = common::string(result, path) { + common::push_field(document, "Account", account_id); + break; + } + } + common::push_path_field(document, "Label", result, &["account", "label"]); + common::push_path_field(document, "Public key", result, &["account", "public_key"]); + common::push_bool_field( + document, + "Write capable", + result, + &["account", "write_capable"], + ); + common::push_path_field(document, "Source", result, &["source"]); +} + +fn add_account_list(document: &mut TerminalDocument, result: &Value) { + common::push_count_field(document, "Count", result, &["count"]); + let rows = common::array(result, &["accounts"]) + .into_iter() + .flatten() + .map(|account| { + TerminalTableRow::new(vec![ + common::string(account, &["id"]).unwrap_or_default(), + common::string(account, &["label"]).unwrap_or_default(), + common::string(account, &["public_key"]).unwrap_or_default(), + ]) + }) + .collect::<Vec<_>>(); + document.sections.push(common::table_section( + "Accounts", + vec![ + TerminalTableColumn::new("Account", 7, 22), + TerminalTableColumn::new("Label", 5, 18), + TerminalTableColumn::new("Public key", 10, 24), + ], + rows, + "No accounts found", + )); + if let Some(source) = common::string(result, &["source"]) { + document + .sections + .push(TerminalSection::lines("Source", vec![source])); + } +} diff --git a/src/out/terminal/renderers/common.rs b/src/out/terminal/renderers/common.rs @@ -0,0 +1,352 @@ +use serde_json::Value; + +use crate::out::envelope::OutputEnvelope; +use crate::out::terminal::actions::terminal_actions_from_next_actions; +use crate::out::terminal::errors::terminal_error_document; +use crate::out::terminal::layout::{ + TerminalDocument, TerminalField, TerminalHeader, TerminalReference, TerminalSection, + TerminalSymbol, TerminalWarning, +}; +use crate::out::terminal::tables::{TerminalTable, TerminalTableColumn, TerminalTableRow}; +use crate::out::terminal::values::{proof_summary, string_path, transport_label}; + +pub(crate) fn generic_terminal_document(envelope: &OutputEnvelope) -> TerminalDocument { + let display = terminal_display_source(envelope); + let mut document = if envelope.errors.is_empty() { + let status = terminal_envelope_status(envelope); + TerminalDocument::new(TerminalHeader::new( + terminal_status_symbol(status, envelope.dry_run), + terminal_title(envelope.operation_id.as_str(), status), + )) + } else { + let mut document = terminal_error_document(envelope); + add_terminal_display_fields(&mut document, display, false); + document + }; + if envelope.errors.is_empty() { + add_terminal_display_fields(&mut document, display, true); + } + document.warnings = envelope + .warnings + .iter() + .map(|warning| TerminalWarning::new(warning.code.clone(), warning.message.clone())) + .collect(); + document.next = terminal_actions_from_next_actions(&envelope.next_actions); + document.reference = terminal_reference(envelope); + document +} + +pub(crate) fn document_with_title( + envelope: &OutputEnvelope, + title: impl Into<String>, +) -> TerminalDocument { + let mut document = generic_terminal_document(envelope); + document.header.title = title.into(); + document +} + +pub(crate) fn document_with_status_title( + envelope: &OutputEnvelope, + title_prefix: &str, +) -> TerminalDocument { + let status = terminal_envelope_status(envelope); + let mut document = generic_terminal_document(envelope); + document.header.symbol = terminal_status_symbol(status, envelope.dry_run); + document.header.title = format!("{title_prefix} {}", terminal_status_label(status)); + document +} + +pub(crate) fn result(envelope: &OutputEnvelope) -> &Value { + &envelope.result +} + +pub(crate) fn display_source(envelope: &OutputEnvelope) -> &Value { + terminal_display_source(envelope) +} + +pub(crate) fn state(value: &Value) -> Option<&str> { + terminal_state(value) +} + +pub(crate) fn status_label(status: &str) -> String { + terminal_status_label(status) +} + +pub(crate) fn push_field( + document: &mut TerminalDocument, + label: impl Into<String>, + value: impl Into<String>, +) { + let label = label.into(); + let value = value.into(); + if value.trim().is_empty() { + return; + } + if document + .fields + .iter() + .any(|field| field.label == label && field.value == value) + { + return; + } + document.fields.push(TerminalField::new(label, value)); +} + +pub(crate) fn push_verbose_field( + document: &mut TerminalDocument, + label: impl Into<String>, + value: impl Into<String>, +) { + let value = value.into(); + if value.trim().is_empty() { + return; + } + document.fields.push(TerminalField::verbose(label, value)); +} + +pub(crate) fn push_path_field( + document: &mut TerminalDocument, + label: &str, + value: &Value, + path: &[&str], +) { + if let Some(value) = string_path(value, path) { + push_field(document, label, value); + } +} + +pub(crate) fn push_verbose_path_field( + document: &mut TerminalDocument, + label: &str, + value: &Value, + path: &[&str], +) { + if let Some(value) = string_path(value, path) { + push_verbose_field(document, label, value); + } +} + +pub(crate) fn push_count_field( + document: &mut TerminalDocument, + label: &str, + value: &Value, + path: &[&str], +) { + if let Some(value) = number_path(value, path) { + push_field(document, label, value.to_string()); + } +} + +pub(crate) fn push_bool_field( + document: &mut TerminalDocument, + label: &str, + value: &Value, + path: &[&str], +) { + if let Some(value) = bool_path(value, path) { + push_field(document, label, if value { "yes" } else { "no" }); + } +} + +pub(crate) fn table_section( + title: &str, + columns: Vec<TerminalTableColumn>, + rows: Vec<TerminalTableRow>, + empty: &str, +) -> TerminalSection { + let mut table = TerminalTable::new(columns).with_empty(empty); + for row in rows { + table = table.with_row(row); + } + TerminalSection::table(title, table) +} + +pub(crate) fn string(value: &Value, path: &[&str]) -> Option<String> { + string_path(value, path).map(str::to_owned) +} + +pub(crate) fn array<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Vec<Value>> { + let mut current = value; + for segment in path { + current = current.get(*segment)?; + } + current.as_array() +} + +pub(crate) fn number_path(value: &Value, path: &[&str]) -> Option<i64> { + let mut current = value; + for segment in path { + current = current.get(*segment)?; + } + current.as_i64() +} + +pub(crate) fn bool_path(value: &Value, path: &[&str]) -> Option<bool> { + let mut current = value; + for segment in path { + current = current.get(*segment)?; + } + current.as_bool() +} + +pub(crate) fn title_for(envelope: &OutputEnvelope, noun: &str) -> String { + let status = terminal_envelope_status(envelope); + format!("{noun} {}", terminal_status_label(status)) +} + +fn add_terminal_display_fields( + document: &mut TerminalDocument, + display: &Value, + include_reason: bool, +) { + if let Some(state) = terminal_state(display) { + push_field(document, "State", terminal_status_label(state)); + } + if let Some(mode) = terminal_publish_transport(display) { + push_field(document, "Transport", transport_label(mode)); + } + if let Some(state) = terminal_publish_state(display) { + push_field(document, "Publish", terminal_status_label(state)); + } + if let Some(proof) = proof_summary(display) { + push_field(document, "Proof", proof); + } + if include_reason && let Some(reason) = terminal_reason(display) { + push_field(document, "Reason", reason.to_owned()); + } +} + +fn terminal_reference(envelope: &OutputEnvelope) -> Option<TerminalReference> { + let reference = TerminalReference { + request_id: Some(envelope.request_id.clone()), + correlation_id: envelope.correlation_id.clone(), + idempotency_key: envelope.idempotency_key.clone(), + event_id: None, + event_addr: None, + job_id: None, + path: None, + source: None, + }; + (!reference.is_empty()).then_some(reference) +} + +fn terminal_display_source(envelope: &OutputEnvelope) -> &Value { + if !envelope.result.is_null() { + return &envelope.result; + } + envelope + .errors + .first() + .and_then(|error| error.detail.as_ref()) + .unwrap_or(&envelope.result) +} + +fn terminal_state(result: &Value) -> Option<&str> { + string_path(result, &["state"]) +} + +fn terminal_publish_transport(result: &Value) -> Option<&str> { + string_path(result, &["publish", "mode"]) + .or_else(|| string_path(result, &["checks", "publish", "mode"])) + .or_else(|| string_path(result, &["publish_transport"])) +} + +fn terminal_publish_state(result: &Value) -> Option<&str> { + string_path(result, &["publish", "state"]) + .or_else(|| string_path(result, &["checks", "publish", "state"])) + .or_else(|| string_path(result, &["publish_state"])) +} + +fn terminal_reason(result: &Value) -> Option<&str> { + string_path(result, &["reason"]) + .or_else(|| string_path(result, &["publish", "reason"])) + .or_else(|| string_path(result, &["checks", "publish", "reason"])) + .or_else(|| string_path(result, &["store", "reason"])) + .or_else(|| string_path(result, &["checks", "store", "reason"])) + .or_else(|| string_path(result, &["checks", "account", "reason"])) +} + +fn terminal_envelope_status(envelope: &OutputEnvelope) -> &str { + if !envelope.errors.is_empty() { + return "error"; + } + if let Some(state) = envelope + .result + .get("state") + .and_then(|value| value.as_str()) + { + return state; + } + if envelope.dry_run { + return "dry_run"; + } + "ok" +} + +fn terminal_status_symbol(status: &str, dry_run: bool) -> TerminalSymbol { + if dry_run || status == "dry_run" { + return TerminalSymbol::Neutral; + } + if terminal_status_needs_attention(status) { + TerminalSymbol::Attention + } else { + TerminalSymbol::Success + } +} + +fn terminal_status_needs_attention(status: &str) -> bool { + matches!( + status, + "needs_attention" + | "unconfigured" + | "unavailable" + | "blocked" + | "invalid" + | "not_ready" + | "partial" + | "degraded" + | "failed" + | "conflict" + | "missing" + ) +} + +fn terminal_title(operation_id: &str, status: &str) -> String { + format!( + "{} {}", + operation_title(operation_id), + terminal_status_label(status) + ) +} + +fn operation_title(operation_id: &str) -> String { + let mut words = operation_id + .split('.') + .flat_map(|part| part.split('_')) + .filter(|part| !part.is_empty()) + .map(str::to_owned) + .collect::<Vec<_>>(); + if let Some(first) = words.first_mut() { + *first = capitalize_ascii_word(first); + } + words.join(" ") +} + +fn terminal_status_label(status: &str) -> String { + status + .split('_') + .filter(|part| !part.is_empty()) + .collect::<Vec<_>>() + .join(" ") +} + +fn capitalize_ascii_word(word: &str) -> String { + let mut chars = word.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + let mut rendered = String::new(); + rendered.push(first.to_ascii_uppercase()); + rendered.extend(chars); + rendered +} diff --git a/src/out/terminal/renderers/config.rs b/src/out/terminal/renderers/config.rs @@ -0,0 +1,58 @@ +use crate::out::envelope::OutputEnvelope; +use crate::out::terminal::layout::{TerminalDocument, TerminalSymbol}; +use crate::out::terminal::registry::{TerminalOperationRenderer, TerminalRendererRegistry}; +use crate::out::terminal::renderer::TerminalRenderContext; +use crate::out::terminal::values::transport_label; + +use super::common; + +pub fn register(registry: TerminalRendererRegistry) -> TerminalRendererRegistry { + registry.register("config.get", &CONFIG_RENDERER) +} + +struct ConfigRenderer; + +static CONFIG_RENDERER: ConfigRenderer = ConfigRenderer; + +impl TerminalOperationRenderer for ConfigRenderer { + fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { + if !envelope.errors.is_empty() { + return common::generic_terminal_document(envelope); + } + let result = common::result(envelope); + let publish_state = + common::string(result, &["publish", "state"]).unwrap_or_else(|| "ready".to_owned()); + let mut document = common::document_with_title( + envelope, + format!("Config {}", common::status_label(publish_state.as_str())), + ); + if !matches!(publish_state.as_str(), "ready" | "configured" | "ok") { + document.header.symbol = TerminalSymbol::Attention; + } + common::push_path_field(&mut document, "Output", result, &["output", "format"]); + if let Some(transport) = common::string(result, &["publish", "transport"]) { + common::push_field( + &mut document, + "Transport", + transport_label(transport.as_str()), + ); + } + common::push_path_field(&mut document, "Publish", result, &["publish", "state"]); + common::push_path_field(&mut document, "Signer", result, &["signer", "mode"]); + common::push_path_field( + &mut document, + "Account", + result, + &["account_resolution", "status"], + ); + common::push_count_field(&mut document, "Relays", result, &["relay", "count"]); + common::push_verbose_path_field(&mut document, "Profile", result, &["paths", "profile"]); + common::push_verbose_path_field( + &mut document, + "Config", + result, + &["paths", "app_config_path"], + ); + document + } +} diff --git a/src/out/terminal/renderers/health.rs b/src/out/terminal/renderers/health.rs @@ -0,0 +1,55 @@ +use crate::out::envelope::OutputEnvelope; +use crate::out::terminal::layout::TerminalDocument; +use crate::out::terminal::registry::{TerminalOperationRenderer, TerminalRendererRegistry}; +use crate::out::terminal::renderer::TerminalRenderContext; + +use super::common; + +pub fn register(registry: TerminalRendererRegistry) -> TerminalRendererRegistry { + registry + .register("health.status.get", &HEALTH_RENDERER) + .register("health.check.run", &HEALTH_RENDERER) +} + +struct HealthRenderer; + +static HEALTH_RENDERER: HealthRenderer = HealthRenderer; + +impl TerminalOperationRenderer for HealthRenderer { + fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { + if !envelope.errors.is_empty() { + return common::generic_terminal_document(envelope); + } + let result = common::result(envelope); + let title = match envelope.operation_id.as_str() { + "health.check.run" => common::title_for(envelope, "Health check"), + _ => common::title_for(envelope, "Health"), + }; + let mut document = common::document_with_title(envelope, title); + common::push_path_field( + &mut document, + "Account", + result, + &["checks", "account", "state"], + ); + common::push_path_field( + &mut document, + "Store", + result, + &["checks", "store", "state"], + ); + common::push_path_field( + &mut document, + "Publish", + result, + &["checks", "publish", "state"], + ); + common::push_path_field( + &mut document, + "Network", + result, + &["checks", "network", "state"], + ); + document + } +} diff --git a/src/out/terminal/renderers/mod.rs b/src/out/terminal/renderers/mod.rs @@ -0,0 +1,8 @@ +pub mod account; +pub mod common; +pub mod config; +pub mod health; +pub mod runtime; +pub mod store; +pub mod sync; +pub mod workspace; diff --git a/src/out/terminal/renderers/runtime.rs b/src/out/terminal/renderers/runtime.rs @@ -0,0 +1,112 @@ +use serde_json::Value; + +use crate::out::envelope::OutputEnvelope; +use crate::out::terminal::layout::TerminalDocument; +use crate::out::terminal::registry::{TerminalOperationRenderer, TerminalRendererRegistry}; +use crate::out::terminal::renderer::TerminalRenderContext; +use crate::out::terminal::tables::{TerminalTableColumn, TerminalTableRow}; + +use super::common; + +pub fn register(registry: TerminalRendererRegistry) -> TerminalRendererRegistry { + registry + .register("signer.status.get", &RUNTIME_RENDERER) + .register("relay.list", &RUNTIME_RENDERER) +} + +struct RuntimeRenderer; + +static RUNTIME_RENDERER: RuntimeRenderer = RuntimeRenderer; + +impl TerminalOperationRenderer for RuntimeRenderer { + fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { + if !envelope.errors.is_empty() { + return common::generic_terminal_document(envelope); + } + let result = common::result(envelope); + match envelope.operation_id.as_str() { + "relay.list" => relay_document(envelope, result), + _ => signer_document(envelope, result), + } + } +} + +fn signer_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let title = common::title_for(envelope, "Signer"); + let mut document = common::document_with_title(envelope, title); + common::push_path_field(&mut document, "Mode", result, &["mode"]); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_path_field( + &mut document, + "Account", + result, + &["account_resolution", "status"], + ); + common::push_path_field(&mut document, "Binding", result, &["binding", "state"]); + common::push_path_field(&mut document, "Reason", result, &["reason"]); + if let Some(write_kinds) = common::array(result, &["write_kinds"]) { + let rows = write_kinds + .iter() + .map(|write_kind| { + TerminalTableRow::new(vec![ + common::string(write_kind, &["command"]).unwrap_or_default(), + common::string(write_kind, &["event_kind"]).unwrap_or_else(|| { + write_kind + .get("event_kind") + .and_then(Value::as_i64) + .map(|value| value.to_string()) + .unwrap_or_default() + }), + common::bool_path(write_kind, &["ready"]) + .map(|ready| if ready { "ready" } else { "blocked" }.to_owned()) + .unwrap_or_default(), + ]) + }) + .collect::<Vec<_>>(); + document.sections.push(common::table_section( + "Write permissions", + vec![ + TerminalTableColumn::new("Command", 7, 24), + TerminalTableColumn::new("Kind", 4, 8), + TerminalTableColumn::new("State", 5, 8), + ], + rows, + "No signer write permissions reported", + )); + } + document +} + +fn relay_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let title = common::title_for(envelope, "Relays"); + let mut document = common::document_with_title(envelope, title); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_count_field(&mut document, "Count", result, &["count"]); + common::push_path_field(&mut document, "Source", result, &["source"]); + let rows = common::array(result, &["relays"]) + .into_iter() + .flatten() + .map(|relay| { + TerminalTableRow::new(vec![ + common::string(relay, &["url"]).unwrap_or_default(), + common::bool_path(relay, &["read"]) + .map(|value| if value { "yes" } else { "no" }.to_owned()) + .unwrap_or_default(), + common::bool_path(relay, &["write"]) + .map(|value| if value { "yes" } else { "no" }.to_owned()) + .unwrap_or_default(), + ]) + }) + .collect::<Vec<_>>(); + document.sections.push(common::table_section( + "Relays", + vec![ + TerminalTableColumn::new("URL", 12, 42), + TerminalTableColumn::new("Read", 4, 4), + TerminalTableColumn::new("Write", 5, 5), + ], + rows, + "No relays configured", + )); + document +} diff --git a/src/out/terminal/renderers/store.rs b/src/out/terminal/renderers/store.rs @@ -0,0 +1,62 @@ +use crate::out::envelope::OutputEnvelope; +use crate::out::terminal::layout::TerminalDocument; +use crate::out::terminal::registry::{TerminalOperationRenderer, TerminalRendererRegistry}; +use crate::out::terminal::renderer::TerminalRenderContext; + +use super::common; + +pub fn register(registry: TerminalRendererRegistry) -> TerminalRendererRegistry { + registry + .register("store.init", &STORE_RENDERER) + .register("store.status.get", &STORE_RENDERER) + .register("store.export", &STORE_RENDERER) + .register("store.backup.create", &STORE_RENDERER) + .register("store.backup.restore", &STORE_RENDERER) +} + +struct StoreRenderer; + +static STORE_RENDERER: StoreRenderer = StoreRenderer; + +impl TerminalOperationRenderer for StoreRenderer { + fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { + if !envelope.errors.is_empty() { + return common::generic_terminal_document(envelope); + } + let result = common::result(envelope); + let mut document = + common::document_with_title(envelope, store_title(envelope.operation_id.as_str())); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_path_field(&mut document, "Source", result, &["source"]); + common::push_path_field(&mut document, "Storage", result, &["sdk_storage"]); + common::push_path_field( + &mut document, + "Legacy", + result, + &["legacy_replica", "state"], + ); + common::push_count_field( + &mut document, + "Events", + result, + &["event_store", "total_events"], + ); + common::push_count_field(&mut document, "Outbox", result, &["outbox", "total_events"]); + common::push_verbose_path_field(&mut document, "Root", result, &["local_root"]); + common::push_verbose_path_field(&mut document, "SDK root", result, &["sdk_root"]); + common::push_verbose_path_field(&mut document, "Export", result, &["path"]); + common::push_verbose_path_field(&mut document, "Backup", result, &["backup_path"]); + document + } +} + +fn store_title(operation_id: &str) -> &'static str { + match operation_id { + "store.init" => "Store initialized", + "store.status.get" => "Store status", + "store.export" => "Store exported", + "store.backup.create" => "Store backup created", + "store.backup.restore" => "Store backup restored", + _ => "Store", + } +} diff --git a/src/out/terminal/renderers/sync.rs b/src/out/terminal/renderers/sync.rs @@ -0,0 +1,66 @@ +use crate::out::envelope::OutputEnvelope; +use crate::out::terminal::layout::TerminalDocument; +use crate::out::terminal::registry::{TerminalOperationRenderer, TerminalRendererRegistry}; +use crate::out::terminal::renderer::TerminalRenderContext; + +use super::common; + +pub fn register(registry: TerminalRendererRegistry) -> TerminalRendererRegistry { + registry + .register("sync.status.get", &SYNC_RENDERER) + .register("sync.pull", &SYNC_RENDERER) + .register("sync.push", &SYNC_RENDERER) + .register("sync.watch", &SYNC_RENDERER) +} + +struct SyncRenderer; + +static SYNC_RENDERER: SyncRenderer = SyncRenderer; + +impl TerminalOperationRenderer for SyncRenderer { + fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { + if !envelope.errors.is_empty() { + return common::generic_terminal_document(envelope); + } + let result = common::display_source(envelope); + let mut document = + common::document_with_title(envelope, sync_title(envelope.operation_id.as_str())); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_path_field( + &mut document, + "Freshness", + result, + &["freshness", "display"], + ); + common::push_count_field(&mut document, "Relays", result, &["relay_count"]); + common::push_count_field(&mut document, "Fetched", result, &["fetched_count"]); + common::push_count_field(&mut document, "Ingested", result, &["ingested_count"]); + common::push_count_field(&mut document, "Skipped", result, &["skipped_count"]); + common::push_count_field(&mut document, "Failed", result, &["failed_count"]); + common::push_count_field( + &mut document, + "Pending", + result, + &["queue", "pending_count"], + ); + common::push_count_field( + &mut document, + "Ready", + result, + &["queue", "ready_signed_count"], + ); + common::push_path_field(&mut document, "Reason", result, &["reason"]); + common::push_verbose_path_field(&mut document, "Root", result, &["local_root"]); + document + } +} + +fn sync_title(operation_id: &str) -> &'static str { + match operation_id { + "sync.status.get" => "Sync status", + "sync.pull" => "Sync pull", + "sync.push" => "Sync push", + "sync.watch" => "Sync watch", + _ => "Sync", + } +} diff --git a/src/out/terminal/renderers/workspace.rs b/src/out/terminal/renderers/workspace.rs @@ -0,0 +1,37 @@ +use crate::out::envelope::OutputEnvelope; +use crate::out::terminal::layout::TerminalDocument; +use crate::out::terminal::registry::{TerminalOperationRenderer, TerminalRendererRegistry}; +use crate::out::terminal::renderer::TerminalRenderContext; + +use super::common; + +pub fn register(registry: TerminalRendererRegistry) -> TerminalRendererRegistry { + registry + .register("workspace.init", &WORKSPACE_RENDERER) + .register("workspace.get", &WORKSPACE_RENDERER) +} + +struct WorkspaceRenderer; + +static WORKSPACE_RENDERER: WorkspaceRenderer = WorkspaceRenderer; + +impl TerminalOperationRenderer for WorkspaceRenderer { + fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { + if !envelope.errors.is_empty() { + return common::generic_terminal_document(envelope); + } + let result = common::result(envelope); + let mut document = common::document_with_title( + envelope, + match envelope.operation_id.as_str() { + "workspace.init" => "Workspace initialized", + _ => "Workspace ready", + }, + ); + common::push_path_field(&mut document, "Profile", result, &["profile"]); + common::push_verbose_path_field(&mut document, "Config", result, &["app_config_path"]); + common::push_verbose_path_field(&mut document, "Data", result, &["app_data_root"]); + common::push_verbose_path_field(&mut document, "Replica", result, &["replica_db_path"]); + document + } +} diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -2794,7 +2794,7 @@ fn default_terminal_output_is_concise_and_not_json() { let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); - assert!(stdout.starts_with("✓ Workspace get ok\n")); + assert!(stdout.starts_with("✓ Workspace ready\n")); assert!(stderr.is_empty()); assert!(serde_json::from_str::<Value>(&stdout).is_err()); } @@ -2810,7 +2810,7 @@ fn explicit_terminal_output_is_accepted() { let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); - assert!(stdout.starts_with("✓ Workspace get ok\n")); + assert!(stdout.starts_with("✓ Workspace ready\n")); assert!(stderr.is_empty()); assert!(serde_json::from_str::<Value>(&stdout).is_err()); } @@ -2879,7 +2879,7 @@ fn terminal_health_status_surfaces_publish_reason_and_actions() { assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.starts_with("! Health status get needs attention\n")); + assert!(stdout.starts_with("! Health needs attention\n")); assert!(stdout.contains("Publish unconfigured")); assert!(stdout.contains("Reason direct_nostr_relay publish transport requires a selected or default write-capable local account for signed writes")); assert!(stdout.contains("Next\n radroots account create")); @@ -2906,6 +2906,39 @@ fn terminal_market_refresh_missing_store_shows_action() { } #[test] +fn core_runtime_terminal_output_does_not_emit_secret_material() { + let sandbox = RadrootsCliSandbox::new(); + + for args in [ + &["config", "get"][..], + &["account", "list"][..], + &["account", "create"][..], + &["signer", "status", "get"][..], + ] { + let output = sandbox.command().args(args).output().expect("run command"); + assert!(output.status.success(), "{args:?}"); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + let lowered = stdout.to_ascii_lowercase(); + + for forbidden in [ + "private_key", + "secret_key", + "seed phrase", + "mnemonic", + "bearer ", + "token", + "nsec", + "/secrets/", + ] { + assert!( + !lowered.contains(forbidden), + "`{args:?}` terminal output leaked `{forbidden}`: {stdout}" + ); + } + } +} + +#[test] fn terminal_failure_output_routes_to_stderr_and_preserves_message() { let output = radroots() .args(["--format", "terminal", "trade", "submit"])