use crate::commands;
use crate::errors::strip_ansi;
use crate::outcome::Outcome;
use crate::output::{ColorChoice, Ctx, ErrorMessage, Message, MessageFormat, Shell, StdCtx};
use clap::{ArgAction, Parser, Subcommand, builder::BoolishValueParser, error::ErrorKind};
use std::ffi::OsString;
use std::io::Write;
use std::process::ExitCode;
use tracing::debug;
const LONG_ABOUT: &str = r#"CLI for building with Turnkey Verifiable Cloud.
Some commands accept multiple configuration input types.
Configuration values are resolved in this order, highest priority first:
1. Command-line flag (e.g. --app-id)
2. Environment variable (e.g. TVC_APP_ID)
3. Config file value (--config-file)
4. Built-in default
Special rules (exceptions to the order above):
--pivot-args replaces the config file's list entirely (does not append)
Debug-mode flags (--dangerous-deploy-debug-mode and
--dangerous-enable-debug-mode-deployments) are opt-in only: the flag
or its env var can turn debug mode ON, but its absence never turns OFF
a config file that enables it. To disable debug mode, set it false in
the config file (or omit it) and do not pass the flag.
Authentication:
Local: run `tvc login` once; commands then read ~/.config/turnkey/.
CI: set TVC_ORG_ID, TVC_API_KEY_PUBLIC, and TVC_API_KEY_PRIVATE
to authenticate without files. Env vars take precedence over local
config files. Setting some but not all three required vars will error.
Interactive behavior:
By default, commands may prompt when stdin is a TTY. Use --non-interactive
or set TVC_NON_INTERACTIVE=true to disable prompts and fail fast instead.
Output format:
--message-format human (default) prints human-readable text. Use
--message-format json to emit machine-readable output instead: one JSON
object per line (newline-delimited JSON), each with a "reason" field
identifying the message, including errors. JSON mode implies
--non-interactive, so commands never prompt and fail fast on missing input.
Errors emit reason "command_error" (or "missing_required_input") plus a
"code" classifying the failure, an optional numeric "httpStatus", and a
"message" carrying the full error chain. The "code" taxonomy is:
missing_required_input a required value was absent (non-interactive)
usage_error bad flags/args (argument parsing failed)
invalid_input semantic validation failed in the command
unauthorized HTTP 401/403
not_found HTTP 404, or a resource that resolved to empty
api_error other non-success HTTP status, or a failed or
unexpected activity
approval_required the activity needs more approvals
network_error connect/timeout/DNS: request never reached the
server
command_error fallback for everything else
Exit codes are unchanged: 0 success, 1 runtime error, 2 usage error."#;
#[derive(Debug, Parser)]
#[command(about = "CLI for building with Turnkey Verifiable Cloud", long_about = LONG_ABOUT)]
pub struct Cli {
#[arg(
long,
global = true,
env = "TVC_NON_INTERACTIVE",
action = ArgAction::SetTrue,
value_parser = BoolishValueParser::new()
)]
non_interactive: bool,
#[arg(long, global = true, value_enum, default_value_t = MessageFormat::Human)]
message_format: MessageFormat,
#[arg(long, global = true, value_enum, default_value_t = ColorChoice::Auto)]
color: ColorChoice,
#[command(subcommand)]
command: Commands,
}
impl Cli {
pub async fn run() -> ExitCode {
let args = match Cli::try_parse() {
Ok(args) => args,
Err(error) => return handle_parse_error(error),
};
debug!(
command = args.command.name(),
non_interactive = args.non_interactive,
message_format = ?args.message_format,
color = ?args.color,
"dispatching"
);
let shell = Shell::standard(args.message_format, args.color);
let mut ctx = Ctx::new(shell, args.non_interactive);
let result = args.command.run(&mut ctx).await;
match result {
Ok(outcome) => {
if let Err(emit_error) = ctx.shell().emit(&outcome) {
let mut stderr = std::io::stderr();
let _ = writeln!(stderr, "warning: failed to write CLI output: {emit_error}");
}
ExitCode::SUCCESS
}
Err(error) => {
let shell = ctx.shell();
let emit_result = if shell.message_format().is_json() {
shell.emit(&ErrorMessage::from_error(&error))
} else {
shell.human().error(&error)
};
if let Err(emit_error) = emit_result {
let mut stderr = std::io::stderr();
let _ = writeln!(stderr, "error: failed to write CLI error: {emit_error}");
}
ExitCode::FAILURE
}
}
}
}
const USAGE_ERROR_EXIT_CODE: i32 = 2;
fn handle_parse_error(error: clap::Error) -> ExitCode {
match error.kind() {
ErrorKind::DisplayHelp
| ErrorKind::DisplayVersion
| ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => error.exit(),
_ => {
if args_request_json_output(std::env::args_os()) {
let message = strip_ansi(&error.render().to_string())
.trim_end()
.to_string();
let error_message = ErrorMessage::usage_error(message);
let mut stdout = std::io::stdout();
let _ = writeln!(stdout, "{}", error_message.to_json_string());
std::process::exit(USAGE_ERROR_EXIT_CODE)
} else {
error.exit()
}
}
}
}
fn args_request_json_output(args: impl IntoIterator<Item = OsString>) -> bool {
const FLAG: &str = "--message-format";
const JSON_FLAG: &str = "--message-format=json";
let args: Vec<_> = args.into_iter().collect();
args.iter().any(|arg| arg == JSON_FLAG)
|| args
.windows(2)
.any(|pair| pair[0] == FLAG && pair[1] == "json")
}
impl Commands {
async fn run(self, ctx: &mut StdCtx) -> anyhow::Result<Outcome> {
match self {
Commands::Deploy { command } => match command {
DeployCommands::Approve(args) => commands::deploy::approve::run(ctx, args).await,
DeployCommands::GetStatus(args) => {
commands::deploy::get_status::run(ctx, args).await
}
DeployCommands::ProvisioningDetails(args) => {
commands::deploy::provisioning_details::run(ctx, args).await
}
DeployCommands::Provision(args) => {
commands::deploy::provision::run(ctx, args).await
}
DeployCommands::PostShare(args) => {
commands::deploy::post_share::run(ctx, args).await
}
DeployCommands::Status(args) => commands::deploy::status::run(ctx, args).await,
DeployCommands::Create(args) => commands::deploy::create::run(ctx, args).await,
DeployCommands::Init(args) => commands::deploy::init::run(ctx, args).await,
DeployCommands::DebugLogs(args) => {
commands::deploy::debug_logs::run(ctx, args).await
}
DeployCommands::Delete(args) => commands::deploy::delete::run(ctx, args).await,
DeployCommands::Restore(args) => commands::deploy::restore::run(ctx, args).await,
},
Commands::App { command } => match command {
AppCommands::Status(args) => commands::app::status::run(ctx, args).await,
AppCommands::List(args) => commands::app::list::run(ctx, args).await,
AppCommands::Create(args) => commands::app::create::run(ctx, args).await,
AppCommands::Init(args) => commands::app::init::run(ctx, args).await,
AppCommands::SetLiveDeploy(args) => {
commands::app::set_live_deploy::run(ctx, args).await
}
AppCommands::Delete(args) => commands::app::delete::run(ctx, args).await,
},
Commands::Keys { command } => match command {
KeysCommands::CreateQuorumKey(args) => {
commands::keys::create_quorum_key::run(ctx, args).await
}
KeysCommands::GenerateLocalQuorumKey(args) => {
commands::keys::generate_local_quorum_key::run(ctx, args).await
}
KeysCommands::InitLocalQuorumKey(args) => {
commands::keys::init_local_quorum_key::run(ctx, args).await
}
KeysCommands::ReEncryptLocalShare(args) => {
commands::keys::re_encrypt_local_share::run(ctx, args).await
}
},
Commands::Login(args) => commands::login::run(ctx, args).await,
Commands::Operator { command } => match command {
OperatorCommands::Create(args) => commands::operator::create::run(ctx, args).await,
},
Commands::Profile { command } => match command {
ProfileCommands::Delete(delete_args) => {
commands::login::run_delete(ctx, delete_args).await
}
},
}
}
}
#[derive(Debug, Subcommand)]
enum Commands {
Login(commands::login::Args),
Operator {
#[command(subcommand)]
command: OperatorCommands,
},
Profile {
#[command(subcommand)]
command: ProfileCommands,
},
Deploy {
#[command(subcommand)]
command: DeployCommands,
},
App {
#[command(subcommand)]
command: AppCommands,
},
Keys {
#[command(subcommand)]
command: KeysCommands,
},
}
impl Commands {
fn name(&self) -> &'static str {
match self {
Commands::Login(_) => "login",
Commands::Operator { command } => match command {
OperatorCommands::Create(_) => "operator create",
},
Commands::Profile { command } => match command {
ProfileCommands::Delete(_) => "profile delete",
},
Commands::Deploy { command } => command.name(),
Commands::App { command } => command.name(),
Commands::Keys { command } => command.name(),
}
}
}
#[derive(Debug, Subcommand)]
enum OperatorCommands {
Create(commands::operator::create::Args),
}
#[derive(Debug, Subcommand)]
enum ProfileCommands {
Delete(commands::login::DeleteArgs),
}
#[derive(Debug, Subcommand)]
enum DeployCommands {
Approve(commands::deploy::approve::Args),
GetStatus(commands::deploy::get_status::Args),
ProvisioningDetails(commands::deploy::provisioning_details::Args),
Provision(commands::deploy::provision::Args),
PostShare(commands::deploy::post_share::Args),
Status(commands::deploy::status::Args),
#[command(
long_about = commands::deploy::create::LONG_ABOUT,
after_help = commands::deploy::PORT_GUIDANCE
)]
Create(commands::deploy::create::Args),
#[command(long_about = commands::deploy::init::LONG_ABOUT)]
Init(commands::deploy::init::Args),
#[command(long_about = commands::deploy::debug_logs::LONG_ABOUT)]
DebugLogs(commands::deploy::debug_logs::Args),
Delete(commands::deploy::delete::Args),
Restore(commands::deploy::restore::Args),
}
impl DeployCommands {
fn name(&self) -> &'static str {
match self {
DeployCommands::Approve(_) => "deploy approve",
DeployCommands::GetStatus(_) => "deploy get-status",
DeployCommands::ProvisioningDetails(_) => "deploy provisioning-details",
DeployCommands::Provision(_) => "deploy provision",
DeployCommands::PostShare(_) => "deploy post-share",
DeployCommands::Status(_) => "deploy status",
DeployCommands::Create(_) => "deploy create",
DeployCommands::Init(_) => "deploy init",
DeployCommands::DebugLogs(_) => "deploy debug-logs",
DeployCommands::Delete(_) => "deploy delete",
DeployCommands::Restore(_) => "deploy restore",
}
}
}
#[derive(Debug, Subcommand)]
enum AppCommands {
Status(commands::app::status::Args),
List(commands::app::list::Args),
Create(commands::app::create::Args),
Init(commands::app::init::Args),
SetLiveDeploy(commands::app::set_live_deploy::Args),
Delete(commands::app::delete::Args),
}
#[derive(Debug, Subcommand)]
enum KeysCommands {
CreateQuorumKey(commands::keys::create_quorum_key::Args),
GenerateLocalQuorumKey(commands::keys::generate_local_quorum_key::Args),
InitLocalQuorumKey(commands::keys::init_local_quorum_key::Args),
ReEncryptLocalShare(commands::keys::re_encrypt_local_share::Args),
}
impl AppCommands {
fn name(&self) -> &'static str {
match self {
AppCommands::Status(_) => "app status",
AppCommands::List(_) => "app list",
AppCommands::Create(_) => "app create",
AppCommands::Init(_) => "app init",
AppCommands::SetLiveDeploy(_) => "app set-live-deploy",
AppCommands::Delete(_) => "app delete",
}
}
}
impl KeysCommands {
fn name(&self) -> &'static str {
match self {
KeysCommands::CreateQuorumKey(_) => "keys create-quorum-key",
KeysCommands::GenerateLocalQuorumKey(_) => "keys generate-local-quorum-key",
KeysCommands::InitLocalQuorumKey(_) => "keys init-local-quorum-key",
KeysCommands::ReEncryptLocalShare(_) => "keys re-encrypt-local-share",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(tokens: &[&str]) -> Vec<OsString> {
tokens.iter().map(OsString::from).collect()
}
#[test]
fn argv_human_format_is_not_json() {
assert!(!args_request_json_output(argv(&[
"tvc",
"deploy",
"status",
"--message-format",
"human",
])));
assert!(!args_request_json_output(argv(&[
"tvc",
"--message-format=human",
])));
}
#[test]
fn args_tolerable_false_positive_after_end_of_options() {
assert!(args_request_json_output(argv(&[
"tvc",
"some-command",
"--",
"--message-format",
"json",
])));
}
}