tvc 0.13.1

CLI for Turnkey Verifiable Cloud
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
//! CLI parsing and dispatch.

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."#;

/// CLI command parsing and dispatch.
#[derive(Debug, Parser)]
#[command(about = "CLI for building with Turnkey Verifiable Cloud", long_about = LONG_ABOUT)]
pub struct Cli {
    /// Disable interactive prompts and fail fast when required values are missing.
    #[arg(
        long,
        global = true,
        env = "TVC_NON_INTERACTIVE",
        action = ArgAction::SetTrue,
        value_parser = BoolishValueParser::new()
    )]
    non_interactive: bool,

    /// Format user-facing output.
    #[arg(long, global = true, value_enum, default_value_t = MessageFormat::Human)]
    message_format: MessageFormat,

    /// Control ANSI color in user-facing output.
    #[arg(long, global = true, value_enum, default_value_t = ColorChoice::Auto)]
    color: ColorChoice,

    #[command(subcommand)]
    command: Commands,
}

impl Cli {
    /// Run the 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) => {
                // Fail open: the command itself already succeeded, so a failure
                // to deliver the outcome message should not flip the exit code.
                // Make a best-effort warning on stderr and still exit successfully.
                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
            }
        }
    }
}

/// Exit code for a usage error (bad flags/args). Matches clap's default.
const USAGE_ERROR_EXIT_CODE: i32 = 2;

/// Handle a clap parse failure.
///
/// `--help`/`-h` and `--version` also surface as `Err`; those must always print
/// clap's own text (never JSON), so we defer to `error.exit()` for them. Real
/// parse failures are emitted as a single `command_error`/`usage_error` NDJSON
/// line on stdout when `--message-format json` was requested, otherwise handed
/// back to clap's default rendering via `error.exit()`.
fn handle_parse_error(error: clap::Error) -> ExitCode {
    match error.kind() {
        // Help/version output must never be JSON — let clap print and exit.
        ErrorKind::DisplayHelp
        | ErrorKind::DisplayVersion
        | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => error.exit(),
        _ => {
            if args_request_json_output(std::env::args_os()) {
                // Emit clap's full text (including the "Usage:" line) with any
                // ANSI stripped so agents get pure NDJSON they can self-correct
                // from, regardless of terminal/color detection.
                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();
                // Best-effort: if writing fails we still exit with the usage code.
                let _ = writeln!(stdout, "{}", error_message.to_json_string());
                std::process::exit(USAGE_ERROR_EXIT_CODE)
            } else {
                error.exit()
            }
        }
    }
}

/// Check if the command args wanted `json` output.
///
/// Matches both `--message-format json` and `--message-format=json`. This is
/// intentionally a heuristic: it recognizes raw token patterns without
/// reconstructing clap's command grammar, so flag-like positional values (for
/// example, after `--`) can produce a false positive. Emitting JSON in that
/// ambiguous case is preferable to returning non-JSON output to a consumer
/// that may have requested JSON.
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 {
    /// Authenticate with Turnkey.
    Login(commands::login::Args),
    /// Manage hosted TVC operators.
    Operator {
        #[command(subcommand)]
        command: OperatorCommands,
    },
    /// Manage saved login profiles.
    Profile {
        #[command(subcommand)]
        command: ProfileCommands,
    },
    /// Manage deployments.
    Deploy {
        #[command(subcommand)]
        command: DeployCommands,
    },
    /// Manage applications.
    App {
        #[command(subcommand)]
        command: AppCommands,
    },
    /// Manage cryptographic keys.
    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 a hosted TVC operator and save it to the active organization.
    Create(commands::operator::create::Args),
}

#[derive(Debug, Subcommand)]
enum ProfileCommands {
    /// Permanently delete a saved login profile and its key files.
    Delete(commands::login::DeleteArgs),
}

#[derive(Debug, Subcommand)]
enum DeployCommands {
    /// Approve a deployment manifest.
    Approve(commands::deploy::approve::Args),
    /// Get live runtime status for a deployment from the cluster.
    GetStatus(commands::deploy::get_status::Args),
    /// Get provisioning details for a deployment.
    ProvisioningDetails(commands::deploy::provisioning_details::Args),
    /// Provision one hosted quorum-key share for a deployment.
    Provision(commands::deploy::provision::Args),
    /// Post a re-encrypted quorum key share for a deployment.
    PostShare(commands::deploy::post_share::Args),
    /// Get the status of a deployment.
    Status(commands::deploy::status::Args),
    /// Create a new deployment from a config file.
    #[command(
        long_about = commands::deploy::create::LONG_ABOUT,
        after_help = commands::deploy::PORT_GUIDANCE
    )]
    Create(commands::deploy::create::Args),
    /// Generate a deployment configuration file
    #[command(long_about = commands::deploy::init::LONG_ABOUT)]
    Init(commands::deploy::init::Args),
    /// Fetch debug logs for a deployment.
    #[command(long_about = commands::deploy::debug_logs::LONG_ABOUT)]
    DebugLogs(commands::deploy::debug_logs::Args),
    /// Delete a deployment by marking it for deletion.
    Delete(commands::deploy::delete::Args),
    /// Restore a deleted deployment.
    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 {
    /// Get live runtime status for an app from the cluster.
    Status(commands::app::status::Args),
    /// List applications.
    List(commands::app::list::Args),
    /// Create a new application from a config file.
    Create(commands::app::create::Args),
    /// Generate a template app configuration file.
    Init(commands::app::init::Args),
    /// Set the live deployment for an app.
    SetLiveDeploy(commands::app::set_live_deploy::Args),
    /// Delete an app and all of its deployments.
    Delete(commands::app::delete::Args),
}

#[derive(Debug, Subcommand)]
enum KeysCommands {
    /// Create a hosted quorum key encrypted to hosted operator keys.
    CreateQuorumKey(commands::keys::create_quorum_key::Args),
    /// Generate and shamir-split a local quorum key, encrypting each share to an operator key.
    GenerateLocalQuorumKey(commands::keys::generate_local_quorum_key::Args),
    /// Generate a template local quorum key configuration file.
    InitLocalQuorumKey(commands::keys::init_local_quorum_key::Args),
    /// Re-encrypt a local share for enclave provisioning.
    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() {
        // Documented, accepted heuristic limitation: the scan does not
        // interpret `--`, so flag-like positional values after it still count
        // as a request for JSON output.
        assert!(args_request_json_output(argv(&[
            "tvc",
            "some-command",
            "--",
            "--message-format",
            "json",
        ])));
    }
}