iota/iota/src/main.rs
2026-07-28 02:20:15 +02:00

955 lines
39 KiB
Rust

use iota_cli::{
ipc_client::IpcClient, screens::main_screen::MainScreen, theme,
ui::start_bootstrap_tui_with_theme,
};
use iota_ipc::{LocalRequest, ResponsePayload, ResponseResult};
use iota_process_manager::detect;
use std::{path::Path, process::ExitCode, sync::Arc};
mod cli_args;
mod cli_color;
mod daemon_setup_flow;
mod local_daemon;
mod startup_error;
mod terms;
use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat};
use cli_color::ColorConfig;
use startup_error::StartupError;
#[tokio::main(flavor = "multi_thread")]
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
if !matches!(error, StartupError::Cancelled) {
startup_error::print_error(&error);
}
startup_error::exit_code(&error)
}
}
}
async fn run() -> Result<(), StartupError> {
let invocation =
CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?;
let CliInvocation {
theme_override,
output,
color,
unicode,
command,
} = invocation;
let local_endpoint = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
.map_err(|error| StartupError::Other(format!("Cannot resolve user IPC path: {error}")))?
.ipc_endpoint
{
iota_paths::IpcEndpoint::UnixSocket(path) => path,
iota_paths::IpcEndpoint::WindowsPipe(name) => {
return Err(StartupError::Other(format!(
"Windows IPC endpoint {name} is not supported by this client build"
)));
}
};
let system_endpoint = match iota_paths::IotaPaths::resolve(iota_paths::Scope::System)
.map_err(|error| StartupError::Other(format!("Cannot resolve system IPC path: {error}")))?
.ipc_endpoint
{
iota_paths::IpcEndpoint::UnixSocket(path) => path,
iota_paths::IpcEndpoint::WindowsPipe(name) => {
return Err(StartupError::Other(format!(
"Windows IPC endpoint {name} is not supported by this client build"
)));
}
};
let endpoints = daemon_setup_flow::DaemonEndpoints {
local: local_endpoint,
system: system_endpoint,
};
match command {
Command::Help => {
print_help();
Ok(())
}
Command::Version => {
println!("iota {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Command::Completions { shell } => print_completions(&shell),
Command::ManPage => {
print_man_page();
Ok(())
}
Command::TermsStatus { system } => terms::run(terms::TermsCommand::Status { system }).await,
Command::TermsShow { document } => terms::run(terms::TermsCommand::Show { document }).await,
Command::TermsAccept { system } => terms::run(terms::TermsCommand::Accept { system }).await,
Command::Install { bundle, operator } => {
iota_installer::install_linux_bundle_with_operator(
Path::new(&bundle),
operator.as_deref(),
)
.map_err(|error| StartupError::Other(format!("Installation failed: {error}")))
}
command => {
if matches!(
command,
Command::DaemonEnable { .. }
| Command::DaemonDisableStartup
| Command::DaemonStartupStatus
| Command::DaemonStart
| Command::DaemonRestartService
| Command::DaemonStopService
) {
return run_startup_command(command).await;
}
if !matches!(command, Command::Dashboard) {
let state_dir = iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
.map_err(|error| {
StartupError::Other(format!("Cannot resolve consent storage: {error}"))
})?
.state_dir;
if !iota_terms::consent::load(&state_dir).has_all_required() {
return Err(StartupError::Consent("Run `iota terms accept` in an interactive terminal to review and accept the required terms.".into()));
}
let ipc = tokio::select! {
result = connect_available(&endpoints) => result?,
_ = tokio::signal::ctrl_c() => return Err(StartupError::Cancelled),
};
return run_command(ipc, command, output).await;
}
run_dashboard(theme_override, color, unicode, endpoints).await
}
}
}
async fn run_startup_command(command: Command) -> Result<(), StartupError> {
let manager = iota_process_manager::detect()
.await
.ok_or_else(|| StartupError::Other("no supported process manager detected".into()))?;
let status = match command {
Command::DaemonEnable { mode } => {
let mode = match mode.as_str() {
"socket" | "socket-activated" => iota_process_manager::StartupMode::SocketActivated,
"always-on" => iota_process_manager::StartupMode::AlwaysOn,
_ => {
return Err(StartupError::InvalidCommand(
"--mode must be socket or always-on".into(),
));
}
};
manager
.enable_startup(mode)
.await
.map_err(|e| StartupError::Other(e.to_string()))?
}
Command::DaemonDisableStartup => manager
.disable_startup()
.await
.map_err(|e| StartupError::Other(e.to_string()))?,
Command::DaemonStartupStatus => {
let status = manager
.iota_startup_status()
.await
.map_err(|e| StartupError::Other(e.to_string()))?;
println!("service active: {}", status.service.active);
println!("service enabled: {}", status.service.enabled);
println!("socket active: {}", status.socket.active);
println!("socket enabled: {}", status.socket.enabled);
println!("detected mode: {:?}", status.detected);
return Ok(());
}
Command::DaemonStart => {
let status = manager
.process_action(iota_process_manager::ProcessAction::Start)
.await
.map_err(|e| StartupError::Other(e.to_string()))?;
println!("Daemon started. detected mode: {:?}", status.detected);
return Ok(());
}
Command::DaemonRestartService => {
let status = manager
.process_action(iota_process_manager::ProcessAction::Restart)
.await
.map_err(|e| StartupError::Other(e.to_string()))?;
println!("Daemon restarted. detected mode: {:?}", status.detected);
return Ok(());
}
Command::DaemonStopService => {
let status = manager
.process_action(iota_process_manager::ProcessAction::Stop)
.await
.map_err(|e| StartupError::Other(e.to_string()))?;
println!("Daemon stopped. detected mode: {:?}", status.detected);
return Ok(());
}
_ => unreachable!(),
};
println!("deployment status: {:?}", status.detected);
Ok(())
}
async fn connect_available(
endpoints: &daemon_setup_flow::DaemonEndpoints,
) -> Result<Arc<IpcClient>, StartupError> {
match IpcClient::connect(&endpoints.local).await {
Ok(client) => Ok(client),
Err(local_error) => IpcClient::connect(&endpoints.system)
.await
.map_err(|system_error| {
if system_error.kind() == std::io::ErrorKind::TimedOut {
StartupError::IpcTimedOut(endpoints.system.clone())
} else if local_error.kind() == std::io::ErrorKind::PermissionDenied {
StartupError::SocketPermissionDenied(endpoints.local.clone())
} else {
StartupError::Other(format!(
"Could not connect to {} or {}: {local_error}; {system_error}",
endpoints.local.display(),
endpoints.system.display()
))
}
}),
}
}
async fn run_dashboard(
theme_override: Option<theme::ThemeName>,
color_policy: CapabilityPolicy,
unicode_policy: CapabilityPolicy,
endpoints: daemon_setup_flow::DaemonEndpoints,
) -> Result<(), StartupError> {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return Err(StartupError::Terminal(
"stdin and stdout must be interactive terminals".into(),
));
}
if std::env::var("TERM").as_deref() == Ok("dumb") {
return Err(StartupError::Terminal(
"TERM=dumb does not support the interactive dashboard".into(),
));
}
let stored_terminal = theme::UiConfig::load().unwrap_or_default();
let color_policy = match color_policy {
CapabilityPolicy::Auto => match stored_terminal.color {
theme::TerminalPolicy::Auto => CapabilityPolicy::Auto,
theme::TerminalPolicy::Always => CapabilityPolicy::Always,
theme::TerminalPolicy::Never => CapabilityPolicy::Never,
},
policy => policy,
};
let unicode_policy = match unicode_policy {
CapabilityPolicy::Auto => match stored_terminal.unicode {
theme::TerminalPolicy::Auto => CapabilityPolicy::Auto,
theme::TerminalPolicy::Always => CapabilityPolicy::Always,
theme::TerminalPolicy::Never => CapabilityPolicy::Never,
},
policy => policy,
};
let color_enabled = match color_policy {
CapabilityPolicy::Always => true,
CapabilityPolicy::Never => false,
CapabilityPolicy::Auto => {
std::env::var_os("NO_COLOR").is_none() && std::env::var("TERM").as_deref() != Ok("dumb")
}
};
let unicode_enabled = match unicode_policy {
CapabilityPolicy::Always => true,
CapabilityPolicy::Never => false,
CapabilityPolicy::Auto => std::env::var("LC_ALL")
.or_else(|_| std::env::var("LC_CTYPE"))
.or_else(|_| std::env::var("LANG"))
.map(|locale| {
let locale = locale.to_ascii_lowercase();
locale.contains("utf-8") || locale.contains("utf8")
})
.unwrap_or(false),
};
let truecolor_enabled = std::env::var("COLORTERM")
.map(|value| {
let value = value.to_ascii_lowercase();
value.contains("truecolor") || value.contains("24bit")
})
.unwrap_or(false);
let session = start_bootstrap_tui_with_theme(theme::resolve_with_terminal_profile(
theme::UiConfig::resolve_theme(theme_override),
color_enabled,
unicode_enabled,
truecolor_enabled,
))
.map_err(|error| StartupError::Terminal(error.to_string()))?;
let ui = session.ui();
let result = async {
let consent = iota_core::consent_state::check(ui.clone()).await
.map_err(StartupError::Consent)?;
if consent != (true, true) {
return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into()));
}
persist_dashboard_consent().await?;
let initial = tokio::select! {
result = connect_available(&endpoints) => result,
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
};
let context = match initial {
Ok(client) => daemon_setup_flow::ConnectionContext { ipc: client },
Err(_) => {
let system = tokio::select! {
manager = detect() => manager.ok_or(StartupError::SystemManagerUnavailable),
_ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled),
}?;
// A missing unit is expected before the system daemon has
// been installed. Keep bootstrap alive and expose that state
// as a disabled setup option instead of treating it as a
// fatal startup error.
let system_capability = tokio::select! {
status = system.iota_startup_status() => status.map(|_| system).map_err(map_process_manager_error),
_ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled),
};
let caps = daemon_setup_flow::Capabilities {
executable: daemon_executable(),
socket: writable_socket_path(&endpoints.local),
system: system_capability,
};
daemon_setup_flow::run(ui.clone(), &endpoints, caps).await?
}
};
let ipc = context.ipc.clone();
ipc.spawn_reconnector();
ui.attach_daemon(ipc).await;
let main_screen = MainScreen::new(ui.clone()).await;
ui.set_root_screen(Box::new(main_screen)).await;
ui.render().await.map_err(|error| StartupError::Terminal(error.to_string()))?;
ui.wait_for_shutdown().await;
Ok(())
}.await;
let render_failure = session.shutdown().await;
// Terminal restoration comes first; then stop IPC background tasks with
// their own bounded shutdown so a lost daemon cannot retain the process.
if let Some(ipc) = ui.ipc().await {
ipc.shutdown().await;
}
match (result, render_failure) {
(Err(error), _) => Err(error),
(Ok(()), Some(error)) => Err(StartupError::Terminal(error)),
(Ok(()), None) => Ok(()),
}
}
async fn persist_dashboard_consent() -> Result<(), StartupError> {
let docs = iota_terms::get_current_docs().await.ok_or_else(|| {
StartupError::Consent("Could not verify the current agreements after acceptance.".into())
})?;
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
.map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}")))?;
let mut record = iota_terms::consent::load(&paths.state_dir);
for document in [&docs.0, &docs.1, &docs.2] {
record.accept(document);
}
iota_terms::consent::save(&paths.state_dir, &record)
.map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))
}
fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError {
use iota_process_manager::ProcessManagerErrorKind::*;
match error.kind() {
PermissionDenied => StartupError::SystemPermissionDenied(error.to_string()),
TimedOut => StartupError::SystemCommandTimedOut(error.to_string()),
_ => StartupError::Other(error.to_string()),
}
}
fn daemon_executable() -> Result<std::path::PathBuf, StartupError> {
let candidate = iota_paths::daemon_executable();
if candidate.is_file() {
Ok(candidate)
} else {
Err(StartupError::DaemonExecutableMissing(candidate))
}
}
fn writable_socket_path(path: &Path) -> Result<(), StartupError> {
let parent = path.parent().ok_or_else(|| {
StartupError::LocalSocketNotWritable(
path.to_path_buf(),
std::io::Error::new(std::io::ErrorKind::InvalidInput, "socket has no parent"),
)
})?;
std::fs::create_dir_all(parent)
.map_err(|e| StartupError::LocalSocketNotWritable(path.to_path_buf(), e))?;
let probe = parent.join(format!(".iota-write-probe-{}", std::process::id()));
std::fs::File::create(&probe)
.map_err(|e| StartupError::LocalSocketNotWritable(path.to_path_buf(), e))?;
let _ = std::fs::remove_file(probe);
Ok(())
}
fn print_help() {
let color = cli_color::ColorConfig::new();
println!(
"{}",
cli_color::heading(&color, "Iota Operator Console")
);
println!();
println!("Usage: iota [OPTIONS] [COMMAND]");
println!();
println!(
"{}",
cli_color::info(&color, "Commands:")
);
println!(" (no command) Launch the interactive dashboard");
println!(" status Show daemon status");
println!(" tasks List active tasks");
println!(" users list List all users");
println!(" users show <ID> Show user details");
println!(" users add <NAME> Create a new user");
println!(" users remove <ID> Remove a user (requires --yes)");
println!(" omikron status Show Omikron connection status");
println!(" omikron reconnect Reconnect to Omikron");
println!(" identity rotate Rotate identity keys (requires --yes)");
println!(" config get Show current configuration");
println!(" config set <KEY> <VAL> Set a configuration value");
println!(" config reload Reload configuration");
println!(" components Show component health");
println!(" logs [--limit N] Show recent log entries");
println!(" update check Check for updates");
println!(" community list List communities");
println!(" terms status Show terms acceptance status");
println!(" terms show <DOC> Show a terms document");
println!(" terms accept Accept required terms");
println!(" daemon restart Restart the daemon (requires --yes)");
println!(" daemon stop Stop the daemon (requires --yes)");
println!(" daemon enable Enable daemon at startup");
println!(" daemon disable-startup Disable daemon at startup");
println!(" daemon startup-status Show startup configuration");
println!(" daemon start Start the daemon");
println!(" daemon restart-service Restart the daemon service");
println!(" daemon stop-service Stop the daemon service");
println!(" daemon install Install from a bundle");
println!(" help Show this help message");
println!(" completions <SHELL> Generate shell completions");
println!(" man Show the man page");
println!();
println!(
"{}",
cli_color::info(&color, "Options:")
);
println!(" --theme <THEME> Theme: monospace, binary, ansi, surface");
println!(" --output <FORMAT> Output format: text, json, yaml, table");
println!(" --color <WHEN> Color: auto, always, never");
println!(" --unicode <WHEN> Unicode: auto, always, never");
println!(" --no-color Disable colored output");
println!(" --yes, -y Confirm destructive operations");
println!(" -h, --help Show help");
println!(" -V, --version Show version");
println!();
println!(
"{}",
cli_color::info(&color, "Examples:")
);
println!(" iota Launch the interactive dashboard");
println!(" iota status Show daemon status");
println!(" iota users list --output=json List users in JSON format");
println!(" iota users add alice Create a user named 'alice'");
println!(" iota users remove 42 --yes Remove user 42");
println!(" iota config get --output=yaml Show config in YAML format");
println!(" iota logs --limit 50 Show last 50 log entries");
println!(" iota completions bash Generate bash completions");
println!();
println!(
"{}",
cli_color::info(&color, "Exit Codes:")
);
println!(" 0 Success");
println!(" 1 General error");
println!(" 2 Invalid command or arguments");
println!(" 130 Interrupted (Ctrl+C)");
println!();
println!(
"{}",
cli_color::muted(&color, "Environment Variables:")
);
println!(" NO_COLOR Disable colored output when set");
println!(" TERM Terminal type (dumb disables colors)");
println!(" IOTA_THEME Default theme override");
}
fn print_completions(shell: &str) -> Result<(), StartupError> {
let command_paths = CliInvocation::command_paths();
let words = command_paths
.iter()
.flat_map(|command| command.split_whitespace())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()
.join(" ");
match shell {
"bash" => println!(
"_iota() {{ local words='{} --help --version --theme --output --color --unicode --yes --mode --bundle --operator'; COMPREPLY=( $(compgen -W \"$words\" -- \"${{COMP_WORDS[COMP_CWORD]}}\") ); }}\ncomplete -F _iota iota",
words
),
"zsh" => println!(
"#compdef iota\n_arguments '1:command:({})' '*::argument:->args'",
words
),
"fish" => {
for command in words.split_whitespace() {
println!("complete -c iota -f -a '{command}'");
}
}
_ => {
return Err(StartupError::InvalidCommand(
"completion shell must be bash, zsh, or fish".into(),
));
}
}
Ok(())
}
fn print_man_page() {
println!(".TH IOTA 1");
println!(".SH NAME\n iota \\- Iota operator console");
println!(".SH SYNOPSIS\n.B iota\n[global options] [command]");
println!(".SH DESCRIPTION");
println!("Iota is the operator console for managing Iota daemon instances.");
println!("It provides both an interactive dashboard and headless CLI commands.");
println!(".SH COMMANDS");
for command in CliInvocation::command_paths() {
println!(".TP\n.B {command}");
}
println!(".SH GLOBAL OPTIONS");
println!(".TP\n.B --output text|json|yaml|table");
println!("Set the output format for headless commands.");
println!(".TP\n.B --color auto|always|never");
println!("Control colored output.");
println!(".TP\n.B --unicode auto|always|never");
println!("Control Unicode character rendering.");
println!(".TP\n.B --yes, -y");
println!("Confirm destructive operations without prompting.");
println!(".SH EXIT CODES");
println!(".TP\n.B 0");
println!("Success");
println!(".TP\n.B 1");
println!("General error");
println!(".TP\n.B 2");
println!("Invalid command or arguments");
println!(".TP\n.B 130");
println!("Interrupted (Ctrl+C)");
println!(".SH EXAMPLES");
println!(".TP\n.B iota");
println!("Launch the interactive dashboard");
println!(".TP\n.B iota status");
println!("Show daemon status");
println!(".TP\n.B iota users list --output=json");
println!("List users in JSON format");
println!(".TP\n.B iota users add alice");
println!("Create a user named 'alice'");
println!(".SH ENVIRONMENT");
println!(".TP\n.B NO_COLOR");
println!("Disable colored output when set");
println!(".TP\n.B TERM");
println!("Terminal type (dumb disables colors)");
println!(".TP\n.B IOTA_THEME");
println!("Default theme override");
}
async fn run_command(
ipc: Arc<IpcClient>,
command: Command,
output: OutputFormat,
) -> Result<(), StartupError> {
let color = ColorConfig::new();
let request = match command {
Command::Status => LocalRequest::GetStatus,
Command::Tasks => LocalRequest::ListTasks,
Command::UsersList => LocalRequest::ListUsers,
Command::UsersShow { user_id } => LocalRequest::GetUser { user_id },
Command::UsersAdd { username } => LocalRequest::CreateUser { username },
Command::UsersRemove {
user_id,
confirmed: true,
} => LocalRequest::RemoveUser { user_id },
Command::UsersImport { username } => LocalRequest::ImportUser { username },
Command::OmikronReconnect => LocalRequest::ReconnectOmikron,
Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity,
Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity,
Command::DaemonRestart { confirmed: true } => LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Restart,
},
Command::DaemonStop { confirmed: true } => LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Stop,
},
Command::DaemonDaemonStatus => LocalRequest::GetDaemonStatus,
Command::OmikronStatus => LocalRequest::GetOmikronStatus,
Command::ConfigGet => LocalRequest::GetConfig,
Command::ConfigSet { key, value } => LocalRequest::SetConfig { key, value },
Command::ConfigReload => LocalRequest::ReloadConfig,
Command::Components => LocalRequest::ListComponents,
Command::Logs { limit } => LocalRequest::GetLogs { limit },
Command::UpdateCheck => LocalRequest::CheckUpdate,
Command::CommunityList => LocalRequest::ListCommunities,
Command::UsersRemove {
confirmed: false, ..
}
| Command::IdentityRotate { confirmed: false }
| Command::RegenerateKeys { confirmed: false }
| Command::DaemonRestart { confirmed: false }
| Command::DaemonStop { confirmed: false } => {
return Err(StartupError::InvalidCommand(
"Refusing destructive command without --yes.".into(),
));
}
Command::Dashboard
| Command::Help
| Command::Version
| Command::Completions { .. }
| Command::ManPage
| Command::Install { .. }
| Command::TermsStatus { .. }
| Command::TermsShow { .. }
| Command::TermsAccept { .. }
| Command::DaemonEnable { .. }
| Command::DaemonDisableStartup
| Command::DaemonStartupStatus
| Command::DaemonStart
| Command::DaemonRestartService
| Command::DaemonStopService => {
return Err(StartupError::InvalidCommand(
"Command cannot be run headlessly.".into(),
));
}
};
match ipc
.send_request(request)
.await
.map_err(|e| StartupError::Other(e.to_string()))?
{
ResponseResult::Ok(payload) => {
if !matches!(output, OutputFormat::Text) {
return render_structured(&payload, output);
}
match payload {
ResponsePayload::Status(status) => {
let phase_color = if status.degraded_reason.is_some() {
cli_color::WARNING
} else {
cli_color::SUCCESS
};
print!(
"{} {}",
cli_color::info(&color, "Phase:"),
color.colorize(&status.phase, phase_color)
);
if !status.tasks.is_empty() {
print!(
", {} {}",
cli_color::info(&color, "Tasks:"),
status.tasks.join(", ")
);
}
if let Some(reason) = status.degraded_reason {
print!(", {}: {}", cli_color::warning(&color, "Degraded"), reason);
}
println!();
}
ResponsePayload::Tasks(tasks) => {
if tasks.is_empty() {
println!("{}", cli_color::muted(&color, "No active tasks."));
} else {
for task in &tasks {
println!("{}", task.name);
}
}
}
ResponsePayload::Users(users) => {
if users.is_empty() {
println!("{}", cli_color::muted(&color, "No users."));
} else {
for user in &users {
println!(
"{} ({})",
cli_color::heading(&color, &user.username),
user.user_id
);
}
}
}
ResponsePayload::UserCreated { user_id, username } => {
println!(
"{} {} ({})",
cli_color::success(&color, "Created user"),
cli_color::heading(&color, &username),
user_id
);
}
ResponsePayload::UserRemoved { user_id } => {
println!(
"{} {}",
cli_color::warning(&color, "Removed user"),
user_id
);
}
ResponsePayload::Acknowledged { message } => {
println!("{}", message);
}
ResponsePayload::DaemonStatus(status) => {
println!("{}", status.formatted);
}
ResponsePayload::Config(config) => {
println!("{}", config.yaml);
}
ResponsePayload::OmikronStatus(status) => {
println!(
"{}: {}",
cli_color::info(&color, "Connected"),
status.connected
);
if let Some(id) = status.iota_id {
println!(
"{}: {}",
cli_color::info(&color, "Iota ID"),
id
);
}
}
ResponsePayload::Components(components) => {
if components.is_empty() {
println!(
"{}",
cli_color::muted(&color, "No component health data available.")
);
} else {
for comp in &components {
let (status_str, style) = match comp.status {
iota_ipc::HealthStatus::Healthy => {
("healthy", cli_color::SUCCESS)
}
iota_ipc::HealthStatus::Degraded => {
("degraded", cli_color::WARNING)
}
iota_ipc::HealthStatus::Failed => ("failed", cli_color::ERROR),
};
let suffix = comp
.message
.as_deref()
.map(|m| format!(" ({m})"))
.unwrap_or_default();
println!(
"{:?}: {}{}",
comp.id,
color.colorize(status_str, style),
suffix
);
}
}
}
ResponsePayload::UserDetail(user) => {
println!(
"{}: {} ({})",
cli_color::info(&color, "User"),
cli_color::heading(&color, &user.username),
user.user_id
);
if let Some(ref name) = user.display_name {
println!("Display Name: {name}");
}
println!("Created At: {}", user.created_at);
if !user.trusted_apps.is_empty() {
println!("Trusted Apps: {}", user.trusted_apps.join(", "));
}
}
ResponsePayload::LogEntries(logs) => {
for entry in &logs.entries {
let ts = entry.timestamp_ms;
let (level, style) = if entry.is_error {
("ERR", cli_color::ERROR)
} else {
("INF", cli_color::INFO)
};
println!(
"[{ts}] {} {}: {}",
color.colorize(level, style),
entry.sender,
entry.message
);
}
}
ResponsePayload::UpdateStatus(status) => {
if status.available {
println!(
"{}",
cli_color::success(&color, "Update available.")
);
} else {
println!(
"{}",
cli_color::info(&color, "Up to date.")
);
}
}
ResponsePayload::Communities(communities) => {
if communities.is_empty() {
println!("{}", cli_color::muted(&color, "No communities."));
} else {
for c in &communities {
println!(
"{} ({})",
cli_color::heading(&color, &c.title),
c.name
);
}
}
}
}
Ok(())
}
ResponseResult::Error(code) => Err(StartupError::Other(format!(
"Daemon request failed: {code}"
))),
}
}
/// The IPC payload is the versioned, tagged schema used by headless clients.
/// Text remains an operator-oriented presentation; JSON and YAML must never
/// require consumers to parse it.
fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result<(), StartupError> {
match output {
OutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(payload).map_err(|error| StartupError::Other(format!(
"Cannot encode JSON output: {error}"
)))?
),
OutputFormat::Yaml => print!(
"{}",
serde_yaml::to_string(payload).map_err(|error| StartupError::Other(format!(
"Cannot encode YAML output: {error}"
)))?
),
OutputFormat::Table => render_table(payload),
OutputFormat::Text => unreachable!(),
}
Ok(())
}
fn render_table(payload: &ResponsePayload) {
match payload {
ResponsePayload::Users(users) => {
if users.is_empty() {
println!("No users.");
return;
}
println!("{:<8} {}", "ID", "USERNAME");
println!("{:<8} {}", "--------", "--------");
for user in users {
println!("{:<8} {}", user.user_id, user.username);
}
}
ResponsePayload::Tasks(tasks) => {
if tasks.is_empty() {
println!("No active tasks.");
return;
}
println!("{}", "NAME");
println!("{}", "--------");
for task in tasks {
println!("{}", task.name);
}
}
ResponsePayload::Components(components) => {
if components.is_empty() {
println!("No component health data available.");
return;
}
println!("{:<20} {:<10} {}", "COMPONENT", "STATUS", "MESSAGE");
println!("{:<20} {:<10} {}", "--------", "--------", "--------");
for comp in components {
let status_str = match comp.status {
iota_ipc::HealthStatus::Healthy => "healthy",
iota_ipc::HealthStatus::Degraded => "degraded",
iota_ipc::HealthStatus::Failed => "failed",
};
let message = comp.message.as_deref().unwrap_or("-");
println!("{:<20} {:<10} {}", format!("{:?}", comp.id), status_str, message);
}
}
ResponsePayload::Communities(communities) => {
if communities.is_empty() {
println!("No communities.");
return;
}
println!("{:<20} {}", "NAME", "TITLE");
println!("{:<20} {}", "--------", "--------");
for c in communities {
println!("{:<20} {}", c.name, c.title);
}
}
ResponsePayload::LogEntries(logs) => {
if logs.entries.is_empty() {
println!("No log entries.");
return;
}
println!("{:<20} {:<6} {:<12} {}", "TIMESTAMP", "LEVEL", "SENDER", "MESSAGE");
println!("{:<20} {:<6} {:<12} {}", "--------", "--------", "--------", "--------");
for entry in &logs.entries {
let level = if entry.is_error { "ERR" } else { "INF" };
println!(
"{:<20} {:<6} {:<12} {}",
entry.timestamp_ms, level, entry.sender, entry.message
);
}
}
ResponsePayload::Status(status) => {
println!("{:<15} {}", "Field", "Value");
println!("{:<15} {}", "--------", "--------");
println!("{:<15} {}", "Phase", status.phase);
if !status.tasks.is_empty() {
println!("{:<15} {}", "Tasks", status.tasks.join(", "));
}
if let Some(reason) = &status.degraded_reason {
println!("{:<15} {}", "Degraded", reason);
}
}
ResponsePayload::DaemonStatus(status) => {
println!("{}", status.formatted);
}
ResponsePayload::Config(config) => {
println!("{}", config.yaml);
}
ResponsePayload::OmikronStatus(status) => {
println!("{:<15} {}", "Field", "Value");
println!("{:<15} {}", "--------", "--------");
println!("{:<15} {}", "Connected", status.connected);
if let Some(id) = &status.iota_id {
println!("{:<15} {}", "Iota ID", id);
}
}
ResponsePayload::UpdateStatus(status) => {
println!("{:<15} {}", "Field", "Value");
println!("{:<15} {}", "--------", "--------");
println!("{:<15} {}", "Available", status.available);
}
ResponsePayload::UserCreated { user_id, username } => {
println!("Created user {} ({})", username, user_id);
}
ResponsePayload::UserRemoved { user_id } => {
println!("Removed user {}", user_id);
}
ResponsePayload::Acknowledged { message } => {
println!("{}", message);
}
ResponsePayload::UserDetail(user) => {
println!("{:<15} {}", "Field", "Value");
println!("{:<15} {}", "--------", "--------");
println!("{:<15} {}", "Username", user.username);
println!("{:<15} {}", "User ID", user.user_id);
if let Some(ref name) = user.display_name {
println!("{:<15} {}", "Display Name", name);
}
println!("{:<15} {}", "Created At", user.created_at);
if !user.trusted_apps.is_empty() {
println!("{:<15} {}", "Trusted Apps", user.trusted_apps.join(", "));
}
}
}
}