[Wip] CLI & Daemon
This commit is contained in:
parent
3bc5cc959a
commit
6a535099bb
44 changed files with 4417 additions and 303 deletions
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
347
iota/src/main.rs
347
iota/src/main.rs
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue