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:
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"])