diff --git a/Cargo.lock b/Cargo.lock index 9e0423a..528e919 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3762,7 +3762,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "steel-cli" -version = "0.5.0-preview.5" +version = "0.5.0-preview.6" dependencies = [ "aes 0.8.4", "aes-gcm 0.10.3", diff --git a/Cargo.toml b/Cargo.toml index efa4aeb..999f7b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "steel-cli" -version = "0.5.0-preview.5" +version = "0.5.0-preview.6" edition = "2024" description = "Steel CLI - Browser automation for AI agents" license = "MIT" diff --git a/README.md b/README.md index c0c7c28..ec7e324 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ Supported shells: `bash`, `zsh`, `fish`, `powershell`, `elvish`. | Local runtime | `dev install`, `dev start`, `dev stop` | | Credentials | `credentials list`, `credentials create`, `credentials update`, `credentials delete` | | Cloud computers | `computer create`, `computer exec`, `computer ssh`, `computer list`, `computer use` | +| Secrets | `secret list`, `secret create`, `secret get`, `secret update`, `secret delete` | +| Environments | `environment list`, `environment create`, `environment update`, `environment delete` | | Account and utility | `login`, `logout`, `config`, `doctor`, `cache`, `update`, `completion` | Full flags and schemas: [CLI reference](docs/cli-reference.md). diff --git a/docs/cli-reference.md b/docs/cli-reference.md index eb8926c..dc896ce 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -98,6 +98,9 @@ Steel CLI - browser automation for AI agents. This file is generated from `steel - [steel computer delete](#steel-computer-delete) - [steel computer pause](#steel-computer-pause) - [steel computer resume](#steel-computer-resume) +- [steel computer stop](#steel-computer-stop) +- [steel computer start](#steel-computer-start) +- [steel computer restart](#steel-computer-restart) - [steel computer use](#steel-computer-use) - [steel computer exec](#steel-computer-exec) - [steel computer ssh](#steel-computer-ssh) @@ -108,6 +111,18 @@ Steel CLI - browser automation for AI agents. This file is generated from `steel - [steel checkpoint get](#steel-checkpoint-get) - [steel checkpoint delete](#steel-checkpoint-delete) - [steel checkpoint restore](#steel-checkpoint-restore) +- [steel secret](#steel-secret) +- [steel secret list](#steel-secret-list) +- [steel secret create](#steel-secret-create) +- [steel secret get](#steel-secret-get) +- [steel secret update](#steel-secret-update) +- [steel secret delete](#steel-secret-delete) +- [steel environment](#steel-environment) +- [steel environment list](#steel-environment-list) +- [steel environment create](#steel-environment-create) +- [steel environment get](#steel-environment-get) +- [steel environment update](#steel-environment-update) +- [steel environment delete](#steel-environment-delete) - [steel init](#steel-init) - [steel login](#steel-login) - [steel logout](#steel-logout) @@ -1554,6 +1569,9 @@ steel computer - `delete`: Delete a computer - `pause`: Pause a running computer - `resume`: Resume a paused computer +- `stop`: Stop a computer but keep its disk +- `start`: Start a stopped computer +- `restart`: Reboot a running computer - `use`: Remember a computer as the default for other commands - `exec`: Run one command in a computer - `ssh`: Open an SSH session to a computer @@ -1577,6 +1595,11 @@ steel computer create - `--memory` (string, optional): Memory in MiB - `--timeout` (string, optional): Stop the computer after this many seconds of running time - `--auto-pause` (boolean, optional): Pause instead of stopping when the timeout is reached +- `--idle-timeout` (string, optional): Pause after this many seconds without incoming traffic (0 disables it) +- `--env` (string[], optional): Environment variable for the computer, repeatable +- `--secret` (string[], optional): Attach a stored secret as an environment variable, repeatable +- `--environment` (string, optional): Start from an environment; its spec and secrets apply unless overridden here +- `--project` (string, optional): Project to create the computer in (defaults to the API key's project) - `--wait` (boolean, optional): Wait until the computer is running - `--use` (boolean, optional): Make the new computer the default for other commands @@ -1645,7 +1668,52 @@ steel computer resume ### Parameters - `computer_id` (string, optional): Computer ID (defaults to STEEL_COMPUTER_ID or `steel computer use`) -- `--wait` (boolean, optional): Wait until the computer is running +- `--wait` (boolean, optional): Wait until the computer reaches its new state + +## steel computer stop + +Stop a computer but keep its disk + +### Usage + +```bash +steel computer stop +``` + +### Parameters + +- `computer_id` (string, optional): Computer ID (defaults to STEEL_COMPUTER_ID or `steel computer use`) +- `--wait` (boolean, optional): Wait until the computer reaches its new state + +## steel computer start + +Start a stopped computer + +### Usage + +```bash +steel computer start +``` + +### Parameters + +- `computer_id` (string, optional): Computer ID (defaults to STEEL_COMPUTER_ID or `steel computer use`) +- `--wait` (boolean, optional): Wait until the computer reaches its new state + +## steel computer restart + +Reboot a running computer + +### Usage + +```bash +steel computer restart +``` + +### Parameters + +- `computer_id` (string, optional): Computer ID (defaults to STEEL_COMPUTER_ID or `steel computer use`) +- `--wait` (boolean, optional): Wait until the computer reaches its new state ## steel computer use @@ -1803,9 +1871,219 @@ steel checkpoint restore - `checkpoint_id` (string, required): Checkpoint ID - `--timeout` (string, optional): Stop the computer after this many seconds of running time - `--auto-pause` (boolean, optional): Pause instead of stopping when the timeout is reached +- `--idle-timeout` (string, optional): Pause after this many seconds without incoming traffic (0 disables it) - `--wait` (boolean, optional): Wait until the computer is running - `--use` (boolean, optional): Make the new computer the default for other commands +## steel secret + +Secrets for computers: list, create, update, delete + +### Usage + +```bash +steel secret +``` + +### Subcommands + +- `list`: List secrets +- `create`: Store a new secret +- `get`: Get one secret's metadata +- `update`: Rename a secret or replace its value +- `delete`: Delete a secret + +## steel secret list + +List secrets + +### Usage + +```bash +steel secret list +``` + +### Parameters + +- `--project` (string, optional): Project to use (defaults to the API key's project) + +## steel secret create + +Store a new secret + +### Usage + +```bash +steel secret create +``` + +### Parameters + +- `name` (string, required): Secret name, also the environment variable name inside computers +- `--value` (string, optional): The secret value +- `--value-stdin` (boolean, optional): Read the secret value from stdin +- `--project` (string, optional): Project to use (defaults to the API key's project) + +## steel secret get + +Get one secret's metadata + +### Usage + +```bash +steel secret get +``` + +### Parameters + +- `secret_id` (string, required): Secret ID +- `--project` (string, optional): Project to use (defaults to the API key's project) + +## steel secret update + +Rename a secret or replace its value + +### Usage + +```bash +steel secret update +``` + +### Parameters + +- `secret_id` (string, required): Secret ID +- `--name` (string, optional): New name +- `--value` (string, optional): New value +- `--value-stdin` (boolean, optional): Read the new value from stdin +- `--project` (string, optional): Project to use (defaults to the API key's project) + +## steel secret delete + +Delete a secret + +### Usage + +```bash +steel secret delete +``` + +### Parameters + +- `secret_id` (string, required): Secret ID +- `--project` (string, optional): Project to use (defaults to the API key's project) + +## steel environment + +Reusable computer environments: a spec plus secrets + +### Usage + +```bash +steel environment +``` + +### Subcommands + +- `list`: List environments +- `create`: Create an environment +- `get`: Get one environment +- `update`: Change an environment's name, spec or secrets +- `delete`: Delete an environment + +## steel environment list + +List environments + +### Usage + +```bash +steel environment list +``` + +### Parameters + +- `--project` (string, optional): Project to use (defaults to the API key's project) + +## steel environment create + +Create an environment + +### Usage + +```bash +steel environment create +``` + +### Parameters + +- `name` (string, required): Environment name +- `--template` (string, optional): Template name +- `--vcpu` (string, optional): Number of vCPUs +- `--memory` (string, optional): Memory in MiB +- `--disk` (string, optional): Disk in MiB +- `--timeout` (string, optional): Stop computers after this many seconds of running time +- `--auto-pause` (boolean, optional): Pause instead of stopping when the timeout is reached +- `--env` (string[], optional): Environment variable for computers, repeatable +- `--secret` (string[], optional): Attach a stored secret as an environment variable, repeatable +- `--secret-value` (string[], optional): Store a new secret and attach it, repeatable +- `--project` (string, optional): Project to use (defaults to the API key's project) + +## steel environment get + +Get one environment + +### Usage + +```bash +steel environment get +``` + +### Parameters + +- `environment_id` (string, required): Environment ID +- `--project` (string, optional): Project to use (defaults to the API key's project) + +## steel environment update + +Change an environment's name, spec or secrets + +### Usage + +```bash +steel environment update +``` + +### Parameters + +- `environment_id` (string, required): Environment ID +- `--name` (string, optional): New name +- `--template` (string, optional): Template name +- `--vcpu` (string, optional): Number of vCPUs +- `--memory` (string, optional): Memory in MiB +- `--disk` (string, optional): Disk in MiB +- `--timeout` (string, optional): Stop computers after this many seconds of running time +- `--auto-pause` (boolean, optional): Pause instead of stopping when the timeout is reached +- `--env` (string[], optional): Environment variable for computers, repeatable +- `--secret` (string[], optional): Attach a stored secret as an environment variable, repeatable +- `--secret-value` (string[], optional): Store a new secret and attach it, repeatable +- `--unset-secret` (string[], optional): Detach a secret, repeatable +- `--project` (string, optional): Project to use (defaults to the API key's project) + +## steel environment delete + +Delete an environment + +### Usage + +```bash +steel environment delete +``` + +### Parameters + +- `environment_id` (string, required): Environment ID +- `--project` (string, optional): Project to use (defaults to the API key's project) + ## steel init One-command onboarding: login + verify + install agent skills diff --git a/src/api/computers.rs b/src/api/computers.rs index 7374a4e..ba385fd 100644 --- a/src/api/computers.rs +++ b/src/api/computers.rs @@ -19,6 +19,9 @@ pub struct CreateComputer { pub auto_pause: Option, pub idle_timeout_seconds: Option, pub env: BTreeMap, + pub secrets: BTreeMap, + pub environment_id: Option, + pub project_id: Option, } impl CreateComputer { @@ -45,6 +48,15 @@ impl CreateComputer { if !self.env.is_empty() { body["env"] = json!(self.env); } + if !self.secrets.is_empty() { + body["secrets"] = json!(self.secrets); + } + if let Some(environment) = &self.environment_id { + body["environmentId"] = json!(environment); + } + if let Some(project) = &self.project_id { + body["projectId"] = json!(project); + } body } } @@ -270,6 +282,12 @@ mod tests { auto_pause: Some(true), idle_timeout_seconds: Some(90), env: BTreeMap::from([("TOKEN".into(), "abc".into())]), + secrets: BTreeMap::from([( + "API_KEY".into(), + "2f1b6f2e-2c6f-4d9a-9c2a-8f4c1e0d7b31".into(), + )]), + environment_id: Some("9b2d1c5e-1b2a-4c3d-8e4f-5a6b7c8d9e0f".into()), + project_id: Some("0c1d2e3f-4a5b-4c6d-8e7f-8091a2b3c4d5".into()), }; assert_eq!( full.body(), @@ -281,6 +299,9 @@ mod tests { "autoPause": true, "idleTimeoutSeconds": 90, "env": { "TOKEN": "abc" }, + "secrets": { "API_KEY": "2f1b6f2e-2c6f-4d9a-9c2a-8f4c1e0d7b31" }, + "environmentId": "9b2d1c5e-1b2a-4c3d-8e4f-5a6b7c8d9e0f", + "projectId": "0c1d2e3f-4a5b-4c6d-8e7f-8091a2b3c4d5", }) ); } diff --git a/src/api/environments.rs b/src/api/environments.rs new file mode 100644 index 0000000..7669cac --- /dev/null +++ b/src/api/environments.rs @@ -0,0 +1,303 @@ +use std::collections::BTreeMap; + +use serde_json::{Map, Value, json}; + +use crate::api::client::{ApiError, SteelClient}; +use crate::api::secrets::with_project; +use crate::config::auth::Auth; +use crate::config::settings::ApiMode; + +pub fn environment_path(id: &str, project_id: Option<&str>) -> String { + with_project( + &format!("/environments/{}", urlencoding::encode(id)), + project_id, + ) +} + +#[derive(Debug, Default, Clone)] +pub struct EnvironmentSpec { + pub template: Option, + pub vcpu: Option, + pub memory_mib: Option, + pub disk_mib: Option, + pub timeout_seconds: Option, + pub auto_pause: Option, + pub env: BTreeMap, +} + +impl EnvironmentSpec { + pub fn is_empty(&self) -> bool { + self.template.is_none() + && self.vcpu.is_none() + && self.memory_mib.is_none() + && self.disk_mib.is_none() + && self.timeout_seconds.is_none() + && self.auto_pause.is_none() + && self.env.is_empty() + } + + pub fn apply_to(&self, current: Value) -> Value { + let mut spec = match current { + Value::Object(fields) => fields, + _ => Map::new(), + }; + if let Some(template) = &self.template { + spec.insert("template".into(), json!(template)); + } + if let Some(vcpu) = self.vcpu { + spec.insert("vcpu".into(), json!(vcpu)); + } + if let Some(memory) = self.memory_mib { + spec.insert("memoryMib".into(), json!(memory)); + } + if let Some(disk) = self.disk_mib { + spec.insert("diskMib".into(), json!(disk)); + } + if let Some(timeout) = self.timeout_seconds { + spec.insert("timeoutSeconds".into(), json!(timeout)); + } + if let Some(auto_pause) = self.auto_pause { + spec.insert("autoPause".into(), json!(auto_pause)); + } + if !self.env.is_empty() { + let mut env = match spec.remove("env") { + Some(Value::Object(fields)) => fields, + _ => Map::new(), + }; + for (name, value) in &self.env { + env.insert(name.clone(), json!(value)); + } + spec.insert("env".into(), Value::Object(env)); + } + Value::Object(spec) + } + + pub fn body(&self) -> Value { + self.apply_to(json!({})) + } +} + +#[derive(Debug, Default, Clone)] +pub struct CreateEnvironment { + pub name: String, + pub project_id: Option, + pub spec: Option, + pub secrets: BTreeMap, +} + +impl CreateEnvironment { + pub fn body(&self) -> Value { + let mut body = json!({ "name": self.name }); + if let Some(project) = &self.project_id { + body["projectId"] = json!(project); + } + if let Some(spec) = &self.spec { + body["spec"] = spec.clone(); + } + if !self.secrets.is_empty() { + body["secrets"] = json!(self.secrets); + } + body + } +} + +#[derive(Debug, Default, Clone)] +pub struct UpdateEnvironment { + pub name: Option, + pub spec: Option, + pub secrets: BTreeMap, +} + +impl UpdateEnvironment { + pub fn is_empty(&self) -> bool { + self.name.is_none() && self.spec.is_none() && self.secrets.is_empty() + } + + pub fn body(&self) -> Value { + let mut body = json!({}); + if let Some(name) = &self.name { + body["name"] = json!(name); + } + if let Some(spec) = &self.spec { + body["spec"] = spec.clone(); + } + if !self.secrets.is_empty() { + body["secrets"] = json!(self.secrets); + } + body + } +} + +impl SteelClient { + pub async fn list_environments( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + project_id: Option<&str>, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::GET, + &with_project("/environments", project_id), + None, + auth, + ) + .await + } + + pub async fn create_environment( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + request: &CreateEnvironment, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::POST, + "/environments", + Some(request.body()), + auth, + ) + .await + } + + pub async fn get_environment( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + id: &str, + project_id: Option<&str>, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::GET, + &environment_path(id, project_id), + None, + auth, + ) + .await + } + + pub async fn update_environment( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + id: &str, + project_id: Option<&str>, + request: &UpdateEnvironment, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::PATCH, + &environment_path(id, project_id), + Some(request.body()), + auth, + ) + .await + } + + pub async fn delete_environment( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + id: &str, + project_id: Option<&str>, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::DELETE, + &environment_path(id, project_id), + None, + auth, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_encode_ids_and_carry_the_project() { + assert_eq!(environment_path("env 1", None), "/environments/env%201"); + assert_eq!( + environment_path("env", Some("proj")), + "/environments/env?projectId=proj" + ); + } + + #[test] + fn spec_merges_into_the_current_one() { + let spec = EnvironmentSpec { + memory_mib: Some(4096), + auto_pause: Some(true), + env: BTreeMap::from([("B".into(), "2".into())]), + ..Default::default() + }; + assert!(!spec.is_empty()); + assert!(EnvironmentSpec::default().is_empty()); + let merged = spec.apply_to(json!({ + "template": "steel", + "vcpu": 2, + "env": { "A": "1" } + })); + assert_eq!( + merged, + json!({ + "template": "steel", + "vcpu": 2, + "memoryMib": 4096, + "autoPause": true, + "env": { "A": "1", "B": "2" } + }) + ); + assert_eq!( + spec.body(), + json!({ "memoryMib": 4096, "autoPause": true, "env": { "B": "2" } }) + ); + } + + #[test] + fn bodies_only_carry_given_fields() { + let create = CreateEnvironment { + name: "dev".into(), + ..Default::default() + }; + assert_eq!(create.body(), json!({ "name": "dev" })); + let full = CreateEnvironment { + name: "dev".into(), + project_id: Some("proj".into()), + spec: Some(json!({ "template": "steel" })), + secrets: BTreeMap::from([ + ("TOKEN".into(), json!({ "secretId": "id" })), + ("KEY".into(), json!({ "value": "abc" })), + ]), + }; + assert_eq!( + full.body(), + json!({ + "name": "dev", + "projectId": "proj", + "spec": { "template": "steel" }, + "secrets": { "TOKEN": { "secretId": "id" }, "KEY": { "value": "abc" } } + }) + ); + assert!(UpdateEnvironment::default().is_empty()); + let update = UpdateEnvironment { + secrets: BTreeMap::from([("OLD".into(), Value::Null)]), + ..Default::default() + }; + assert_eq!(update.body(), json!({ "secrets": { "OLD": null } })); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 8c7a11b..493cd75 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,7 +1,9 @@ pub mod checkpoints; pub mod client; pub mod computers; +pub mod environments; pub mod generated; pub mod projects; +pub mod secrets; pub mod session; pub mod top_level; diff --git a/src/api/secrets.rs b/src/api/secrets.rs new file mode 100644 index 0000000..7f89630 --- /dev/null +++ b/src/api/secrets.rs @@ -0,0 +1,234 @@ +use std::collections::BTreeMap; + +use anyhow::{Result, bail}; +use serde_json::{Value, json}; + +use crate::api::client::{ApiError, SteelClient}; +use crate::config::auth::Auth; +use crate::config::settings::ApiMode; +use crate::util::pairs::parse_pairs; + +pub fn with_project(path: &str, project_id: Option<&str>) -> String { + match project_id.map(str::trim).filter(|id| !id.is_empty()) { + Some(project) => format!("{path}?projectId={}", urlencoding::encode(project)), + None => path.to_string(), + } +} + +pub fn secret_path(id: &str, project_id: Option<&str>) -> String { + with_project(&format!("/secrets/{}", urlencoding::encode(id)), project_id) +} + +pub fn is_secret_id(value: &str) -> bool { + let parts: Vec<&str> = value.split('-').collect(); + parts.len() == 5 + && parts.iter().zip([8, 4, 4, 4, 12]).all(|(part, len)| { + part.len() == len && part.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) +} + +pub fn parse_secret_ids(pairs: &[String]) -> Result> { + let bindings = parse_pairs("--secret", "NAME=SECRET_ID", pairs)?; + for (name, id) in &bindings { + if !is_secret_id(id) { + bail!( + "--secret {name} wants a secret id, got {id:?}. Store the value with `steel secret create` first." + ); + } + } + Ok(bindings) +} + +#[derive(Debug, Default, Clone)] +pub struct CreateSecret { + pub name: String, + pub value: String, + pub project_id: Option, +} + +impl CreateSecret { + pub fn body(&self) -> Value { + let mut body = json!({ "name": self.name, "value": self.value }); + if let Some(project) = &self.project_id { + body["projectId"] = json!(project); + } + body + } +} + +#[derive(Debug, Default, Clone)] +pub struct UpdateSecret { + pub name: Option, + pub value: Option, +} + +impl UpdateSecret { + pub const fn is_empty(&self) -> bool { + self.name.is_none() && self.value.is_none() + } + + pub fn body(&self) -> Value { + let mut body = json!({}); + if let Some(name) = &self.name { + body["name"] = json!(name); + } + if let Some(value) = &self.value { + body["value"] = json!(value); + } + body + } +} + +impl SteelClient { + pub async fn list_secrets( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + project_id: Option<&str>, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::GET, + &with_project("/secrets", project_id), + None, + auth, + ) + .await + } + + pub async fn create_secret( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + request: &CreateSecret, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::POST, + "/secrets", + Some(request.body()), + auth, + ) + .await + } + + pub async fn get_secret( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + id: &str, + project_id: Option<&str>, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::GET, + &secret_path(id, project_id), + None, + auth, + ) + .await + } + + pub async fn update_secret( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + id: &str, + project_id: Option<&str>, + request: &UpdateSecret, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::PATCH, + &secret_path(id, project_id), + Some(request.body()), + auth, + ) + .await + } + + pub async fn delete_secret( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + id: &str, + project_id: Option<&str>, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::DELETE, + &secret_path(id, project_id), + None, + auth, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ID: &str = "2f1b6f2e-2c6f-4d9a-9c2a-8f4c1e0d7b31"; + + #[test] + fn paths_encode_ids_and_carry_the_project() { + assert_eq!(secret_path("a b", None), "/secrets/a%20b"); + assert_eq!( + secret_path(ID, Some("proj 1")), + format!("/secrets/{ID}?projectId=proj%201") + ); + assert_eq!(with_project("/secrets", Some(" ")), "/secrets"); + } + + #[test] + fn secret_ids_are_uuids() { + assert!(is_secret_id(ID)); + assert!(is_secret_id("00000000-0000-0000-0000-000000000000")); + assert!(!is_secret_id("sk-live-abc")); + assert!(!is_secret_id("2f1b6f2e-2c6f-4d9a-9c2a-8f4c1e0d7b3")); + assert!(!is_secret_id("2f1b6f2e-2c6f-4d9a-9c2a-8f4c1e0d7b3g")); + } + + #[test] + fn secret_bindings_refuse_plain_values() { + let bindings = parse_secret_ids(&[format!("TOKEN={ID}")]).unwrap(); + assert_eq!(bindings["TOKEN"], ID); + let err = parse_secret_ids(&["TOKEN=sk-live-abc".into()]).unwrap_err(); + assert!(err.to_string().contains("steel secret create")); + } + + #[test] + fn bodies_only_carry_given_fields() { + let create = CreateSecret { + name: "TOKEN".into(), + value: "abc".into(), + project_id: None, + }; + assert_eq!(create.body(), json!({ "name": "TOKEN", "value": "abc" })); + let scoped = CreateSecret { + project_id: Some("proj".into()), + ..create + }; + assert_eq!( + scoped.body(), + json!({ "name": "TOKEN", "value": "abc", "projectId": "proj" }) + ); + assert!(UpdateSecret::default().is_empty()); + let update = UpdateSecret { + name: Some("API_TOKEN".into()), + value: None, + }; + assert_eq!(update.body(), json!({ "name": "API_TOKEN" })); + } +} diff --git a/src/commands/computer/mod.rs b/src/commands/computer/mod.rs index e948d7a..28b16bb 100644 --- a/src/commands/computer/mod.rs +++ b/src/commands/computer/mod.rs @@ -11,9 +11,11 @@ use serde_json::Value; use crate::api::client::SteelClient; use crate::api::computers::CreateComputer; +use crate::api::secrets::parse_secret_ids; use crate::commands::checkpoint; use crate::config::settings::{self, ComputerConfig}; use crate::status; +use crate::util::pairs::parse_pairs; use crate::util::{api, output}; pub const WAIT_POLL_INTERVAL: Duration = Duration::from_secs(1); @@ -115,6 +117,18 @@ pub struct CreateArgs { #[arg(long = "env", value_name = "KEY=VALUE")] pub env: Vec, + /// Attach a stored secret as an environment variable, repeatable + #[arg(long = "secret", value_name = "NAME=SECRET_ID")] + pub secrets: Vec, + + /// Start from an environment; its spec and secrets apply unless overridden here + #[arg(long = "environment", value_name = "ENVIRONMENT_ID")] + pub environment_id: Option, + + /// Project to create the computer in (defaults to the API key's project) + #[arg(long = "project", value_name = "PROJECT_ID")] + pub project_id: Option, + /// Wait until the computer is running #[arg(long)] pub wait: bool, @@ -213,19 +227,7 @@ pub fn remember_computer(id: Option<&str>) -> Result<()> { } pub fn parse_env(pairs: &[String]) -> Result> { - let mut env = BTreeMap::new(); - for pair in pairs { - let Some((name, value)) = pair.split_once('=') else { - bail!("--env wants KEY=VALUE, got {pair:?}."); - }; - if name.is_empty() { - bail!("--env wants a name before the '=', got {pair:?}."); - } - if env.insert(name.to_string(), value.to_string()).is_some() { - bail!("--env {name} was given twice."); - } - } - Ok(env) + parse_pairs("--env", "KEY=VALUE", pairs) } pub fn status_of(computer: &Value) -> &str { @@ -250,6 +252,9 @@ async fn run_create(args: CreateArgs) -> Result<()> { auto_pause: args.auto_pause.then_some(true), idle_timeout_seconds: args.idle_timeout_seconds, env: parse_env(&args.env)?, + secrets: parse_secret_ids(&args.secrets)?, + environment_id: args.environment_id, + project_id: args.project_id, }; let created = client .create_computer(&base_url, mode, &auth, &request) diff --git a/src/commands/environment.rs b/src/commands/environment.rs new file mode 100644 index 0000000..b1f9c59 --- /dev/null +++ b/src/commands/environment.rs @@ -0,0 +1,371 @@ +use std::collections::BTreeMap; + +use anyhow::{Context, Result, bail}; +use clap::{Args, Parser, Subcommand}; +use serde_json::{Value, json}; + +use crate::api::client::SteelClient; +use crate::api::environments::{CreateEnvironment, EnvironmentSpec, UpdateEnvironment}; +use crate::api::secrets::parse_secret_ids; +use crate::commands::secret::ProjectArgs; +use crate::util::pairs::parse_pairs; +use crate::util::{api, output}; + +#[derive(Subcommand)] +pub enum Command { + /// List environments + List(ProjectArgs), + + /// Create an environment + Create(CreateArgs), + + /// Get one environment + Get(IdArgs), + + /// Change an environment's name, spec or secrets + Update(UpdateArgs), + + /// Delete an environment + Delete(IdArgs), +} + +impl Command { + pub const fn telemetry_name(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Create(_) => "create", + Self::Get(_) => "get", + Self::Update(_) => "update", + Self::Delete(_) => "delete", + } + } +} + +#[derive(Parser)] +pub struct IdArgs { + /// Environment ID + pub environment_id: String, + + #[command(flatten)] + pub project: ProjectArgs, +} + +#[derive(Args)] +pub struct SpecArgs { + /// Template name + #[arg(long)] + pub template: Option, + + /// Number of vCPUs + #[arg(long)] + pub vcpu: Option, + + /// Memory in MiB + #[arg(long = "memory", value_name = "MIB")] + pub memory_mib: Option, + + /// Disk in MiB + #[arg(long = "disk", value_name = "MIB")] + pub disk_mib: Option, + + /// Stop computers after this many seconds of running time + #[arg(long = "timeout", value_name = "SECONDS")] + pub timeout_seconds: Option, + + /// Pause instead of stopping when the timeout is reached + #[arg(long = "auto-pause")] + pub auto_pause: bool, + + /// Environment variable for computers, repeatable + #[arg(long = "env", value_name = "KEY=VALUE")] + pub env: Vec, +} + +impl SpecArgs { + pub fn to_spec(&self) -> Result { + Ok(EnvironmentSpec { + template: self.template.clone(), + vcpu: self.vcpu, + memory_mib: self.memory_mib, + disk_mib: self.disk_mib, + timeout_seconds: self.timeout_seconds, + auto_pause: self.auto_pause.then_some(true), + env: parse_pairs("--env", "KEY=VALUE", &self.env)?, + }) + } +} + +#[derive(Args)] +pub struct SecretArgs { + /// Attach a stored secret as an environment variable, repeatable + #[arg(long = "secret", value_name = "NAME=SECRET_ID")] + pub secrets: Vec, + + /// Store a new secret and attach it, repeatable + #[arg(long = "secret-value", value_name = "NAME=VALUE")] + pub secret_values: Vec, +} + +#[derive(Parser)] +pub struct CreateArgs { + /// Environment name + pub name: String, + + #[command(flatten)] + pub spec: SpecArgs, + + #[command(flatten)] + pub secrets: SecretArgs, + + #[command(flatten)] + pub project: ProjectArgs, +} + +#[derive(Parser)] +pub struct UpdateArgs { + /// Environment ID + pub environment_id: String, + + /// New name + #[arg(long)] + pub name: Option, + + #[command(flatten)] + pub spec: SpecArgs, + + #[command(flatten)] + pub secrets: SecretArgs, + + /// Detach a secret, repeatable + #[arg(long = "unset-secret", value_name = "NAME")] + pub unset_secrets: Vec, + + #[command(flatten)] + pub project: ProjectArgs, +} + +pub async fn run(command: Command) -> Result<()> { + match command { + Command::List(args) => run_list(args).await, + Command::Create(args) => run_create(args).await, + Command::Get(args) => run_get(args).await, + Command::Update(args) => run_update(args).await, + Command::Delete(args) => run_delete(args).await, + } +} + +pub fn secret_declarations( + ids: &[String], + values: &[String], + unset: &[String], +) -> Result> { + let mut declarations = BTreeMap::new(); + for (name, id) in parse_secret_ids(ids)? { + declarations.insert(name, json!({ "secretId": id })); + } + for (name, value) in parse_pairs("--secret-value", "NAME=VALUE", values)? { + if value.is_empty() { + bail!("--secret-value {name} must not be empty."); + } + if declarations + .insert(name.clone(), json!({ "value": value })) + .is_some() + { + bail!("{name} was given more than once."); + } + } + for name in unset { + let name = name.trim(); + if name.is_empty() { + bail!("--unset-secret wants a name."); + } + if declarations.insert(name.to_string(), Value::Null).is_some() { + bail!("{name} was given more than once."); + } + } + Ok(declarations) +} + +pub fn name_of(environment: &Value) -> &str { + environment["name"].as_str().unwrap_or("-") +} + +pub fn id_of(environment: &Value) -> Result { + environment["id"] + .as_str() + .map(str::to_string) + .context("the API returned an environment without an id") +} + +async fn run_list(args: ProjectArgs) -> Result<()> { + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let data = client + .list_environments(&base_url, mode, &auth, args.project_id.as_deref()) + .await?; + if output::is_json() { + output::success_data(data); + } else { + print_environments(&data); + } + Ok(()) +} + +async fn run_create(args: CreateArgs) -> Result<()> { + let spec = args.spec.to_spec()?; + let request = CreateEnvironment { + name: args.name, + project_id: args.project.project_id, + spec: (!spec.is_empty()).then(|| spec.body()), + secrets: secret_declarations(&args.secrets.secrets, &args.secrets.secret_values, &[])?, + }; + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let data = client + .create_environment(&base_url, mode, &auth, &request) + .await?; + if output::is_json() { + output::success_data(data); + } else { + println!("Created {} as {}.", name_of(&data), id_of(&data)?); + } + Ok(()) +} + +async fn run_get(args: IdArgs) -> Result<()> { + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let data = client + .get_environment( + &base_url, + mode, + &auth, + &args.environment_id, + args.project.project_id.as_deref(), + ) + .await?; + output::success_data(data); + Ok(()) +} + +async fn run_update(args: UpdateArgs) -> Result<()> { + let project = args.project.project_id.as_deref(); + let spec_changes = args.spec.to_spec()?; + let secrets = secret_declarations( + &args.secrets.secrets, + &args.secrets.secret_values, + &args.unset_secrets, + )?; + if args.name.is_none() && spec_changes.is_empty() && secrets.is_empty() { + bail!( + "Give something to change: --name, a spec flag, --secret, --secret-value or --unset-secret." + ); + } + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let spec = if spec_changes.is_empty() { + None + } else { + let current = client + .get_environment(&base_url, mode, &auth, &args.environment_id, project) + .await?; + Some(spec_changes.apply_to(current["spec"].clone())) + }; + let request = UpdateEnvironment { + name: args.name, + spec, + secrets, + }; + let data = client + .update_environment( + &base_url, + mode, + &auth, + &args.environment_id, + project, + &request, + ) + .await?; + if output::is_json() { + output::success_data(data); + } else { + println!("Updated {}.", args.environment_id); + } + Ok(()) +} + +async fn run_delete(args: IdArgs) -> Result<()> { + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let data = client + .delete_environment( + &base_url, + mode, + &auth, + &args.environment_id, + args.project.project_id.as_deref(), + ) + .await?; + if output::is_json() { + output::success_data(data); + } else { + println!("Deleted {}.", args.environment_id); + } + Ok(()) +} + +fn print_environments(data: &Value) { + let environments = data["environments"].as_array().cloned().unwrap_or_default(); + if environments.is_empty() { + println!("No environments."); + return; + } + let rows: Vec> = environments + .iter() + .map(|environment| { + vec![ + environment["id"].as_str().unwrap_or("").to_string(), + name_of(environment).to_string(), + environment["spec"]["template"] + .as_str() + .unwrap_or("-") + .to_string(), + environment["secrets"] + .as_object() + .map_or(0, serde_json::Map::len) + .to_string(), + environment["updatedAt"].as_str().unwrap_or("").to_string(), + ] + }) + .collect(); + output::print_table(&["ID", "NAME", "TEMPLATE", "SECRETS", "UPDATED"], &rows); +} + +#[cfg(test)] +mod tests { + use super::*; + + const ID: &str = "2f1b6f2e-2c6f-4d9a-9c2a-8f4c1e0d7b31"; + + #[test] + fn declarations_cover_ids_values_and_unsets() { + let declarations = secret_declarations( + &[format!("TOKEN={ID}")], + &["KEY=abc".into()], + &["OLD".into()], + ) + .unwrap(); + assert_eq!(declarations["TOKEN"], json!({ "secretId": ID })); + assert_eq!(declarations["KEY"], json!({ "value": "abc" })); + assert_eq!(declarations["OLD"], Value::Null); + } + + #[test] + fn declarations_refuse_conflicts_and_blanks() { + assert!(secret_declarations(&["TOKEN=plain".into()], &[], &[]).is_err()); + assert!(secret_declarations(&[], &["KEY=".into()], &[]).is_err()); + assert!(secret_declarations(&[format!("A={ID}")], &["A=x".into()], &[]).is_err()); + assert!(secret_declarations(&[], &["A=x".into()], &["A".into()]).is_err()); + assert!(secret_declarations(&[], &[], &[" ".into()]).is_err()); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f450a35..d7357ed 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod credentials; pub mod describe; pub mod dev; pub mod doctor; +pub mod environment; pub mod forge; pub mod init; pub mod login; @@ -16,6 +17,7 @@ pub mod pdf; pub mod profile; pub mod scrape; pub mod screenshot; +pub mod secret; pub mod sessions; pub mod skills; pub mod update; @@ -304,6 +306,18 @@ pub enum Command { command: checkpoint::Command, }, + /// Secrets for computers: list, create, update, delete + Secret { + #[command(subcommand)] + command: secret::Command, + }, + + /// Reusable computer environments: a spec plus secrets + Environment { + #[command(subcommand)] + command: environment::Command, + }, + /// One-command onboarding: login + verify + install agent skills Init(init::Args), @@ -367,6 +381,10 @@ fn telemetry_command_path(command: &Command) -> Option { Command::Sessions { command } => Some(format!("sessions.{}", command.telemetry_name())), Command::Computer { command } => Some(format!("computer.{}", command.telemetry_name())), Command::Checkpoint { command } => Some(format!("checkpoint.{}", command.telemetry_name())), + Command::Secret { command } => Some(format!("secret.{}", command.telemetry_name())), + Command::Environment { command } => { + Some(format!("environment.{}", command.telemetry_name())) + } Command::Init(_) => Some("init".to_string()), Command::Login(_) => Some("login".to_string()), Command::Logout(_) => Some("logout".to_string()), @@ -416,6 +434,8 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { Command::Sessions { command } => sessions::run(command).await, Command::Computer { command } => computer::run(command).await, Command::Checkpoint { command } => checkpoint::run(command).await, + Command::Secret { command } => secret::run(command).await, + Command::Environment { command } => environment::run(command).await, Command::Init(args) => init::run(args).await, Command::Login(args) => login::run(args).await, Command::Logout(args) => logout::run(args).await, diff --git a/src/commands/secret.rs b/src/commands/secret.rs new file mode 100644 index 0000000..beee88b --- /dev/null +++ b/src/commands/secret.rs @@ -0,0 +1,282 @@ +use std::io::Read; + +use anyhow::{Context, Result, bail}; +use clap::{Args, Parser, Subcommand}; +use serde_json::Value; + +use crate::api::client::SteelClient; +use crate::api::secrets::{CreateSecret, UpdateSecret}; +use crate::util::{api, output}; + +#[derive(Subcommand)] +pub enum Command { + /// List secrets + List(ProjectArgs), + + /// Store a new secret + Create(CreateArgs), + + /// Get one secret's metadata + Get(IdArgs), + + /// Rename a secret or replace its value + Update(UpdateArgs), + + /// Delete a secret + Delete(IdArgs), +} + +impl Command { + pub const fn telemetry_name(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Create(_) => "create", + Self::Get(_) => "get", + Self::Update(_) => "update", + Self::Delete(_) => "delete", + } + } +} + +#[derive(Args)] +pub struct ProjectArgs { + /// Project to use (defaults to the API key's project) + #[arg(long = "project", value_name = "PROJECT_ID")] + pub project_id: Option, +} + +#[derive(Parser)] +pub struct IdArgs { + /// Secret ID + pub secret_id: String, + + #[command(flatten)] + pub project: ProjectArgs, +} + +#[derive(Parser)] +pub struct CreateArgs { + /// Secret name, also the environment variable name inside computers + pub name: String, + + /// The secret value + #[arg(long, value_name = "VALUE", conflicts_with = "value_stdin")] + pub value: Option, + + /// Read the secret value from stdin + #[arg(long = "value-stdin")] + pub value_stdin: bool, + + #[command(flatten)] + pub project: ProjectArgs, +} + +#[derive(Parser)] +pub struct UpdateArgs { + /// Secret ID + pub secret_id: String, + + /// New name + #[arg(long)] + pub name: Option, + + /// New value + #[arg(long, value_name = "VALUE", conflicts_with = "value_stdin")] + pub value: Option, + + /// Read the new value from stdin + #[arg(long = "value-stdin")] + pub value_stdin: bool, + + #[command(flatten)] + pub project: ProjectArgs, +} + +pub async fn run(command: Command) -> Result<()> { + match command { + Command::List(args) => run_list(args).await, + Command::Create(args) => run_create(args).await, + Command::Get(args) => run_get(args).await, + Command::Update(args) => run_update(args).await, + Command::Delete(args) => run_delete(args).await, + } +} + +pub fn trim_trailing_newline(text: &str) -> &str { + let text = text.strip_suffix('\n').unwrap_or(text); + text.strip_suffix('\r').unwrap_or(text) +} + +pub fn read_value(value: Option, from_stdin: bool) -> Result> { + if from_stdin { + let mut text = String::new(); + std::io::stdin() + .read_to_string(&mut text) + .context("Failed to read the value from stdin")?; + let value = trim_trailing_newline(&text).to_string(); + if value.is_empty() { + bail!("Nothing was read from stdin."); + } + return Ok(Some(value)); + } + if value.as_deref() == Some("") { + bail!("--value must not be empty."); + } + Ok(value) +} + +pub fn name_of(secret: &Value) -> &str { + secret["name"].as_str().unwrap_or("-") +} + +pub fn id_of(secret: &Value) -> Result { + secret["id"] + .as_str() + .map(str::to_string) + .context("the API returned a secret without an id") +} + +async fn run_list(args: ProjectArgs) -> Result<()> { + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let data = client + .list_secrets(&base_url, mode, &auth, args.project_id.as_deref()) + .await?; + if output::is_json() { + output::success_data(data); + } else { + print_secrets(&data); + } + Ok(()) +} + +async fn run_create(args: CreateArgs) -> Result<()> { + let Some(value) = read_value(args.value, args.value_stdin)? else { + bail!("Give the value with --value or --value-stdin."); + }; + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let request = CreateSecret { + name: args.name, + value, + project_id: args.project.project_id, + }; + let data = client + .create_secret(&base_url, mode, &auth, &request) + .await?; + if output::is_json() { + output::success_data(data); + } else { + println!("Created {} as {}.", name_of(&data), id_of(&data)?); + } + Ok(()) +} + +async fn run_get(args: IdArgs) -> Result<()> { + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let data = client + .get_secret( + &base_url, + mode, + &auth, + &args.secret_id, + args.project.project_id.as_deref(), + ) + .await?; + output::success_data(data); + Ok(()) +} + +async fn run_update(args: UpdateArgs) -> Result<()> { + let request = UpdateSecret { + name: args.name, + value: read_value(args.value, args.value_stdin)?, + }; + if request.is_empty() { + bail!("Give something to change: --name, --value or --value-stdin."); + } + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let data = client + .update_secret( + &base_url, + mode, + &auth, + &args.secret_id, + args.project.project_id.as_deref(), + &request, + ) + .await?; + if output::is_json() { + output::success_data(data); + } else { + println!("Updated {}.", args.secret_id); + } + Ok(()) +} + +async fn run_delete(args: IdArgs) -> Result<()> { + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let data = client + .delete_secret( + &base_url, + mode, + &auth, + &args.secret_id, + args.project.project_id.as_deref(), + ) + .await?; + if output::is_json() { + output::success_data(data); + } else { + println!("Deleted {}.", args.secret_id); + } + Ok(()) +} + +fn print_secrets(data: &Value) { + let secrets = data["secrets"].as_array().cloned().unwrap_or_default(); + if secrets.is_empty() { + println!("No secrets."); + return; + } + let rows: Vec> = secrets + .iter() + .map(|secret| { + vec![ + secret["id"].as_str().unwrap_or("").to_string(), + name_of(secret).to_string(), + secret["version"] + .as_u64() + .map_or_else(|| "-".to_string(), |version| version.to_string()), + secret["updatedAt"].as_str().unwrap_or("").to_string(), + ] + }) + .collect(); + output::print_table(&["ID", "NAME", "VERSION", "UPDATED"], &rows); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trailing_newlines_are_dropped_once() { + assert_eq!(trim_trailing_newline("abc\n"), "abc"); + assert_eq!(trim_trailing_newline("abc\r\n"), "abc"); + assert_eq!(trim_trailing_newline("abc\n\n"), "abc\n"); + assert_eq!(trim_trailing_newline("abc"), "abc"); + } + + #[test] + fn values_from_flags_pass_through_and_empty_ones_are_refused() { + assert_eq!(read_value(None, false).unwrap(), None); + assert_eq!( + read_value(Some("x".into()), false).unwrap().as_deref(), + Some("x") + ); + assert!(read_value(Some(String::new()), false).is_err()); + } +} diff --git a/src/util/mod.rs b/src/util/mod.rs index b2a2124..16676b4 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,3 +1,4 @@ pub mod api; pub mod output; +pub mod pairs; pub mod url; diff --git a/src/util/output.rs b/src/util/output.rs index a4a8f30..342f55c 100644 --- a/src/util/output.rs +++ b/src/util/output.rs @@ -183,6 +183,34 @@ pub fn success_text(data: Value) { } } +pub fn print_table(header: &[&str], rows: &[Vec]) { + let widths: Vec = header + .iter() + .enumerate() + .map(|(column, name)| { + rows.iter() + .map(|row| row.get(column).map_or(0, String::len)) + .chain(std::iter::once(name.len())) + .max() + .unwrap_or(0) + }) + .collect(); + let line = |cells: Vec<&str>| { + cells + .iter() + .enumerate() + .map(|(column, cell)| format!("{cell:>() + .join(" ") + .trim_end() + .to_string() + }; + println!("{}", line(header.to_vec())); + for row in rows { + println!("{}", line(row.iter().map(String::as_str).collect())); + } +} + /// Format a top-level error for output, then exit. Called from main. pub fn handle_error(err: &anyhow::Error) -> ! { if let Some(SilentExit(code)) = err.downcast_ref::() { diff --git a/src/util/pairs.rs b/src/util/pairs.rs new file mode 100644 index 0000000..adfa951 --- /dev/null +++ b/src/util/pairs.rs @@ -0,0 +1,44 @@ +use std::collections::BTreeMap; + +use anyhow::{Result, bail}; + +pub fn parse_pairs(flag: &str, shape: &str, pairs: &[String]) -> Result> { + let mut parsed = BTreeMap::new(); + for pair in pairs { + let Some((name, value)) = pair.split_once('=') else { + bail!("{flag} wants {shape}, got {pair:?}."); + }; + if name.is_empty() { + bail!("{flag} wants a name before the '=', got {pair:?}."); + } + if parsed.insert(name.to_string(), value.to_string()).is_some() { + bail!("{flag} {name} was given twice."); + } + } + Ok(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_pairs_and_refuses_bad_ones() { + assert!(parse_pairs("--env", "KEY=VALUE", &[]).unwrap().is_empty()); + let parsed = parse_pairs( + "--env", + "KEY=VALUE", + &["A=1".into(), "B=x=y".into(), "C=".into()], + ) + .unwrap(); + assert_eq!(parsed["A"], "1"); + assert_eq!(parsed["B"], "x=y"); + assert_eq!(parsed["C"], ""); + let err = parse_pairs("--env", "KEY=VALUE", &["NOPE".into()]).unwrap_err(); + assert!(err.to_string().contains("--env wants KEY=VALUE")); + assert!(parse_pairs("--env", "KEY=VALUE", &["=1".into()]).is_err()); + let err = + parse_pairs("--secret", "NAME=SECRET_ID", &["A=1".into(), "A=2".into()]).unwrap_err(); + assert!(err.to_string().contains("--secret A was given twice")); + } +} diff --git a/tests/cli-spec.json b/tests/cli-spec.json index 83a0506..00c5150 100644 --- a/tests/cli-spec.json +++ b/tests/cli-spec.json @@ -61,6 +61,16 @@ { "name": "stop" } ] }, + { + "name": "environment", + "subcommands": [ + { "name": "create" }, + { "name": "delete" }, + { "name": "get" }, + { "name": "list" }, + { "name": "update" } + ] + }, { "name": "forge" }, { "name": "login" }, { "name": "logout" }, @@ -76,6 +86,16 @@ }, { "name": "scrape" }, { "name": "screenshot" }, + { + "name": "secret", + "subcommands": [ + { "name": "create" }, + { "name": "delete" }, + { "name": "get" }, + { "name": "list" }, + { "name": "update" } + ] + }, { "name": "update" } ] } diff --git a/tests/computer_create.rs b/tests/computer_create.rs new file mode 100644 index 0000000..dffc545 --- /dev/null +++ b/tests/computer_create.rs @@ -0,0 +1,100 @@ +//! End-to-end tests for `steel computer create` with secrets, an environment and a project. + +use std::process::{Command, Output}; + +use serde_json::json; +use wiremock::matchers::{body_json, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const COMPUTER: &str = "cmp_0000123456789abcdefghjkmnpqrs"; +const ENVIRONMENT: &str = "9b2d1c5e-1b2a-4c3d-8e4f-5a6b7c8d9e0f"; +const SECRET: &str = "2f1b6f2e-2c6f-4d9a-9c2a-8f4c1e0d7b31"; +const PROJECT: &str = "0c1d2e3f-4a5b-4c6d-8e7f-8091a2b3c4d5"; + +async fn run_steel(server: &MockServer, args: &[&str]) -> Output { + let tmp = tempfile::tempdir().expect("temp dir"); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_steel")); + cmd.env("STEEL_CONFIG_DIR", tmp.path()); + cmd.env("STEEL_API_URL", format!("{}/v1", server.uri())); + cmd.env("STEEL_API_KEY", "ste-test-key"); + cmd.env("STEEL_TELEMETRY_DISABLED", "1"); + cmd.env("STEEL_FORCE_TTY", "1"); + cmd.env_remove("STEEL_COMPUTER_ID"); + cmd.arg("--no-update-check"); + cmd.args(args); + tokio::task::spawn_blocking(move || { + let output = cmd.output().expect("failed to execute steel binary"); + drop(tmp); + output + }) + .await + .expect("steel process") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} + +#[tokio::test(flavor = "multi_thread")] +async fn create_sends_secrets_environment_and_project() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/computers")) + .and(body_json(json!({ + "environmentId": ENVIRONMENT, + "secrets": { "TOKEN": SECRET }, + "projectId": PROJECT, + "env": { "A": "1" } + }))) + .respond_with( + ResponseTemplate::new(201) + .set_body_json(json!({ "id": COMPUTER, "status": "starting" })), + ) + .expect(1) + .mount(&server) + .await; + + let output = run_steel( + &server, + &[ + "computer", + "create", + "--environment", + ENVIRONMENT, + "--secret", + &format!("TOKEN={SECRET}"), + "--project", + PROJECT, + "--env", + "A=1", + ], + ) + .await; + + assert!(output.status.success(), "{}", stderr(&output)); + assert!(stdout(&output).contains(&format!("{COMPUTER} is starting."))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn create_refuses_plain_values_for_secret() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/computers")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&server) + .await; + + let output = run_steel( + &server, + &["computer", "create", "--secret", "TOKEN=sk-live-abc"], + ) + .await; + + assert!(!output.status.success()); + assert!(stderr(&output).contains("steel secret create")); +} diff --git a/tests/environment_commands.rs b/tests/environment_commands.rs new file mode 100644 index 0000000..8333ff7 --- /dev/null +++ b/tests/environment_commands.rs @@ -0,0 +1,256 @@ +//! End-to-end tests for `steel environment` against a fake API host. + +use std::process::{Command, Output}; + +use serde_json::json; +use wiremock::matchers::{body_json, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ENVIRONMENT: &str = "9b2d1c5e-1b2a-4c3d-8e4f-5a6b7c8d9e0f"; +const SECRET: &str = "2f1b6f2e-2c6f-4d9a-9c2a-8f4c1e0d7b31"; +const PROJECT: &str = "0c1d2e3f-4a5b-4c6d-8e7f-8091a2b3c4d5"; + +async fn run_steel(server: &MockServer, args: &[&str]) -> Output { + let tmp = tempfile::tempdir().expect("temp dir"); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_steel")); + cmd.env("STEEL_CONFIG_DIR", tmp.path()); + cmd.env("STEEL_API_URL", format!("{}/v1", server.uri())); + cmd.env("STEEL_API_KEY", "ste-test-key"); + cmd.env("STEEL_TELEMETRY_DISABLED", "1"); + cmd.env("STEEL_FORCE_TTY", "1"); + cmd.arg("--no-update-check"); + cmd.args(args); + tokio::task::spawn_blocking(move || { + let output = cmd.output().expect("failed to execute steel binary"); + drop(tmp); + output + }) + .await + .expect("steel process") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} + +fn environment() -> serde_json::Value { + json!({ + "id": ENVIRONMENT, + "name": "dev", + "version": 1, + "spec": { "template": "steel", "vcpu": 2, "env": { "A": "1" } }, + "secrets": { "TOKEN": SECRET, "OLD": SECRET }, + "networkSecrets": [], + "createdAt": "2026-09-12T00:00:00Z", + "updatedAt": "2026-09-12T01:00:00Z" + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn environment_list_prints_a_table() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/environments")) + .and(query_param("projectId", PROJECT)) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({ "environments": [environment()] })), + ) + .expect(1) + .mount(&server) + .await; + + let output = run_steel(&server, &["environment", "list", "--project", PROJECT]).await; + + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + assert!(text.contains("TEMPLATE")); + assert!(text.contains(ENVIRONMENT)); + assert!(text.contains("dev")); + assert!(text.contains("steel")); + assert!(text.contains('2')); +} + +#[tokio::test(flavor = "multi_thread")] +async fn environment_create_sends_the_spec_and_secrets() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/environments")) + .and(body_json(json!({ + "name": "dev", + "projectId": PROJECT, + "spec": { "template": "steel", "vcpu": 2, "autoPause": true, "env": { "A": "1" } }, + "secrets": { "TOKEN": { "secretId": SECRET }, "KEY": { "value": "abc" } } + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(environment())) + .expect(1) + .mount(&server) + .await; + + let output = run_steel( + &server, + &[ + "environment", + "create", + "dev", + "--template", + "steel", + "--vcpu", + "2", + "--auto-pause", + "--env", + "A=1", + "--secret", + &format!("TOKEN={SECRET}"), + "--secret-value", + "KEY=abc", + "--project", + PROJECT, + ], + ) + .await; + + assert!(output.status.success(), "{}", stderr(&output)); + assert!(stdout(&output).contains(&format!("Created dev as {ENVIRONMENT}."))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn environment_create_refuses_plain_values_for_secret() { + let server = MockServer::start().await; + + let output = run_steel( + &server, + &[ + "environment", + "create", + "dev", + "--secret", + "TOKEN=sk-live-abc", + ], + ) + .await; + + assert!(!output.status.success()); + assert!(stderr(&output).contains("steel secret create")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn environment_update_merges_the_spec_and_unsets_secrets() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/v1/environments/{ENVIRONMENT}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(environment())) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path(format!("/v1/environments/{ENVIRONMENT}"))) + .and(body_json(json!({ + "name": "staging", + "spec": { "template": "steel", "vcpu": 2, "memoryMib": 4096, "env": { "A": "1", "B": "2" } }, + "secrets": { "OLD": null } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(environment())) + .expect(1) + .mount(&server) + .await; + + let output = run_steel( + &server, + &[ + "environment", + "update", + ENVIRONMENT, + "--name", + "staging", + "--memory", + "4096", + "--env", + "B=2", + "--unset-secret", + "OLD", + ], + ) + .await; + + assert!(output.status.success(), "{}", stderr(&output)); + assert!(stdout(&output).contains(&format!("Updated {ENVIRONMENT}."))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn environment_update_skips_the_fetch_without_spec_changes() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/v1/environments/{ENVIRONMENT}"))) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path(format!("/v1/environments/{ENVIRONMENT}"))) + .and(body_json( + json!({ "secrets": { "TOKEN": { "secretId": SECRET } } }), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(environment())) + .expect(1) + .mount(&server) + .await; + + let output = run_steel( + &server, + &[ + "environment", + "update", + ENVIRONMENT, + "--secret", + &format!("TOKEN={SECRET}"), + ], + ) + .await; + + assert!(output.status.success(), "{}", stderr(&output)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn environment_update_needs_a_change() { + let server = MockServer::start().await; + + let output = run_steel(&server, &["environment", "update", ENVIRONMENT]).await; + + assert!(!output.status.success()); + assert!(stderr(&output).contains("--unset-secret")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn environment_get_and_delete_use_the_id() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/v1/environments/{ENVIRONMENT}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(environment())) + .expect(1) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path(format!("/v1/environments/{ENVIRONMENT}"))) + .and(query_param("projectId", PROJECT)) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + + let output = run_steel(&server, &["--json", "environment", "get", ENVIRONMENT]).await; + assert!(output.status.success(), "{}", stderr(&output)); + let parsed: serde_json::Value = serde_json::from_str(&stdout(&output)).expect("json"); + assert_eq!(parsed["data"]["name"], "dev"); + + let output = run_steel( + &server, + &["environment", "delete", ENVIRONMENT, "--project", PROJECT], + ) + .await; + assert!(output.status.success(), "{}", stderr(&output)); + assert!(stdout(&output).contains(&format!("Deleted {ENVIRONMENT}."))); +} diff --git a/tests/secret_commands.rs b/tests/secret_commands.rs new file mode 100644 index 0000000..7021e1e --- /dev/null +++ b/tests/secret_commands.rs @@ -0,0 +1,256 @@ +//! End-to-end tests for `steel secret` against a fake API host. + +use std::io::Write; +use std::process::{Command, Output, Stdio}; + +use serde_json::json; +use wiremock::matchers::{body_json, header, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const SECRET: &str = "2f1b6f2e-2c6f-4d9a-9c2a-8f4c1e0d7b31"; +const PROJECT: &str = "0c1d2e3f-4a5b-4c6d-8e7f-8091a2b3c4d5"; + +fn command(server: &MockServer, dir: &std::path::Path, args: &[&str]) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_steel")); + cmd.env("STEEL_CONFIG_DIR", dir); + cmd.env("STEEL_API_URL", format!("{}/v1", server.uri())); + cmd.env("STEEL_API_KEY", "ste-test-key"); + cmd.env("STEEL_TELEMETRY_DISABLED", "1"); + cmd.env("STEEL_FORCE_TTY", "1"); + cmd.arg("--no-update-check"); + cmd.args(args); + cmd +} + +async fn run_steel(server: &MockServer, args: &[&str]) -> Output { + let tmp = tempfile::tempdir().expect("temp dir"); + let mut cmd = command(server, tmp.path(), args); + tokio::task::spawn_blocking(move || { + let output = cmd.output().expect("failed to execute steel binary"); + drop(tmp); + output + }) + .await + .expect("steel process") +} + +async fn run_steel_with_stdin(server: &MockServer, args: &[&str], stdin: &str) -> Output { + let tmp = tempfile::tempdir().expect("temp dir"); + let mut cmd = command(server, tmp.path(), args); + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let input = stdin.to_string(); + tokio::task::spawn_blocking(move || { + let mut child = cmd.spawn().expect("failed to execute steel binary"); + child + .stdin + .take() + .expect("stdin") + .write_all(input.as_bytes()) + .expect("write stdin"); + let output = child.wait_with_output().expect("steel process"); + drop(tmp); + output + }) + .await + .expect("steel process") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} + +fn secret(name: &str, version: u64) -> serde_json::Value { + json!({ + "id": SECRET, + "name": name, + "source": "steel", + "version": version, + "createdAt": "2026-09-12T00:00:00Z", + "updatedAt": "2026-09-12T01:00:00Z" + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn secret_list_prints_a_table() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/secrets")) + .and(header("steel-api-key", "ste-test-key")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({ "secrets": [secret("TOKEN", 3)] })), + ) + .expect(1) + .mount(&server) + .await; + + let output = run_steel(&server, &["secret", "list"]).await; + + assert!(output.status.success()); + let text = stdout(&output); + assert!(text.contains("ID")); + assert!(text.contains("VERSION")); + assert!(text.contains(SECRET)); + assert!(text.contains("TOKEN")); + assert!(text.contains('3')); +} + +#[tokio::test(flavor = "multi_thread")] +async fn secret_list_scopes_to_a_project() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/secrets")) + .and(query_param("projectId", PROJECT)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "secrets": [] }))) + .expect(1) + .mount(&server) + .await; + + let output = run_steel(&server, &["secret", "list", "--project", PROJECT]).await; + + assert!(output.status.success()); + assert!(stdout(&output).contains("No secrets.")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn secret_create_sends_the_value_and_project() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/secrets")) + .and(body_json(json!({ + "name": "TOKEN", + "value": "sk-live-abc", + "projectId": PROJECT + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(secret("TOKEN", 1))) + .expect(1) + .mount(&server) + .await; + + let output = run_steel( + &server, + &[ + "secret", + "create", + "TOKEN", + "--value", + "sk-live-abc", + "--project", + PROJECT, + ], + ) + .await; + + assert!(output.status.success(), "{}", stderr(&output)); + assert!(stdout(&output).contains(&format!("Created TOKEN as {SECRET}."))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn secret_create_reads_the_value_from_stdin() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/secrets")) + .and(body_json(json!({ "name": "TOKEN", "value": "from stdin" }))) + .respond_with(ResponseTemplate::new(201).set_body_json(secret("TOKEN", 1))) + .expect(1) + .mount(&server) + .await; + + let output = run_steel_with_stdin( + &server, + &["secret", "create", "TOKEN", "--value-stdin"], + "from stdin\n", + ) + .await; + + assert!(output.status.success(), "{}", stderr(&output)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn secret_create_needs_a_value() { + let server = MockServer::start().await; + + let output = run_steel(&server, &["secret", "create", "TOKEN"]).await; + + assert!(!output.status.success()); + assert!(stderr(&output).contains("--value")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn secret_get_prints_the_metadata() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/v1/secrets/{SECRET}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(secret("TOKEN", 2))) + .expect(1) + .mount(&server) + .await; + + let output = run_steel(&server, &["--json", "secret", "get", SECRET]).await; + + assert!(output.status.success()); + let parsed: serde_json::Value = serde_json::from_str(&stdout(&output)).expect("json"); + assert_eq!(parsed["data"]["id"], SECRET); + assert_eq!(parsed["data"]["version"], 2); +} + +#[tokio::test(flavor = "multi_thread")] +async fn secret_update_sends_only_the_given_fields() { + let server = MockServer::start().await; + Mock::given(method("PATCH")) + .and(path(format!("/v1/secrets/{SECRET}"))) + .and(query_param("projectId", PROJECT)) + .and(body_json(json!({ "value": "rotated" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(secret("TOKEN", 2))) + .expect(1) + .mount(&server) + .await; + + let output = run_steel( + &server, + &[ + "secret", + "update", + SECRET, + "--value", + "rotated", + "--project", + PROJECT, + ], + ) + .await; + + assert!(output.status.success(), "{}", stderr(&output)); + assert!(stdout(&output).contains(&format!("Updated {SECRET}."))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn secret_update_needs_a_change() { + let server = MockServer::start().await; + + let output = run_steel(&server, &["secret", "update", SECRET]).await; + + assert!(!output.status.success()); + assert!(stderr(&output).contains("--name")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn secret_delete_reports_the_id() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path(format!("/v1/secrets/{SECRET}"))) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + + let output = run_steel(&server, &["secret", "delete", SECRET]).await; + + assert!(output.status.success(), "{}", stderr(&output)); + assert!(stdout(&output).contains(&format!("Deleted {SECRET}."))); +}