[Wip] CLI & Daemon

This commit is contained in:
Alex 2026-07-25 18:23:36 +02:00
commit 6a535099bb
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
44 changed files with 4417 additions and 303 deletions

View file

@ -12,3 +12,6 @@ iota-process-manager = { path = "../iota-process-manager" }
iota-paths = { path = "../iota-paths" }
tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
serde_json = "1"
serde_yaml = "0.9"
clap = { version = "4.5", features = ["derive"] }

View file

@ -1,15 +1,86 @@
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind};
use iota_cli::theme::ThemeName;
#[derive(Debug)]
pub struct CliInvocation {
pub theme_override: Option<ThemeName>,
pub output: OutputFormat,
pub color: CapabilityPolicy,
pub unicode: CapabilityPolicy,
pub command: Command,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum CapabilityPolicy {
Auto,
Always,
Never,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
Text,
Json,
Yaml,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum CliTheme { Monospace, Binary, Ansi, Surface }
impl From<CliTheme> for ThemeName {
fn from(value: CliTheme) -> Self {
match value { CliTheme::Monospace => Self::Monospace, CliTheme::Binary => Self::Binary, CliTheme::Ansi => Self::Ansi, CliTheme::Surface => Self::Surface }
}
}
#[derive(Parser, Debug)]
#[command(name = "iota", version, about = "Iota operator console", arg_required_else_help = false)]
struct Cli {
#[arg(long, global = true, value_enum)] theme: Option<CliTheme>,
#[arg(long, global = true, value_enum, default_value_t = OutputFormat::Text)] output: OutputFormat,
#[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] color: CapabilityPolicy,
#[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] unicode: CapabilityPolicy,
#[arg(long, global = true)] no_color: bool,
#[command(subcommand)] command: Option<CliCommand>,
}
#[derive(Subcommand, Debug)]
enum CliCommand {
Status, Tasks,
Users(UsersArgs), Omikron(OmikronArgs), Identity(IdentityArgs), Daemon(DaemonArgs), Config(ConfigArgs),
RegenerateKeys { #[arg(long)] yes: bool },
Components,
Logs { #[arg(long, default_value_t = 100)] limit: usize },
Update(UpdateArgs),
Community(CommunityArgs),
Completions { shell: String },
Man,
}
#[derive(Args, Debug)] struct UsersArgs { #[command(subcommand)] action: UsersAction }
#[derive(Subcommand, Debug)] enum UsersAction { List, Show { user_id: i64 }, Add { username: String }, Remove { user_id: i64, #[arg(long)] yes: bool }, Import { username: String } }
#[derive(Args, Debug)] struct OmikronArgs { #[command(subcommand)] action: OmikronAction }
#[derive(Subcommand, Debug)] enum OmikronAction { Reconnect, Status }
#[derive(Args, Debug)] struct IdentityArgs { #[command(subcommand)] action: IdentityAction }
#[derive(Subcommand, Debug)] enum IdentityAction { Rotate { #[arg(long)] yes: bool } }
#[derive(Args, Debug)] struct ConfigArgs { #[command(subcommand)] action: ConfigAction }
#[derive(Subcommand, Debug)] enum ConfigAction { Get, Set { key: String, value: String }, Reload }
#[derive(Args, Debug)] struct DaemonArgs { #[command(subcommand)] action: DaemonAction }
#[derive(Subcommand, Debug)] enum DaemonAction {
Restart { #[arg(long)] yes: bool }, Stop { #[arg(long)] yes: bool },
Enable { #[arg(long, value_parser = ["socket", "always-on"])] mode: String }, DisableStartup, Status, StartupStatus, Start, RestartService, StopService,
Install { #[arg(long)] bundle: String, #[arg(long)] operator: Option<String> },
}
#[derive(Args, Debug)] struct UpdateArgs { #[command(subcommand)] action: UpdateAction }
#[derive(Subcommand, Debug)] enum UpdateAction { Check }
#[derive(Args, Debug)] struct CommunityArgs { #[command(subcommand)] action: CommunityAction }
#[derive(Subcommand, Debug)] enum CommunityAction { List }
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
Dashboard,
Help,
Version,
Completions { shell: String },
ManPage,
Install {
bundle: String,
operator: Option<String>,
@ -17,95 +88,125 @@ pub enum Command {
Status,
Tasks,
UsersList,
UsersShow { user_id: i64 },
UsersAdd {
username: String,
},
UsersRemove {
user_id: i64,
confirmed: bool,
},
UsersImport { username: String },
OmikronReconnect,
IdentityRotate {
confirmed: bool,
},
DaemonRestart {
confirmed: bool,
},
DaemonStop {
confirmed: bool,
},
DaemonStopProcess,
DaemonEnable {
mode: String,
},
DaemonDisableStartup,
DaemonDaemonStatus,
DaemonStartupStatus,
DaemonStart,
DaemonRestartService,
DaemonStopService,
ConfigGet,
ConfigSet { key: String, value: String },
ConfigReload,
OmikronStatus,
RegenerateKeys {
confirmed: bool,
},
Components,
Logs { limit: usize },
UpdateCheck,
CommunityList,
}
impl CliInvocation {
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
let mut theme_override = None;
let mut command = Vec::new();
let mut args = args.into_iter();
while let Some(argument) = args.next() {
if argument == "--theme" {
let value = args.next().ok_or_else(|| {
format!(
"--theme requires a value ({})",
ThemeName::supported_names()
)
})?;
theme_override = Some(value.parse()?);
} else if let Some(value) = argument.strip_prefix("--theme=") {
theme_override = Some(value.parse()?);
} else {
command.push(argument);
}
let args = args.into_iter().collect::<Vec<_>>();
if args.as_slice() == ["help"] {
return Ok(Self::special(Command::Help));
}
let command = match command.as_slice() {
[] => Command::Dashboard,
[help] if help == "help" || help == "--help" => Command::Help,
[status] if status == "status" => Command::Status,
[tasks] if tasks == "tasks" => Command::Tasks,
[noun, verb] if noun == "users" && verb == "list" => Command::UsersList,
[noun, verb, flag] if noun == "daemon" && verb == "restart" => Command::DaemonRestart {
confirmed: flag == "--yes",
let parsed = Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| {
match error.kind() {
ErrorKind::DisplayHelp => return "__help__".to_owned(),
ErrorKind::DisplayVersion => return "__version__".to_owned(),
_ => error.to_string(),
}
});
let parsed = match parsed {
Ok(parsed) => parsed,
Err(marker) if marker == "__help__" => return Ok(Self::special(Command::Help)),
Err(marker) if marker == "__version__" => return Ok(Self::special(Command::Version)),
Err(error) => return Err(error),
};
let command = match parsed.command {
None => Command::Dashboard,
Some(CliCommand::Status) => Command::Status,
Some(CliCommand::Tasks) => Command::Tasks,
Some(CliCommand::Components) => Command::Components,
Some(CliCommand::Completions { shell }) => Command::Completions { shell },
Some(CliCommand::Man) => Command::ManPage,
Some(CliCommand::Users(users)) => match users.action { UsersAction::List => Command::UsersList, UsersAction::Show { user_id } => Command::UsersShow { user_id }, UsersAction::Add { username } => Command::UsersAdd { username }, UsersAction::Remove { user_id, yes } => Command::UsersRemove { user_id, confirmed: yes }, UsersAction::Import { username } => Command::UsersImport { username } },
Some(CliCommand::Omikron(omikron)) => match omikron.action { OmikronAction::Reconnect => Command::OmikronReconnect, OmikronAction::Status => Command::OmikronStatus },
Some(CliCommand::Identity(identity)) => match identity.action { IdentityAction::Rotate { yes } => Command::IdentityRotate { confirmed: yes } },
Some(CliCommand::Config(config)) => match config.action { ConfigAction::Get => Command::ConfigGet, ConfigAction::Set { key, value } => Command::ConfigSet { key, value }, ConfigAction::Reload => Command::ConfigReload },
Some(CliCommand::RegenerateKeys { yes }) => Command::RegenerateKeys { confirmed: yes },
Some(CliCommand::Logs { limit }) => Command::Logs { limit },
Some(CliCommand::Update(update)) => match update.action { UpdateAction::Check => Command::UpdateCheck },
Some(CliCommand::Community(community)) => match community.action { CommunityAction::List => Command::CommunityList },
Some(CliCommand::Daemon(daemon)) => match daemon.action {
DaemonAction::Restart { yes } => Command::DaemonRestart { confirmed: yes }, DaemonAction::Stop { yes } => Command::DaemonStop { confirmed: yes },
DaemonAction::Enable { mode } => Command::DaemonEnable { mode }, DaemonAction::DisableStartup => Command::DaemonDisableStartup,
DaemonAction::Status => Command::DaemonDaemonStatus, DaemonAction::StartupStatus => Command::DaemonStartupStatus,
DaemonAction::Start => Command::DaemonStart, DaemonAction::RestartService => Command::DaemonRestartService, DaemonAction::StopService => Command::DaemonStopService,
DaemonAction::Install { bundle, operator } => Command::Install { bundle, operator },
},
[noun, verb] if noun == "daemon" && verb == "restart" => {
Command::DaemonRestart { confirmed: false }
}
[noun, verb, flag] if noun == "daemon" && verb == "stop" => Command::DaemonStop {
confirmed: flag == "--yes",
},
[noun, verb] if noun == "daemon" && verb == "stop" => {
Command::DaemonStop { confirmed: false }
}
[noun, verb] if noun == "daemon" && verb == "stop-process" => {
Command::DaemonStopProcess
}
[noun, verb] if noun == "daemon" && verb == "disable-startup" => {
Command::DaemonDisableStartup
}
[noun, verb] if noun == "daemon" && verb == "status" => Command::DaemonDaemonStatus,
[noun, verb, flag, mode]
if noun == "daemon" && verb == "enable" && flag == "--mode" =>
{
Command::DaemonEnable { mode: mode.clone() }
}
[noun, verb, bundle_flag, bundle]
if noun == "daemon" && verb == "install" && bundle_flag == "--bundle" =>
{
Command::Install {
bundle: bundle.clone(),
operator: None,
}
}
[noun, verb, bundle_flag, bundle, operator_flag, operator]
if noun == "daemon"
&& verb == "install"
&& bundle_flag == "--bundle"
&& operator_flag == "--operator" =>
{
Command::Install {
bundle: bundle.clone(),
operator: Some(operator.clone()),
}
}
_ => return Err("Unknown command. Run `iota --help`.".into()),
};
Ok(Self {
theme_override,
theme_override: parsed.theme.map(Into::into),
output: parsed.output,
color: if parsed.no_color { CapabilityPolicy::Never } else { parsed.color },
unicode: parsed.unicode,
command,
})
}
fn special(command: Command) -> Self {
Self { theme_override: None, output: OutputFormat::Text, color: CapabilityPolicy::Auto, unicode: CapabilityPolicy::Auto, command }
}
pub fn help_text() -> String {
Cli::command().render_long_help().to_string()
}
pub fn command_paths() -> Vec<String> {
fn collect(command: &clap::Command, prefix: &str, paths: &mut Vec<String>) {
for subcommand in command.get_subcommands() {
let path = if prefix.is_empty() {
subcommand.get_name().to_owned()
} else {
format!("{prefix} {}", subcommand.get_name())
};
if subcommand.get_subcommands().next().is_some() {
collect(subcommand, &path, paths);
} else {
paths.push(path);
}
}
}
let command = Cli::command();
let mut paths = Vec::new();
collect(&command, "", &mut paths);
paths
}
}
#[cfg(test)]
@ -126,6 +227,55 @@ mod tests {
assert!(error.contains(ThemeName::supported_names()));
}
#[test]
fn parses_structured_output_as_a_global_option() {
let invocation =
CliInvocation::parse(["users".into(), "list".into(), "--output=json".into()]).unwrap();
assert_eq!(invocation.output, OutputFormat::Json);
assert_eq!(invocation.command, Command::UsersList);
}
#[test]
fn parses_terminal_capability_overrides() {
let invocation = CliInvocation::parse([
"--color=never".into(),
"--unicode".into(),
"always".into(),
])
.unwrap();
assert_eq!(invocation.color, CapabilityPolicy::Never);
assert_eq!(invocation.unicode, CapabilityPolicy::Always);
assert_eq!(invocation.command, Command::Dashboard);
}
#[test]
fn no_color_is_a_compatible_alias() {
let invocation = CliInvocation::parse(["--no-color".into()]).unwrap();
assert_eq!(invocation.color, CapabilityPolicy::Never);
}
#[test]
fn supports_standard_help_and_version_flags() {
assert_eq!(
CliInvocation::parse(["-h".into()]).unwrap().command,
Command::Help
);
assert_eq!(
CliInvocation::parse(["--version".into()]).unwrap().command,
Command::Version
);
}
#[test]
fn command_schema_drives_help_and_completion_paths() {
let paths = CliInvocation::command_paths();
assert!(paths.contains(&"users remove".to_owned()));
assert!(paths.contains(&"daemon install".to_owned()));
let help = CliInvocation::help_text();
assert!(help.contains("users"));
assert!(help.contains("--output"));
}
#[test]
fn parses_install_operator_without_raw_slice_matching() {
let invocation = CliInvocation::parse([
@ -151,4 +301,92 @@ mod tests {
let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap();
assert_eq!(invocation.command, Command::DaemonStop { confirmed: false });
}
#[test]
fn parses_users_add() {
let invocation =
CliInvocation::parse(["users".into(), "add".into(), "alice".into()]).unwrap();
assert_eq!(
invocation.command,
Command::UsersAdd {
username: "alice".into()
}
);
}
#[test]
fn rejects_malformed_users_add_shape() {
assert!(
CliInvocation::parse([
"users".into(),
"incorrect".into(),
"add".into(),
"alice".into()
])
.is_err()
);
}
#[test]
fn rejects_unknown_destructive_option() {
assert!(CliInvocation::parse(["daemon".into(), "stop".into(), "--later".into()]).is_err());
}
#[test]
fn parses_users_remove_without_confirmation() {
let invocation =
CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap();
assert_eq!(
invocation.command,
Command::UsersRemove {
user_id: 42,
confirmed: false,
}
);
}
#[test]
fn parses_users_remove_with_confirmation() {
let invocation =
CliInvocation::parse(["users".into(), "remove".into(), "42".into(), "--yes".into()])
.unwrap();
assert_eq!(
invocation.command,
Command::UsersRemove {
user_id: 42,
confirmed: true,
}
);
}
#[test]
fn parses_omikron_reconnect() {
let invocation = CliInvocation::parse(["omikron".into(), "reconnect".into()]).unwrap();
assert_eq!(invocation.command, Command::OmikronReconnect);
}
#[test]
fn parses_identity_rotate_requires_yes() {
let invocation = CliInvocation::parse(["identity".into(), "rotate".into()]).unwrap();
assert_eq!(
invocation.command,
Command::IdentityRotate { confirmed: false }
);
let invocation =
CliInvocation::parse(["identity".into(), "rotate".into(), "--yes".into()]).unwrap();
assert_eq!(
invocation.command,
Command::IdentityRotate { confirmed: true }
);
}
#[test]
fn rejects_daemon_ping_until_protocol_supports_a_ping_contract() {
assert!(CliInvocation::parse(["daemon".into(), "ping".into()]).is_err());
}
#[test]
fn rejects_daemon_diagnostics_until_protocol_supports_diagnostics() {
assert!(CliInvocation::parse(["daemon".into(), "diagnostics".into()]).is_err());
}
}

View file

@ -2,7 +2,7 @@ use iota_cli::{
ipc_client::IpcClient, screens::main_screen::MainScreen, theme,
ui::start_bootstrap_tui_with_theme,
};
use iota_ipc::{LocalRequest, ResponseResult};
use iota_ipc::{LocalRequest, ResponsePayload, ResponseResult};
use iota_process_manager::detect;
use std::{path::Path, process::ExitCode, sync::Arc};
@ -11,7 +11,7 @@ mod daemon_setup_flow;
mod local_daemon;
mod startup_error;
use cli_args::{CliInvocation, Command};
use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat};
use startup_error::StartupError;
#[tokio::main(flavor = "multi_thread")]
@ -30,6 +30,13 @@ async fn main() -> ExitCode {
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
@ -57,11 +64,20 @@ async fn run() -> Result<(), StartupError> {
system: system_endpoint,
};
match invocation.command {
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::Install { bundle, operator } => {
iota_installer::install_linux_bundle_with_operator(
Path::new(&bundle),
@ -72,7 +88,12 @@ async fn run() -> Result<(), StartupError> {
command => {
if matches!(
command,
Command::DaemonEnable { .. } | Command::DaemonDisableStartup
Command::DaemonEnable { .. }
| Command::DaemonDisableStartup
| Command::DaemonStartupStatus
| Command::DaemonStart
| Command::DaemonRestartService
| Command::DaemonStopService
) {
return run_startup_command(command).await;
}
@ -87,9 +108,9 @@ async fn run() -> Result<(), StartupError> {
result = connect_available(&endpoints) => result?,
_ = tokio::signal::ctrl_c() => return Err(StartupError::Cancelled),
};
return run_command(ipc, command).await;
return run_command(ipc, command, output).await;
}
run_dashboard(invocation.theme_override, endpoints).await
run_dashboard(theme_override, color, unicode, endpoints).await
}
}
}
@ -118,6 +139,42 @@ async fn run_startup_command(command: Command) -> Result<(), StartupError> {
.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);
@ -149,6 +206,8 @@ async fn connect_available(
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;
@ -162,9 +221,55 @@ async fn run_dashboard(
"TERM=dumb does not support the interactive dashboard".into(),
));
}
let session = start_bootstrap_tui_with_theme(theme::resolve(theme::UiConfig::resolve_theme(
theme_override,
)))
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 {
@ -258,32 +363,105 @@ fn writable_socket_path(path: &Path) -> Result<(), StartupError> {
}
fn print_help() {
println!(
"Iota operator console\n\nUsage:\n iota [--theme <name>] Open the dashboard\n iota daemon install --bundle <release.zip> [--operator USER]\n iota status Print daemon readiness and tasks\n iota tasks Print active tasks\n iota users list List users\n iota daemon restart --yes\n iota daemon stop --yes\n\nRun the dashboard in an interactive terminal to review required terms."
);
println!("{}", CliInvocation::help_text());
}
async fn run_command(ipc: Arc<IpcClient>, command: Command) -> Result<(), StartupError> {
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 COMMANDS");
for command in CliInvocation::command_paths() {
println!(".TP\n.B {command}");
}
println!(".SH GLOBAL OPTIONS");
println!(".TP\n.B --output text|json|yaml");
println!(".TP\n.B --color auto|always|never");
println!(".TP\n.B --unicode auto|always|never");
}
async fn run_command(
ipc: Arc<IpcClient>,
command: Command,
output: OutputFormat,
) -> Result<(), StartupError> {
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::DaemonStopProcess => LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Stop,
},
Command::DaemonDaemonStatus => LocalRequest::GetDaemonStatus,
Command::DaemonRestart { confirmed: false } | Command::DaemonStop { confirmed: false } => {
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::DaemonEnable { .. } | Command::DaemonDisableStartup
| Command::DaemonStartupStatus | Command::DaemonStart
| Command::DaemonRestartService | Command::DaemonStopService => {
return Err(StartupError::InvalidCommand(
"Command cannot be run headlessly.".into(),
));
@ -294,12 +472,139 @@ async fn run_command(ipc: Arc<IpcClient>, command: Command) -> Result<(), Startu
.await
.map_err(|e| StartupError::Other(e.to_string()))?
{
ResponseResult::Ok(message) => {
println!("{message}");
ResponseResult::Ok(payload) => {
if !matches!(output, OutputFormat::Text) {
return render_structured(&payload, output);
}
match payload {
ResponsePayload::Status(status) => {
print!("Phase: {}", status.phase);
if !status.tasks.is_empty() {
print!(", Tasks: {}", status.tasks.join(", "));
}
if let Some(reason) = status.degraded_reason {
print!(", Degraded: {}", reason);
}
println!();
}
ResponsePayload::Tasks(tasks) => {
if tasks.is_empty() {
println!("No active tasks.");
} else {
for task in &tasks {
println!("{}", task.name);
}
}
}
ResponsePayload::Users(users) => {
if users.is_empty() {
println!("No users.");
} else {
for user in &users {
println!("{} ({})", user.username, user.user_id);
}
}
}
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::DaemonStatus(status) => {
println!("{}", status.formatted);
}
ResponsePayload::Config(config) => {
println!("{}", config.yaml);
}
ResponsePayload::OmikronStatus(status) => {
println!("Connected: {}", status.connected);
if let Some(id) = status.iota_id {
println!("Iota ID: {}", id);
}
}
ResponsePayload::Components(components) => {
if components.is_empty() {
println!("No component health data available.");
} else {
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 suffix = comp
.message
.as_deref()
.map(|m| format!(" ({m})"))
.unwrap_or_default();
println!("{:?}: {}{}", comp.id, status_str, suffix);
}
}
}
ResponsePayload::UserDetail(user) => {
println!("User: {} ({})", 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 = if entry.is_error { "ERR" } else { "INF" };
println!("[{ts}] {level} {}: {}", entry.sender, entry.message);
}
}
ResponsePayload::UpdateStatus(status) => {
if status.available {
println!("Update available.");
} else {
println!("Up to date.");
}
}
ResponsePayload::Communities(communities) => {
if communities.is_empty() {
println!("No communities.");
} else {
for c in &communities {
println!("{} ({})", c.title, c.name);
}
}
}
}
Ok(())
}
ResponseResult::Error(code) => Err(StartupError::Other(format!(
"Daemon request failed: {code:?}"
"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::Text => unreachable!(),
}
Ok(())
}