diff --git a/Cargo.lock b/Cargo.lock index 4284f0e0a..1d357cc15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -531,6 +531,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -1370,6 +1385,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" name = "basilica-cli" version = "0.29.0" dependencies = [ + "assert_cmd", "axum 0.7.9", "base64 0.21.7", "basilica-common", @@ -1396,6 +1412,7 @@ dependencies = [ "semver 1.0.27", "serde", "serde_json", + "serial_test", "sha2 0.10.9", "shellexpand", "ssh-key", @@ -1834,6 +1851,17 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -2621,6 +2649,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.9.0" @@ -5595,6 +5629,33 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -6813,6 +6874,15 @@ dependencies = [ "yap", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.28" @@ -6904,6 +6974,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "seahash" version = "4.1.0" @@ -7239,6 +7315,32 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "sha1" version = "0.10.6" @@ -8603,6 +8705,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.69" diff --git a/crates/basilica-cli/CHANGELOG.md b/crates/basilica-cli/CHANGELOG.md index 148e9c9c2..8cf80f342 100644 --- a/crates/basilica-cli/CHANGELOG.md +++ b/crates/basilica-cli/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Non-interactive mode for agent and CI usage. Auto-detected when stdin + is not a TTY, or forced with `BASILICA_NON_INTERACTIVE=1`. In this + mode every interactive prompt surfaces a structured error instead of + blocking on stdin. + - Errors carry a `field` identifier and a `hint` naming the flag or + command to run. Rendered as single-line JSON on stderr when + `--json` is set, and as human-readable text otherwise. + +### Changed +- OAuth auto-login is skipped in non-interactive mode: commands that + need authentication now exit with a `missing_prerequisite` error + pointing at `basilica login` (or `BASILICA_API_TOKEN`) instead of + starting a callback server that would block for 300s. +- `basilica ssh-keys add` no longer silently generates a new key under + `~/.ssh` in non-interactive mode when no local public keys are + present; pass `--file ` to an existing public key instead. + ## [0.29.0] - 2026-05-04 ### Added diff --git a/crates/basilica-cli/Cargo.toml b/crates/basilica-cli/Cargo.toml index d0581bdf4..aea003c33 100644 --- a/crates/basilica-cli/Cargo.toml +++ b/crates/basilica-cli/Cargo.toml @@ -75,3 +75,5 @@ basilica-sdk = { path = "../basilica-sdk" } basilica-validator = { path = "../basilica-validator", features = ["client", "cli"] } [dev-dependencies] +serial_test = "3" +assert_cmd = "2" diff --git a/crates/basilica-cli/src/cli/args.rs b/crates/basilica-cli/src/cli/args.rs index bb44b407d..675c4b13e 100644 --- a/crates/basilica-cli/src/cli/args.rs +++ b/crates/basilica-cli/src/cli/args.rs @@ -101,6 +101,19 @@ impl Args { Err(err) => { // Check if this is specifically a login_required error if matches!(&err, CliError::Auth(_)) { + // In non-interactive mode, never start an OAuth flow — the + // callback server / device flow will block on user action + // (300s timeout). Surface a structured error instead. + if matches!( + crate::interactive::gate::current(), + crate::interactive::gate::Interactivity::NonInteractive + ) { + return Err(CliError::MissingPrerequisite { + field: "authentication".into(), + hint: "Not authenticated. Run `basilica login` (or `basilica login --device-code` on headless hosts), or set BASILICA_API_TOKEN.".into(), + }); + } + // Inform user we need to authenticate println!("You need to authenticate to continue."); println!(); diff --git a/crates/basilica-cli/src/cli/handlers/ssh_keys.rs b/crates/basilica-cli/src/cli/handlers/ssh_keys.rs index 8fab4b19c..c2d892b44 100644 --- a/crates/basilica-cli/src/cli/handlers/ssh_keys.rs +++ b/crates/basilica-cli/src/cli/handlers/ssh_keys.rs @@ -1,11 +1,12 @@ //! SSH key management handlers for the Basilica CLI use crate::error::CliError; -use crate::output::{compress_path, json_output, print_success}; -use crate::ssh::find_local_public_key_path; +use crate::interactive::gate::{self, ask_confirm, ask_select, ask_text, Interactivity}; +use crate::output::{compress_path, json_output, print_success, print_warning}; +use crate::ssh::{find_local_public_key_path, same_public_key}; use basilica_sdk::BasilicaClient; use console::style; -use dialoguer::{theme::ColorfulTheme, Confirm, Input}; +use dialoguer::theme::ColorfulTheme; use etcetera::{choose_base_strategy, BaseStrategy}; use ssh_key::PublicKey; use std::fs; @@ -249,41 +250,43 @@ pub async fn handle_add_ssh_key( let keys = find_ssh_public_keys(); if keys.is_empty() { - // No keys found, generate one automatically - generate_ssh_key().await? + // No local keys to choose from. Generate one in interactive + // mode; in non-interactive mode refuse to mutate the user's + // ~/.ssh directory silently and tell them to pass --file. + match gate::current() { + Interactivity::NonInteractive => { + return Err(CliError::MissingInput { + field: "ssh_public_key_path".into(), + hint: "No SSH public keys found under ~/.ssh. Pass --file to an existing SSH public key, e.g. ~/.ssh/id_ed25519.pub.".into(), + }); + } + Interactivity::Interactive => generate_ssh_key().await?, + } } else { - // Show interactive selection with existing keys - use dialoguer::Select; - - let options: Vec = keys + let labels: Vec = keys .iter() - .map(|path| { - path.file_name() + .map(|p| { + p.file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string() }) .collect(); + let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + + let idx = ask_select( + "ssh_public_key_path", + "Select an SSH key to register", + &label_refs, + "Pass --file to choose an SSH public key file (e.g. ~/.ssh/id_ed25519.pub)", + )?; - // Run interactive selection in a blocking context - let selection = tokio::task::spawn_blocking(move || { - let theme = ColorfulTheme::default(); - Select::with_theme(&theme) - .with_prompt("Select an SSH key to register") - .items(&options) - .default(0) - .interact() - }) - .await - .map_err(|e| CliError::Internal(color_eyre::eyre::eyre!("Task join error: {}", e)))? - .map_err(|e| CliError::Internal(e.into()))?; - - let path = &keys[selection]; + let path = keys[idx].clone(); println!( "{}", style(format!("Selected SSH public key: {}", path.display())).cyan() ); - path.clone() + path } } }; @@ -303,7 +306,7 @@ pub async fn handle_add_ssh_key( ))); } - // Step 3: Get name interactively if not provided + // Step 3: Get name (from flag, or via the gate) let name = match name { Some(n) => { if n.trim().is_empty() { @@ -319,17 +322,13 @@ pub async fn handle_add_ssh_key( n } None => { - let input: String = tokio::task::spawn_blocking(move || { - let theme = ColorfulTheme::default(); - Input::with_theme(&theme) - .with_prompt("Enter a name for this SSH key") - .default("default".to_string()) - .interact_text() - }) - .await - .map_err(|e| CliError::Internal(color_eyre::eyre::eyre!("Task join error: {}", e)))? - .map_err(|e| CliError::Internal(e.into()))?; - + let default_name = basilica_common::rental::generate_random_rental_name(); + let input = ask_text( + "name", + "Enter a name for this SSH key", + Some(&default_name), + "Pass --name