This commit is contained in:
Alex 2026-07-25 22:59:25 +02:00
commit 009173a97d
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
49 changed files with 1788 additions and 389 deletions

View file

@ -10,6 +10,7 @@ iota-installer = { path = "../iota-installer" }
iota-core = { path = "../iota-core" }
iota-process-manager = { path = "../iota-process-manager" }
iota-paths = { path = "../iota-paths" }
iota-terms = { path = "../iota-terms" }
tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
serde_json = "1"

View file

@ -1,5 +1,6 @@
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind};
use iota_cli::theme::ThemeName;
use iota_terms::TermsType;
#[derive(Debug)]
pub struct CliInvocation {
@ -25,61 +26,220 @@ pub enum OutputFormat {
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum CliTheme { Monospace, Binary, Ansi, Surface }
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 }
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)]
#[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>,
#[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 },
Status,
Tasks,
Users(UsersArgs),
Omikron(OmikronArgs),
Identity(IdentityArgs),
Daemon(DaemonArgs),
Config(ConfigArgs),
Terms(TermsArgs),
RegenerateKeys {
#[arg(long)]
yes: bool,
},
Components,
Logs { #[arg(long, default_value_t = 100)] limit: usize },
Logs {
#[arg(long, default_value_t = 100)]
limit: usize,
},
Update(UpdateArgs),
Community(CommunityArgs),
Completions { shell: String },
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 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(Args, Debug)]
struct TermsArgs {
#[command(subcommand)]
action: TermsAction,
}
#[derive(Subcommand, Debug)]
enum TermsAction {
Status {
#[arg(long)]
system: bool,
},
Show {
document: TermsDocument,
},
Accept {
#[arg(long)]
system: bool,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum TermsDocument {
Eula,
Tos,
Privacy,
}
impl From<TermsDocument> for TermsType {
fn from(value: TermsDocument) -> Self {
match value {
TermsDocument::Eula => TermsType::EULA,
TermsDocument::Tos => TermsType::TOS,
TermsDocument::Privacy => TermsType::PP,
}
}
}
#[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 },
Completions {
shell: String,
},
ManPage,
Install {
bundle: String,
@ -88,7 +248,9 @@ pub enum Command {
Status,
Tasks,
UsersList,
UsersShow { user_id: i64 },
UsersShow {
user_id: i64,
},
UsersAdd {
username: String,
},
@ -96,7 +258,9 @@ pub enum Command {
user_id: i64,
confirmed: bool,
},
UsersImport { username: String },
UsersImport {
username: String,
},
OmikronReconnect,
IdentityRotate {
confirmed: bool,
@ -117,16 +281,30 @@ pub enum Command {
DaemonRestartService,
DaemonStopService,
ConfigGet,
ConfigSet { key: String, value: String },
ConfigSet {
key: String,
value: String,
},
ConfigReload,
OmikronStatus,
RegenerateKeys {
confirmed: bool,
},
Components,
Logs { limit: usize },
Logs {
limit: usize,
},
UpdateCheck,
CommunityList,
TermsStatus {
system: bool,
},
TermsShow {
document: TermsType,
},
TermsAccept {
system: bool,
},
}
impl CliInvocation {
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
@ -134,13 +312,14 @@ impl CliInvocation {
if args.as_slice() == ["help"] {
return Ok(Self::special(Command::Help));
}
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 =
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)),
@ -154,33 +333,77 @@ impl CliInvocation {
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::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::Update(update)) => match update.action {
UpdateAction::Check => Command::UpdateCheck,
},
Some(CliCommand::Community(community)) => match community.action {
CommunityAction::List => Command::CommunityList,
},
Some(CliCommand::Terms(terms)) => match terms.action {
TermsAction::Status { system } => Command::TermsStatus { system },
TermsAction::Show { document } => Command::TermsShow {
document: document.into(),
},
TermsAction::Accept { system } => Command::TermsAccept { system },
},
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::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 },
},
};
Ok(Self {
theme_override: parsed.theme.map(Into::into),
output: parsed.output,
color: if parsed.no_color { CapabilityPolicy::Never } else { parsed.color },
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 }
Self {
theme_override: None,
output: OutputFormat::Text,
color: CapabilityPolicy::Auto,
unicode: CapabilityPolicy::Auto,
command,
}
}
pub fn help_text() -> String {
@ -237,12 +460,9 @@ mod tests {
#[test]
fn parses_terminal_capability_overrides() {
let invocation = CliInvocation::parse([
"--color=never".into(),
"--unicode".into(),
"always".into(),
])
.unwrap();
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);

View file

@ -65,19 +65,23 @@ pub async fn run(
endpoints: &DaemonEndpoints,
caps: Capabilities,
) -> Result<ConnectionContext, StartupError> {
// Try connecting to an already-running daemon before starting a new one.
// The initial dashboard probe may have raced a daemon that was still
// accepting connections. Re-check both endpoints before offering setup:
// a system-managed daemon normally listens on a different socket from a
// locally launched one.
if let Ok(ipc) = IpcClient::connect(&endpoints.local).await {
return Ok(ConnectionContext { ipc });
}
if endpoints.system != endpoints.local {
if let Ok(ipc) = IpcClient::connect(&endpoints.system).await {
return Ok(ConnectionContext { ipc });
}
}
let options = caps.options();
if !options.iter().any(|o| o.enabled) {
let _ = show(
ui,
options,
"Daemon cannot be started. Correct the reported problem, then Retry, or Exit.",
)
.await?;
return Err(StartupError::Cancelled);
return Err(StartupError::Other(
"No running daemon could be reached, and no daemon launch method is available.".into(),
));
}
if UiConfig::load()
.map(|c| c.daemon_start_policy == iota_cli::theme::DaemonStartPolicy::WithUi)

View file

@ -10,6 +10,7 @@ mod cli_args;
mod daemon_setup_flow;
mod local_daemon;
mod startup_error;
mod terms;
use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat};
use startup_error::StartupError;
@ -78,6 +79,9 @@ async fn run() -> Result<(), StartupError> {
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),
@ -98,11 +102,13 @@ async fn run() -> Result<(), StartupError> {
return run_startup_command(command).await;
}
if !matches!(command, Command::Dashboard) {
match iota_core::consent_state::non_interactive_consent() {
iota_core::consent_state::NonInteractiveConsent::Accepted => {}
iota_core::consent_state::NonInteractiveConsent::RequiresInteractiveAcceptance => {
return Err(StartupError::Consent("Run `iota` in an interactive terminal to review and accept the required terms.".into()));
}
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?,
@ -242,8 +248,7 @@ async fn run_dashboard(
CapabilityPolicy::Always => true,
CapabilityPolicy::Never => false,
CapabilityPolicy::Auto => {
std::env::var_os("NO_COLOR").is_none()
&& std::env::var("TERM").as_deref() != Ok("dumb")
std::env::var_os("NO_COLOR").is_none() && std::env::var("TERM").as_deref() != Ok("dumb")
}
};
let unicode_enabled = match unicode_policy {
@ -278,6 +283,7 @@ async fn run_dashboard(
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),
@ -328,6 +334,20 @@ async fn run_dashboard(
}
}
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() {
@ -457,11 +477,21 @@ async fn run_command(
"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 => {
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(),
));

118
iota/src/terms.rs Normal file
View file

@ -0,0 +1,118 @@
use crate::startup_error::StartupError;
use iota_terms::{Doc, TermsType, consent, get_current_docs, get_terms};
use std::io::{self, IsTerminal, Write};
pub enum TermsCommand {
Status { system: bool },
Show { document: TermsType },
Accept { system: bool },
}
fn state_dir(system: bool) -> Result<std::path::PathBuf, StartupError> {
let scope = if system {
iota_paths::Scope::System
} else {
iota_paths::Scope::User
};
iota_paths::IotaPaths::resolve(scope)
.map(|paths| paths.state_dir)
.map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}")))
}
pub async fn run(command: TermsCommand) -> Result<(), StartupError> {
match command {
TermsCommand::Status { system } => {
let record = consent::load(&state_dir(system)?);
println!(
"EULA: {}",
if record.eula.is_some() {
"accepted"
} else {
"not accepted"
}
);
println!(
"Terms of Service: {}",
if record.tos.is_some() {
"accepted"
} else {
"not accepted"
}
);
println!(
"Privacy Policy: {}",
if record.privacy.is_some() {
"accepted"
} else {
"not accepted"
}
);
Ok(())
}
TermsCommand::Show { document } => {
let text = get_terms(document).await.ok_or_else(|| {
StartupError::Consent("Could not fetch the requested terms document.".into())
})?;
print!("{text}");
Ok(())
}
TermsCommand::Accept { system } => accept(system).await,
}
}
async fn accept(system: bool) -> Result<(), StartupError> {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return Err(StartupError::Consent("`iota terms accept` requires an interactive terminal so the documents can be reviewed.".into()));
}
let documents = get_current_docs().await.ok_or_else(|| {
StartupError::Consent(
"Could not fetch the current agreements from the legal endpoint.".into(),
)
})?;
let mut record = consent::load(&state_dir(system)?);
for document in [&documents.0, &documents.1, &documents.2] {
let text = get_terms(document.doc_type).await.ok_or_else(|| {
StartupError::Consent(format!(
"Could not fetch {}.",
document.doc_type.to_string()
))
})?;
println!(
"\n===== {} =====\nVersion: {}\nDocument hash: {}\n",
document.doc_type.to_string(),
document.get_version(),
document.get_hash()
);
print!("{text}\n");
if !confirm(document)? {
return Err(StartupError::Consent(
"No terms were accepted. Iota remains inactive.".into(),
));
}
record.accept(document);
}
consent::save(&state_dir(system)?, &record)
.map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))?;
println!("Terms accepted. Start Iota again to enable services.");
Ok(())
}
fn confirm(document: &Doc) -> Result<bool, StartupError> {
let hash = document.get_hash();
let prefix = hash.get(..10).unwrap_or(&hash);
let expected = format!(
"ACCEPT {} {} {}",
document.doc_type.to_str().to_ascii_uppercase(),
document.get_version(),
prefix
);
print!("To accept this exact document, type:\n{expected}\n> ");
io::stdout()
.flush()
.map_err(|error| StartupError::Consent(error.to_string()))?;
let mut response = String::new();
io::stdin()
.read_line(&mut response)
.map_err(|error| StartupError::Consent(error.to_string()))?;
Ok(response.trim() == expected)
}