Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 98 additions & 25 deletions tvc/README.md
Original file line number Diff line number Diff line change
@@ -1,41 +1,114 @@
# Experimental CLI for Turnkey Verifiable Cloud
# TVC CLI

## Usage
Command-line interface for [Turnkey Verifiable Cloud](https://turnkey.com).

### Create an App
## Build

From the workspace root:

```bash
cargo build -p tvc --release
```

## Basic flow

```bash
# 1. Login and create local config
tvc login

# 2. Create an app
tvc app init --output app.json
# edit app.json
tvc app create app.json

# 3. Create a deployment
tvc deploy init --output deploy.json
# edit deploy.json
tvc deploy create deploy.json

# 4. Approve the manifest
tvc deploy approve --deploy-id <DEPLOYMENT_ID>

# 5. Check status
tvc deploy status --deploy-id <DEPLOYMENT_ID>
```

## Global flags

These apply to all commands:

| Flag | Env Var | Purpose |
|---|---|---|
| `--json` | `TVC_JSON` | Emit machine-readable command results on stdout |
| `--no-input` | `TVC_NO_INPUT` | Fail instead of prompting when input is required |
| `--quiet`, `-q` | | Suppress non-essential status output |
| `--api-key-file <PATH>` | `TVC_API_KEY_FILE` | Override the API key source |
| `--api-url <URL>` | `TVC_API_URL` | Override the Turnkey API base URL |
| `--org-id <ID>` | `TVC_ORG_ID` | Override the organization ID |

## Login

`tvc login` sets up local config, API key material, and operator key material.

Examples:

```bash
# Login to Turnkey
# Interactive
tvc login

# Generate app config template
tvc app init --name my-app --output my-app.json
# Select an existing configured org
tvc login --org my-alias

# Non-interactive org config setup
tvc --no-input --org-id <ORG_ID> login --alias ci --api-env prod
```

Notes:
- first-time API key approval is still manual
- the generated public key and dashboard instructions are always printed
- `--quiet` suppresses routine status messages, not the required setup instructions

## Programmatic use

# Edit my-app.json to fill in required values (quorumPublicKey, operator keys, etc.)
For machine consumption, use `--json` on commands that return useful data:

# Create the app
tvc app create my-app.json
```bash
tvc --json app create app.json
tvc --json deploy create deploy.json
tvc --json deploy status --deploy-id <DEPLOYMENT_ID>
tvc --json deploy approve --deploy-id <DEPLOYMENT_ID> --yes --skip-post
```

### Create and Approve a Deployment
For config-less automation, provide all three client overrides:

```bash
# Generate deployment config template
tvc deploy init --output my-deploy.json
tvc \
--api-key-file /path/to/api_key.json \
--api-url https://api.turnkey.com \
--org-id <ORG_ID> \
--json \
deploy status --deploy-id <DEPLOYMENT_ID>
```

# Edit my-deploy.json to fill in required values (appId, container images, etc.)
If those overrides are not provided, API-backed commands use the active local `tvc login` config.

# Create the deployment
tvc deploy create my-deploy.json
## Deploy approval

# Recommended: uses GetTvcDeployment to fetch manifest and manifest_id automatically
tvc deploy approve \
--deploy-id <DEPLOYMENT_UUID> \
--operator-id <OPERATOR_UUID> # Turnkey's ID for your operator (from app create response)
Important behavior:
- `--no-input` does not imply approval
- non-interactive approval still requires `--yes`
- interactive review text goes to stderr
- approval data goes to stdout

# Alternative: provide manifest file and IDs manually
tvc deploy approve \
--manifest manifest.json \
--manifest-id <MANIFEST_UUID> \ # Turnkey's ID for the manifest (from deploy create response)
--operator-id <OPERATOR_UUID>
```
Examples:

```bash
# Review and approve interactively
tvc deploy approve --deploy-id <DEPLOYMENT_ID>

# Non-interactive approval
tvc --no-input deploy approve --deploy-id <DEPLOYMENT_ID> --yes

# Generate approval locally without posting
tvc deploy approve --manifest manifest.json --operator-seed seed.hex --yes --skip-post
```
47 changes: 40 additions & 7 deletions tvc/src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
//! CLI parsing and dispatch.

use crate::client::ClientOverrides;
use crate::commands;
use clap::{Args as ClapArgs, Parser, Subcommand};
use std::path::PathBuf;

/// Global options available to all commands.
#[derive(Debug, Clone, ClapArgs)]
pub struct GlobalOpts {
/// Output results as JSON.
#[arg(long, global = true, env = "TVC_JSON")]
pub json: bool,

/// Disable all interactive prompts. Fails if input is required.
/// Set TVC_NO_INPUT=true in CI/CD environments.
#[arg(long, global = true, env = "TVC_NO_INPUT")]
Expand All @@ -14,6 +20,28 @@ pub struct GlobalOpts {
/// Suppress non-essential output.
#[arg(long, short, global = true)]
pub quiet: bool,

/// Path to API key JSON file. Overrides the logged-in org's API key.
#[arg(long, global = true, env = "TVC_API_KEY_FILE", value_name = "PATH")]
pub api_key_file: Option<PathBuf>,

/// API base URL override (e.g., https://api.turnkey.com).
#[arg(long, global = true, env = "TVC_API_URL")]
pub api_url: Option<String>,

/// Organization ID override. Bypasses the logged-in org config.
#[arg(long, global = true, env = "TVC_ORG_ID")]
pub org_id: Option<String>,
}

impl GlobalOpts {
pub fn client_overrides(&self) -> ClientOverrides {
ClientOverrides {
api_key_file: self.api_key_file.clone(),
api_url: self.api_url.clone(),
org_id: self.org_id.clone(),
}
}
}

/// CLI command parsing and dispatch.
Expand All @@ -31,24 +59,29 @@ impl Cli {
/// Run the CLI.
pub async fn run() -> anyhow::Result<()> {
let args = Cli::parse();
let no_input = args.global.no_input;
let quiet = args.global.quiet;
let global = args.global;

match args.command {
Commands::Deploy { command } => match command {
DeployCommands::Approve(cmd_args) => {
commands::deploy::approve::run(cmd_args, no_input).await
commands::deploy::approve::run(cmd_args, &global).await
}
DeployCommands::Status(cmd_args) => {
commands::deploy::status::run(cmd_args, &global).await
}
DeployCommands::Create(cmd_args) => {
commands::deploy::create::run(cmd_args, &global).await
}
DeployCommands::Status(cmd_args) => commands::deploy::status::run(cmd_args).await,
DeployCommands::Create(cmd_args) => commands::deploy::create::run(cmd_args).await,
DeployCommands::Init(cmd_args) => commands::deploy::init::run(cmd_args).await,
},
Commands::App { command } => match command {
AppCommands::List(cmd_args) => commands::app::list::run(cmd_args).await,
AppCommands::Create(cmd_args) => commands::app::create::run(cmd_args).await,
AppCommands::Create(cmd_args) => {
commands::app::create::run(cmd_args, &global).await
}
AppCommands::Init(cmd_args) => commands::app::init::run(cmd_args).await,
},
Commands::Login(cmd_args) => commands::login::run(cmd_args, no_input, quiet).await,
Commands::Login(cmd_args) => commands::login::run(cmd_args, &global).await,
}
}
}
Expand Down
68 changes: 58 additions & 10 deletions tvc/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@

use crate::config::turnkey::{Config, StoredApiKey};
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use turnkey_api_key_stamper::TurnkeyP256ApiKey;
use turnkey_client::TurnkeyClient;

#[derive(Debug, Clone, Default)]
pub struct ClientOverrides {
pub api_key_file: Option<PathBuf>,
pub api_url: Option<String>,
pub org_id: Option<String>,
}

/// An authenticated Turnkey client with organization context.
pub struct AuthenticatedClient {
/// The Turnkey API client.
Expand All @@ -15,33 +23,73 @@ pub struct AuthenticatedClient {
pub api_base_url: String,
}

/// Build an authenticated Turnkey client from the local config.
/// Build an authenticated Turnkey client.
///
/// Loads the active organization and API key from `~/.config/turnkey/`.
/// Returns an error if not logged in.
pub async fn build_client() -> Result<AuthenticatedClient> {
/// When overrides for API key file, API URL, and org ID are all provided,
/// the client is built directly from those values without loading config from disk.
/// Otherwise, loads the active organization and API key from `~/.config/turnkey/`.
pub async fn build_client(overrides: &ClientOverrides) -> Result<AuthenticatedClient> {
if let (Some(api_key_file), Some(api_url), Some(org_id)) = (
&overrides.api_key_file,
&overrides.api_url,
&overrides.org_id,
) {
let api_key = load_api_key_from_path(api_key_file)?;
let stamper =
TurnkeyP256ApiKey::from_strings(&api_key.private_key, Some(&api_key.public_key))
.context("failed to load API key")?;

let client = TurnkeyClient::builder()
.api_key(stamper)
.base_url(api_url)
.build()
.context("failed to build Turnkey client")?;

return Ok(AuthenticatedClient {
client,
org_id: org_id.clone(),
api_base_url: api_url.clone(),
});
}

let config = Config::load().await?;

let (alias, org_config) = config
.active_org_config()
.ok_or_else(|| anyhow::anyhow!("No active organization. Run `tvc login` first."))?;

let api_key = StoredApiKey::load(org_config).await?.ok_or_else(|| {
anyhow::anyhow!("No API key found for org '{alias}'. Run `tvc login` first.")
})?;
let api_key = match &overrides.api_key_file {
Some(path) => load_api_key_from_path(path)?,
None => StoredApiKey::load(org_config).await?.ok_or_else(|| {
anyhow::anyhow!("No API key found for org '{alias}'. Run `tvc login` first.")
})?,
};

let stamper = TurnkeyP256ApiKey::from_strings(&api_key.private_key, Some(&api_key.public_key))
.context("failed to load API key")?;

let api_base_url = overrides
.api_url
.as_deref()
.unwrap_or(&org_config.api_base_url);
let org_id = overrides.org_id.as_deref().unwrap_or(&org_config.id);

let client = TurnkeyClient::builder()
.api_key(stamper)
.base_url(&org_config.api_base_url)
.base_url(api_base_url)
.build()
.context("failed to build Turnkey client")?;

Ok(AuthenticatedClient {
client,
org_id: org_config.id.clone(),
api_base_url: org_config.api_base_url.clone(),
org_id: org_id.to_string(),
api_base_url: api_base_url.to_string(),
})
}

fn load_api_key_from_path(path: &Path) -> Result<StoredApiKey> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read API key file: {}", path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("failed to parse API key file: {}", path.display()))
}
Loading