From 78e7584edcfc6e5fcf5842f7ef2442a891c5bea9 Mon Sep 17 00:00:00 2001 From: Joshua Smock Date: Thu, 9 Jul 2026 18:48:39 +0200 Subject: [PATCH 1/6] Use ARM runners (#321) --- .github/workflows/build-binaries.yml | 80 ++++++++++------------------ 1 file changed, 29 insertions(+), 51 deletions(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index bf9e6eb5..884d691b 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -42,6 +42,7 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 - uses: actions/setup-python@v6 with: @@ -72,6 +73,7 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - name: "Install fresh upstream rustup" # The macos-15-arm64 runner image (20260511.0048+) ships a broken # Homebrew rustup whose bundled rustup-init rejects standard argv @@ -117,6 +119,7 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - name: "Install fresh upstream rustup" # The macos-15-arm64 runner image (20260511.0048+) ships a broken # Homebrew rustup whose bundled rustup-init rejects standard argv @@ -170,6 +173,7 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 - uses: actions/setup-python@v6 with: @@ -204,16 +208,16 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - architecture: x64 - name: Build wheel uses: PyO3/maturin-action@v1.50.1 with: target: ${{ matrix.platform.target }} - manylinux: auto + manylinux: 2_28 args: --release --locked --out dist -i python${{ matrix.python-version }} before-script-linux: | # If we're running on rhel centos, install needed packages. @@ -237,23 +241,20 @@ jobs: name: wheels-${{ matrix.platform.target }}-py${{ matrix.python-version }} path: dist - linux-cross: + linux-arm: if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-build') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-arm strategy: matrix: platform: - target: aarch64-unknown-linux-gnu - arch: aarch64 - # see https://github.com/astral-sh/ruff/issues/3791 - # and https://github.com/gnzlbg/jemallocator/issues/170#issuecomment-1503228963 - maturin_docker_options: -e JEMALLOC_SYS_WITH_LG_PAGE=16 -e PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 python-version: ["3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 with: submodules: recursive persist-credentials: false + - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -262,35 +263,14 @@ jobs: with: target: ${{ matrix.platform.target }} manylinux: 2_28 - docker-options: ${{ matrix.platform.maturin_docker_options }} - args: --release --locked --out dist -i ${{ format('python{0}', matrix.python-version) }} - env: - CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse - - uses: uraimo/run-on-arch-action@v3 - if: ${{ matrix.platform.arch != 'ppc64' && matrix.platform.arch != 'ppc64le'}} - name: Test wheel - with: - arch: ${{ matrix.platform.arch }} - distro: ubuntu24.04 - githubToken: ${{ github.token }} - install: | - apt-get update - apt-get install -y software-properties-common - add-apt-repository ppa:deadsnakes/ppa - apt-get update - apt-get install -y --no-install-recommends ${{ format('python{0}-full', matrix.python-version) }} ${{ format('python{0}-venv', matrix.python-version) }} cargo libffi-dev - # Create and use a virtual environment to avoid the externally-managed-environment error - run: | - ln -s -f /usr/bin/${{ format('python{0}', matrix.python-version) }} /usr/bin/python3 - ln -s -f /usr/bin/${{ format('pip{0}', matrix.python-version) }} /usr/bin/pip3 - # Workaround for QEMU bug when emulating 32-bit on 64-bit hosts - # See: https://github.com/docker/buildx/issues/395 - # Use /tmp for CARGO_HOME (often tmpfs, which avoids the QEMU filesystem bug) - export CARGO_HOME=/tmp/cargo-home - python3 -m venv /tmp/venv - . /tmp/venv/bin/activate - pip3 install dist/*.whl --force-reinstall - ${{ env.EXECUTABLE_NAME }} --help + args: --release --locked --out dist -i python${{ matrix.python-version }} + - name: Test wheel + if: ${{ startsWith(matrix.platform.target, 'aarch64') }} + run: | + PYTAG="cp$(python -c "import sys; print(f'{sys.version_info.major}{sys.version_info.minor}')")" + pip install dist/*-${PYTAG}-*.whl --force-reinstall + ${{ env.EXECUTABLE_NAME }} --help + python -m ${{ env.MODULE_NAME }} --help - name: Upload wheel uses: actions/upload-artifact@v6 with: @@ -309,6 +289,7 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -337,19 +318,19 @@ jobs: name: wheels-${{ matrix.platform.target }}-py${{ matrix.python-version }} path: dist - musllinux-cross: + musllinux-arm: if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-build') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-arm strategy: matrix: platform: - target: aarch64-unknown-linux-musl - arch: aarch64 python-version: ["3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -359,18 +340,15 @@ jobs: target: ${{ matrix.platform.target }} manylinux: musllinux_1_2 args: --release --locked --out dist -i python${{ matrix.python-version }} - - uses: uraimo/run-on-arch-action@v2 - name: Test wheel - with: - arch: ${{ matrix.platform.arch }} - distro: alpine_latest - githubToken: ${{ github.token }} - install: | - apk add uv rust + - name: Test wheel + if: matrix.platform.target == 'aarch64-unknown-linux-musl' + uses: addnab/docker-run-action@v3 + with: + image: python:${{ matrix.python-version}}-alpine + options: -v ${{ github.workspace }}:/io -w /io run: | - uv python install ${{ matrix.python-version }} --default - export PATH="/root/.local/bin:$PATH" - python3 -m venv .venv + apk add rust + python -m venv .venv .venv/bin/pip3 install dist/*.whl --force-reinstall .venv/bin/${{ env.EXECUTABLE_NAME }} --help - name: Upload wheel From 6f3863f586601be7726f7cc7019a5632a3b5da20 Mon Sep 17 00:00:00 2001 From: Joshua Smock Date: Fri, 10 Jul 2026 10:48:08 +0200 Subject: [PATCH 2/6] Add Rust cache to all jobs (#323) --- .github/workflows/build-binaries.yml | 34 ++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 884d691b..fa9006dc 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -44,6 +44,10 @@ jobs: submodules: recursive persist-credentials: false - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: sdist - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} @@ -94,6 +98,10 @@ jobs: # freshly-installed upstream rustup with a stale (and possibly # Homebrew-linked) binary from a previous run. cache-bin: false + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: py${{ matrix.python-version }}-macos-x86_64 - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -140,6 +148,10 @@ jobs: # freshly-installed upstream rustup with a stale (and possibly # Homebrew-linked) binary from a previous run. cache-bin: false + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: py${{ matrix.python-version }}-macos-aarch64 - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -175,6 +187,10 @@ jobs: submodules: recursive persist-credentials: false - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: py${{ matrix.python-version }}-${{ matrix.platform.target }} - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -210,6 +226,10 @@ jobs: submodules: recursive persist-credentials: false - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: py${{ matrix.python-version }}-${{ matrix.platform.target }} - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -255,6 +275,10 @@ jobs: submodules: recursive persist-credentials: false - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: py${{ matrix.python-version }}-${{ matrix.platform.target }} - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -293,8 +317,10 @@ jobs: - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - name: Install OpenSSL - run: sudo apt-get update && sudo apt-get install libssl-dev openssl + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: py${{ matrix.python-version }}-${{ matrix.platform.target }} - name: Build wheel uses: PyO3/maturin-action@v1.50.1 with: @@ -334,6 +360,10 @@ jobs: - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: py${{ matrix.python-version }}-${{ matrix.platform.target }} - name: Build wheel uses: PyO3/maturin-action@v1.50.1 with: From 24b6f146227c9cc9582c6ed216cb3b565c7850e0 Mon Sep 17 00:00:00 2001 From: Konstantinos St Date: Tue, 14 Jul 2026 07:17:46 -0300 Subject: [PATCH 3/6] feat: add beta label to CLI output (#324) --- Cargo.lock | 1 + crates/config/Cargo.toml | 3 + crates/config/src/lib.rs | 4 +- crates/config/src/session.rs | 104 ++++++++++++++++++++++++++++++- crates/tower-cmd/src/beta.rs | 103 ++++++++++++++++++++++++++++++ crates/tower-cmd/src/catalogs.rs | 70 +++++++++++++++++++-- crates/tower-cmd/src/lib.rs | 16 +++++ crates/tower-cmd/src/output.rs | 5 ++ 8 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 crates/tower-cmd/src/beta.rs diff --git a/Cargo.lock b/Cargo.lock index 013fb4b7..0e39ade0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -501,6 +501,7 @@ dependencies = [ "serde", "serde_json", "snafu", + "tempfile", "testutils", "tokio", "tower-api", diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 3cf75e69..60b85e58 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -21,3 +21,6 @@ url = { workspace = true } tower-api = { workspace = true } tower-package = { workspace = true } tower-telemetry = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 2e4a48a2..07b36f3d 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -10,7 +10,9 @@ pub use error::Error; pub use session::{default_tower_url, Session, Team, Token, User}; pub use tower_package::{Parameter, Towerfile}; -pub use session::{get_last_version_check_timestamp, set_last_version_check_timestamp}; +pub use session::{ + claim_notice, get_last_version_check_timestamp, set_last_version_check_timestamp, +}; #[derive(Clone, Serialize, Deserialize)] pub struct Config { diff --git a/crates/config/src/session.rs b/crates/config/src/session.rs index 44bea0e4..5537161b 100644 --- a/crates/config/src/session.rs +++ b/crates/config/src/session.rs @@ -1,9 +1,10 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use chrono::{DateTime, TimeZone, Utc}; use serde::{Deserialize, Serialize}; -use std::fs; +use std::fs::{self, OpenOptions}; use std::future::Future; -use std::path::PathBuf; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; use url::Url; use crate::error::Error; @@ -87,6 +88,43 @@ fn find_or_create_config_dir() -> Result { Ok(config_dir) } +/// Atomically claims a persistent, user-level CLI notice. +/// +/// Returns `true` only for the first process to claim this notice ID. Notice +/// IDs are internal constants and may contain ASCII letters, digits, `-`, and +/// `_` only. +pub fn claim_notice(id: &str) -> std::io::Result { + let config_dir = find_or_create_config_dir() + .map_err(|err| std::io::Error::other(format!("finding Tower config directory: {err}")))?; + claim_notice_at(&config_dir, id) +} + +fn claim_notice_at(base_dir: &Path, id: &str) -> std::io::Result { + if id.is_empty() + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "invalid notice ID", + )); + } + + let notices_dir = base_dir.join("notices"); + fs::create_dir_all(¬ices_dir)?; + + match OpenOptions::new() + .write(true) + .create_new(true) + .open(notices_dir.join(id)) + { + Ok(_) => Ok(true), + Err(err) if err.kind() == ErrorKind::AlreadyExists => Ok(false), + Err(err) => Err(err), + } +} + pub fn get_last_version_check_timestamp() -> DateTime { let default: DateTime = Utc.timestamp_opt(0, 0).unwrap(); @@ -346,3 +384,65 @@ where { tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future)) } + +#[cfg(test)] +mod tests { + use std::{fs, sync::Arc, thread}; + + use tempfile::TempDir; + + use super::claim_notice_at; + + #[test] + fn notice_can_only_be_claimed_once() { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + + assert!(claim_notice_at(temp_dir.path(), "storage-beta-v1").unwrap()); + assert!(!claim_notice_at(temp_dir.path(), "storage-beta-v1").unwrap()); + } + + #[test] + fn notice_generations_are_independent() { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + + assert!(claim_notice_at(temp_dir.path(), "storage-beta-v1").unwrap()); + assert!(claim_notice_at(temp_dir.path(), "storage-beta-v2").unwrap()); + } + + #[test] + fn concurrent_notice_claims_have_one_winner() { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + let base_dir = Arc::new(temp_dir.path().to_path_buf()); + let handles = (0..8) + .map(|_| { + let base_dir = Arc::clone(&base_dir); + thread::spawn(move || claim_notice_at(&base_dir, "storage-beta-v1").unwrap()) + }) + .collect::>(); + + let winners = handles + .into_iter() + .map(|handle| handle.join().expect("claim thread should finish")) + .filter(|claimed| *claimed) + .count(); + + assert_eq!(winners, 1); + } + + #[test] + fn notice_claim_rejects_invalid_ids() { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + + assert!(claim_notice_at(temp_dir.path(), "../outside").is_err()); + assert!(claim_notice_at(temp_dir.path(), "").is_err()); + } + + #[test] + fn notice_claim_reports_unavailable_base_directory() { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + let base_dir = temp_dir.path().join("config-file"); + fs::write(&base_dir, "not a directory").expect("fixture should be written"); + + assert!(claim_notice_at(&base_dir, "storage-beta-v1").is_err()); + } +} diff --git a/crates/tower-cmd/src/beta.rs b/crates/tower-cmd/src/beta.rs new file mode 100644 index 00000000..fb6c0c29 --- /dev/null +++ b/crates/tower-cmd/src/beta.rs @@ -0,0 +1,103 @@ +use std::io::{self, IsTerminal}; + +use tower_telemetry::debug; + +use crate::output::{self, OutputMode}; + +pub(crate) struct BetaFeature { + id: &'static str, + message: &'static str, + docs_url: Option<&'static str>, +} + +impl BetaFeature { + pub fn short_about(&self, description: &str) -> String { + format!("{description} [beta]") + } + + pub fn notice(&self) -> String { + match self.docs_url { + Some(url) => format!("{} Learn more: {url}", self.message), + None => self.message.to_string(), + } + } +} + +pub(crate) const STORAGE_BETA_MESSAGE: &str = "Tower Storage is in beta. Core functionality is stable, but some featues and interfaces might change before general availability."; + +pub(crate) const STORAGE: BetaFeature = BetaFeature { + id: "storage-beta-v1", + message: STORAGE_BETA_MESSAGE, + docs_url: None, +}; + +pub(crate) fn notify_once(feature: &BetaFeature) { + let output_mode = output::get_output_mode(); + let stdout_is_terminal = io::stdout().is_terminal(); + let stderr_is_terminal = io::stderr().is_terminal(); + + if !should_notify(output_mode, stdout_is_terminal, stderr_is_terminal) { + return; + } + + match config::claim_notice(feature.id) { + Ok(true) => output::notice_to_stderr("Beta:", &feature.notice()), + Ok(false) => {} + Err(err) => debug!("Failed to persist CLI notice {}: {}", feature.id, err), + } +} + +fn should_notify( + output_mode: OutputMode, + stdout_is_terminal: bool, + stderr_is_terminal: bool, +) -> bool { + output_mode.is_normal() && stdout_is_terminal && stderr_is_terminal +} + +#[cfg(test)] +mod tests { + use super::{should_notify, BetaFeature, STORAGE, STORAGE_BETA_MESSAGE}; + use crate::output::OutputMode; + + #[test] + fn short_about_has_one_beta_suffix() { + let about = STORAGE.short_about("Use Tower Storage"); + + assert_eq!(about, "Use Tower Storage [beta]"); + assert_eq!(about.matches("[beta]").count(), 1); + } + + #[test] + fn notice_omits_docs_sentence_without_a_url() { + let notice = STORAGE.notice(); + + assert_eq!(notice, STORAGE_BETA_MESSAGE); + assert!(!notice.contains("Learn more:")); + } + + #[test] + fn notice_includes_docs_url_when_configured() { + let feature = BetaFeature { + id: "example-beta-v1", + message: "Example is in beta. Its interface may change.", + docs_url: Some("https://example.com/beta"), + }; + + assert_eq!( + feature.notice(), + "Example is in beta. Its interface may change. Learn more: https://example.com/beta" + ); + } + + #[test] + fn notice_requires_normal_output_and_two_terminals() { + assert!(should_notify(OutputMode::Normal, true, true)); + assert!(!should_notify(OutputMode::Normal, false, true)); + assert!(!should_notify(OutputMode::Normal, true, false)); + assert!(!should_notify(OutputMode::Normal, false, false)); + assert!(!should_notify(OutputMode::Json, true, true)); + assert!(!should_notify(OutputMode::McpStdio, true, true)); + assert!(!should_notify(OutputMode::McpStreaming, true, true)); + } +} diff --git a/crates/tower-cmd/src/catalogs.rs b/crates/tower-cmd/src/catalogs.rs index e52a3f11..4c0a3801 100644 --- a/crates/tower-cmd/src/catalogs.rs +++ b/crates/tower-cmd/src/catalogs.rs @@ -5,13 +5,17 @@ use tower_api::models::{ vend_catalog_credentials_body, CatalogCredentials, DescribeCatalogResponse, }; -use crate::{api, output, util::cmd}; +use crate::{api, beta, output, util::cmd}; const STORAGE_CATALOG_TYPE: &str = "tower-catalog"; pub fn catalogs_cmd() -> Command { Command::new("catalogs") - .about("Interact with the catalogs in your Tower account") + .about(format!( + "Interact with the catalogs in your Tower account (includes {})", + beta::STORAGE.short_about("Storage") + )) + .after_help(beta::STORAGE.notice()) .arg_required_else_help(true) .subcommand( Command::new("list") @@ -41,7 +45,7 @@ pub fn catalogs_cmd() -> Command { .arg( Arg::new("storage") .long("storage") - .help("List Tower-managed storage catalogs") + .help(beta::STORAGE.short_about("List Tower-managed storage catalogs")) .conflicts_with("type") .action(ArgAction::SetTrue), ) @@ -107,7 +111,10 @@ pub fn catalogs_cmd() -> Command { .help("Print the vended OAuth token in normal output") .action(ArgAction::SetTrue), ) - .about("Vend short-lived catalog credentials for external tools"), + .about( + beta::STORAGE + .short_about("Vend short-lived catalog credentials for external tools"), + ), ) } @@ -120,6 +127,10 @@ pub async fn do_list(config: Config, args: &ArgMatches) { args.get_one::("type").map(String::as_str) }; + if is_storage_catalog_type(catalog_type) { + beta::notify_once(&beta::STORAGE); + } + let catalogs = output::with_spinner( "Listing catalogs", api::list_catalogs(&config, &env, all, catalog_type), @@ -144,6 +155,8 @@ pub async fn do_list(config: Config, args: &ArgMatches) { } pub async fn do_credentials(config: Config, args: &ArgMatches) { + beta::notify_once(&beta::STORAGE); + let name = args .get_one::("catalog_name") .expect("catalog_name is required"); @@ -184,6 +197,9 @@ pub async fn do_show(config: Config, args: &ArgMatches) { match api::describe_catalog(&config, name, &env).await { Ok(response) => { + if is_storage_catalog_type(Some(&response.catalog.r#type)) { + beta::notify_once(&beta::STORAGE); + } let human = catalog_details_text(&response); output::text(&human, &response); } @@ -191,6 +207,10 @@ pub async fn do_show(config: Config, args: &ArgMatches) { } } +fn is_storage_catalog_type(catalog_type: Option<&str>) -> bool { + catalog_type == Some(STORAGE_CATALOG_TYPE) +} + fn parse_mode(mode: &str) -> vend_catalog_credentials_body::Mode { match mode { "read-write" => vend_catalog_credentials_body::Mode::ReadWrite, @@ -420,7 +440,9 @@ fn snippets( #[cfg(test)] mod tests { - use super::{catalogs_cmd, parse_mode, snippets, token_export_command}; + use super::{ + catalogs_cmd, is_storage_catalog_type, parse_mode, snippets, token_export_command, + }; use tower_api::models::{vend_catalog_credentials_body, CatalogCredentials}; #[test] @@ -495,6 +517,44 @@ mod tests { assert!(result.is_err()); } + #[test] + fn storage_catalog_type_detection_is_scoped() { + assert!(is_storage_catalog_type(Some("tower-catalog"))); + assert!(!is_storage_catalog_type(Some("snowflake-open-catalog"))); + assert!(!is_storage_catalog_type(None)); + } + + #[test] + fn catalog_help_marks_storage_beta_in_short_and_long_help() { + let short_help = catalogs_cmd().render_help().to_string(); + let long_help = catalogs_cmd().render_long_help().to_string(); + + for help in [short_help, long_help] { + assert!(help.contains("includes Storage [beta]")); + assert!(help.contains("Tower Storage is in beta.")); + } + } + + #[test] + fn storage_specific_command_and_flag_are_marked_beta() { + let mut command = catalogs_cmd(); + let credentials_help = command + .find_subcommand_mut("credentials") + .expect("credentials command should exist") + .render_help() + .to_string(); + assert!(credentials_help + .contains("Vend short-lived catalog credentials for external tools [beta]")); + + let mut command = catalogs_cmd(); + let list_help = command + .find_subcommand_mut("list") + .expect("list command should exist") + .render_help() + .to_string(); + assert!(list_help.contains("List Tower-managed storage catalogs [beta]")); + } + #[test] fn show_requires_catalog_name() { let result = catalogs_cmd().try_get_matches_from(["catalogs", "show"]); diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index 9eb84280..b45bc38e 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -3,6 +3,7 @@ use config::{Config, Session}; pub mod api; mod apps; +mod beta; mod catalogs; mod deploy; mod environments; @@ -304,3 +305,18 @@ fn root_cmd() -> Command { .subcommand(mcp::mcp_cmd()) .subcommand(skill::skill_cmd()) } + +#[cfg(test)] +mod tests { + use super::root_cmd; + + #[test] + fn root_help_scopes_beta_label_to_storage() { + let help = root_cmd().render_help().to_string(); + + assert!(help.contains( + "Interact with the catalogs in your Tower account (includes Storage [beta])" + )); + assert!(!help.contains("catalogs [beta]")); + } +} diff --git a/crates/tower-cmd/src/output.rs b/crates/tower-cmd/src/output.rs index 862e8839..12743ec1 100644 --- a/crates/tower-cmd/src/output.rs +++ b/crates/tower-cmd/src/output.rs @@ -68,6 +68,11 @@ fn write_to_stderr(msg: &str) { stderr.flush().ok(); } +pub(crate) fn notice_to_stderr(label: &str, msg: &str) { + let line = format!("{} {}\n", label.bold().yellow(), msg); + write_to_stderr(&line); +} + pub fn set_output_mode(mode: OutputMode) { OUTPUT_MODE.set(mode).ok(); if mode.is_mcp() { From e64b782b79d363f481cc844e58cf805c5269e3b9 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Tue, 14 Jul 2026 14:30:06 +0100 Subject: [PATCH 4/6] feat: send X-Tower-Idempotency-Key on deploy (#303) * feat: send X-Tower-Idempotency-Key on deploy Wire up the deploy command to send an idempotency key so consecutive deploys of unchanged source (e.g. to staging then production) collapse to a single AppVersion server-side instead of creating a new version each time. - Auto-populate the key from the git HEAD SHA when the working tree is clean; omit it on a dirty tree so provenance is never misrepresented. - Add --idempotency-key to override detection (useful for CI building outside a checkout) and --no-idempotency-key to opt out on a clean tree. - Print a hint when the server reuses an existing version so the user understands why no new version was created. - Apply the same git auto-detection to the MCP deploy tool. * test: add deploy idempotency key integration coverage BDD regression tests for the X-Tower-Idempotency-Key deploy behavior: explicit --idempotency-key, --no-idempotency-key opt-out, git auto-detect on a clean tree, header omission on a dirty tree, and the reuse hint on a repeat deploy with the same key. The mock API server now records the idempotency key seen on each deploy and reuses a stored (backdated) version when the same key recurs, exposing both via test-only inspection endpoints. * style: black-format deploy idempotency test files * chore: Cleanup to how the reuse hint is presented * chore: Don't really need to check output mode any longer * fix(test): match reuse-hint assertion to the new CLI wording Commit 560872b2 changed the deploy reuse hint from 'Reusing version ...' to 'No changes since commit ...' and updated the Rust unit test, but the behave step still asserted the old copy, failing the integration suite. --- crates/tower-cmd/src/deploy.rs | 170 +++++++++++++++++- crates/tower-cmd/src/mcp.rs | 15 +- crates/tower-cmd/src/output.rs | 13 ++ crates/tower-cmd/src/util/deploy.rs | 10 ++ crates/tower-cmd/src/util/git.rs | 136 ++++++++++++++ crates/tower-cmd/src/util/mod.rs | 1 + plugin/skills/tower/SKILL.md | 2 + .../features/cli_deploy_idempotency.feature | 40 +++++ tests/integration/features/steps/cli_steps.py | 81 +++++++++ tests/mock-api-server/main.py | 51 +++++- 10 files changed, 512 insertions(+), 7 deletions(-) create mode 100644 crates/tower-cmd/src/util/git.rs create mode 100644 tests/integration/features/cli_deploy_idempotency.feature diff --git a/crates/tower-cmd/src/deploy.rs b/crates/tower-cmd/src/deploy.rs index c5328f38..81cb4d7f 100644 --- a/crates/tower-cmd/src/deploy.rs +++ b/crates/tower-cmd/src/deploy.rs @@ -1,13 +1,20 @@ +use chrono::{DateTime, Duration, Utc}; use clap::{Arg, ArgMatches, Command}; use config::{Config, Towerfile}; use std::convert::From; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::{output, util}; use tower_api::apis::configuration::Configuration; use tower_package::{Package, PackageSpec}; use tower_telemetry::debug; +/// How far in the past a returned version's `created_at` must be before we treat +/// it as a reuse (rather than a fresh insert) for the stdout hint. A fresh +/// deploy stamps `created_at` at request time, so anything older than this was +/// created by an earlier deploy that shared the same idempotency key. +const REUSE_AGE_THRESHOLD: Duration = Duration::seconds(60); + pub fn deploy_cmd() -> Command { Command::new("deploy") .arg( @@ -38,6 +45,22 @@ pub fn deploy_cmd() -> Command { .action(clap::ArgAction::SetTrue) .conflicts_with("environment"), ) + .arg( + Arg::new("idempotency-key") + .long("idempotency-key") + .help( + "Reuse the existing version deployed with this key instead of creating a new one. \ + Defaults to the current git commit SHA when the working tree is clean.", + ) + .conflicts_with("no-idempotency-key"), + ) + .arg( + Arg::new("no-idempotency-key") + .long("no-idempotency-key") + .help("Never send an idempotency key, even on a clean git tree") + .action(clap::ArgAction::SetTrue) + .conflicts_with("idempotency-key"), + ) .about("Deploy your latest code to Tower") } @@ -59,9 +82,29 @@ pub enum DeployTarget { All, } +/// Resolves the idempotency key to send with the deploy, in precedence order: +/// +/// 1. `--no-idempotency-key` → never send a key. +/// 2. `--idempotency-key ` → send that exact value. +/// 3. Otherwise auto-detect the git `HEAD` SHA, but only when the working tree +/// is clean (a dirty tree gets no key, so every deploy creates a new version). +fn resolve_idempotency_key(args: &ArgMatches, dir: &Path) -> Option { + if args.get_flag("no-idempotency-key") { + debug!("--no-idempotency-key set; suppressing idempotency key"); + return None; + } + + if let Some(key) = args.get_one::("idempotency-key") { + return Some(key.clone()); + } + + util::git::clean_head_sha(dir) +} + pub async fn do_deploy(config: Config, args: &ArgMatches) { let dir = resolve_path(args); let create_app = args.get_flag("create"); + let idempotency_key = resolve_idempotency_key(args, &dir); let target = if args.get_flag("all") { DeployTarget::All @@ -71,7 +114,7 @@ pub async fn do_deploy(config: Config, args: &ArgMatches) { DeployTarget::Environment("default".to_string()) }; - if let Err(err) = deploy_from_dir(config, dir, create_app, target).await { + if let Err(err) = deploy_from_dir(config, dir, create_app, target, idempotency_key).await { match err { crate::Error::ApiDeployError { source } => { output::tower_error_and_die(source, "Deploying app failed") @@ -100,6 +143,7 @@ pub async fn deploy_from_dir( dir: PathBuf, create_app: bool, target: DeployTarget, + idempotency_key: Option, ) -> Result<(), crate::Error> { debug!("Building package from directory: {:?}", dir); @@ -135,7 +179,7 @@ pub async fn deploy_from_dir( }; spinner.success(); - do_deploy_package(api_config, package, &towerfile, target).await + do_deploy_package(api_config, package, &towerfile, target, idempotency_key).await } async fn do_deploy_package( @@ -143,6 +187,7 @@ async fn do_deploy_package( package: Package, towerfile: &Towerfile, target: DeployTarget, + idempotency_key: Option, ) -> Result<(), crate::Error> { let (environment, all_environments) = match &target { DeployTarget::All => (None, true), @@ -155,6 +200,7 @@ async fn do_deploy_package( package, environment, all_environments, + idempotency_key.as_deref(), ) .await; @@ -172,15 +218,49 @@ async fn do_deploy_package( ), }; output::success(&line); + + if let Some(hint) = reuse_hint(&version, idempotency_key.as_deref()) { + output::muted(&hint); + } + Ok(()) } Err(err) => Err(crate::Error::ApiDeployError { source: err }), } } +/// Builds the "reused an existing version" hint, or `None` when the deploy +/// created a fresh version. +/// +/// We treat the returned version as a reuse when (a) we sent an idempotency key, +/// (b) the server echoed it back on the version, and (c) the version's +/// `created_at` is meaningfully older than now — i.e. it predates this deploy. +fn reuse_hint(version: &tower_api::models::AppVersion, sent_key: Option<&str>) -> Option { + let sent_key = sent_key?; + + if version.idempotency_key.as_deref() != Some(sent_key) { + return None; + } + + let created_at = DateTime::parse_from_rfc3339(&version.created_at) + .ok()? + .with_timezone(&Utc); + + if Utc::now().signed_duration_since(created_at) < REUSE_AGE_THRESHOLD { + return None; + } + + Some(format!( + "No changes since commit {} (deployed on {})", + sent_key, + util::dates::format(created_at), + )) +} + #[cfg(test)] mod tests { - use super::deploy_cmd; + use super::*; + use tower_api::models::AppVersion; fn parse(args: &[&str]) -> Result { let mut full = vec!["deploy"]; @@ -188,6 +268,17 @@ mod tests { deploy_cmd().try_get_matches_from(full) } + fn version_with(idempotency_key: Option<&str>, created_at: &str) -> AppVersion { + AppVersion { + created_at: created_at.to_string(), + parameters: vec![], + towerfile: String::new(), + version: "v3".to_string(), + idempotency_key: idempotency_key.map(|s| s.to_string()), + content_checksum: None, + } + } + #[test] fn no_args_uses_defaults() { let m = parse(&[]).unwrap(); @@ -276,4 +367,75 @@ mod tests { let err = parse(&["--help"]).unwrap_err(); assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); } + + #[test] + fn idempotency_key_flag() { + let m = parse(&["--idempotency-key", "abc123"]).unwrap(); + assert_eq!( + m.get_one::("idempotency-key").map(|s| s.as_str()), + Some("abc123") + ); + } + + #[test] + fn no_idempotency_key_flag() { + let m = parse(&["--no-idempotency-key"]).unwrap(); + assert!(m.get_flag("no-idempotency-key")); + } + + #[test] + fn idempotency_key_and_no_idempotency_key_conflict() { + let err = parse(&["--idempotency-key", "abc", "--no-idempotency-key"]).unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn resolve_key_prefers_explicit_flag() { + let m = parse(&["--idempotency-key", "explicit"]).unwrap(); + // A non-existent path guarantees git auto-detection can't interfere. + let key = resolve_idempotency_key(&m, Path::new("/nonexistent/path")); + assert_eq!(key.as_deref(), Some("explicit")); + } + + #[test] + fn resolve_key_opt_out_wins_over_git() { + let m = parse(&["--no-idempotency-key"]).unwrap(); + // Even pointed at the (clean-or-dirty) repo we're in, opt-out yields None. + let key = resolve_idempotency_key(&m, Path::new(".")); + assert_eq!(key, None); + } + + #[test] + fn resolve_key_none_outside_git() { + let m = parse(&[]).unwrap(); + let key = resolve_idempotency_key(&m, Path::new("/nonexistent/path")); + assert_eq!(key, None); + } + + #[test] + fn reuse_hint_none_without_sent_key() { + let version = version_with(Some("abc"), "2020-01-01T00:00:00Z"); + assert_eq!(reuse_hint(&version, None), None); + } + + #[test] + fn reuse_hint_none_when_key_mismatch() { + let version = version_with(Some("other"), "2020-01-01T00:00:00Z"); + assert_eq!(reuse_hint(&version, Some("abc")), None); + } + + #[test] + fn reuse_hint_none_for_freshly_created_version() { + // created_at "now" → not a reuse, no hint. + let now = Utc::now().to_rfc3339(); + let version = version_with(Some("abc"), &now); + assert_eq!(reuse_hint(&version, Some("abc")), None); + } + + #[test] + fn reuse_hint_present_for_old_matching_version() { + let version = version_with(Some("abc123"), "2020-01-01T00:00:00Z"); + let hint = reuse_hint(&version, Some("abc123")).expect("expected a reuse hint"); + assert!(hint.contains("No changes since commit abc123")); + } } diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs index 6d406c22..7d69056f 100644 --- a/crates/tower-cmd/src/mcp.rs +++ b/crates/tower-cmd/src/mcp.rs @@ -812,7 +812,20 @@ impl TowerService { let env = request.environment.unwrap_or_else(|| "default".to_string()); let deploy_target = deploy::DeployTarget::Environment(env); - match deploy::deploy_from_dir(self.config.clone(), working_dir, true, deploy_target).await { + // Auto-detect the idempotency key from git (clean tree HEAD) just like + // the CLI deploy command does, so repeated deploys of unchanged source + // collapse to a single AppVersion server-side. + let idempotency_key = crate::util::git::clean_head_sha(&working_dir); + + match deploy::deploy_from_dir( + self.config.clone(), + working_dir, + true, + deploy_target, + idempotency_key, + ) + .await + { Ok(_) => Self::text_success("Deploy completed successfully".to_string()), Err(e) => Self::error_result("Deploy failed", e), } diff --git a/crates/tower-cmd/src/output.rs b/crates/tower-cmd/src/output.rs index 12743ec1..bd8b28ba 100644 --- a/crates/tower-cmd/src/output.rs +++ b/crates/tower-cmd/src/output.rs @@ -115,6 +115,19 @@ pub fn success(msg: &str) { success_with_data(msg, None::); } +pub fn muted(msg: &str) { + if get_output_mode().is_json() { + let response = serde_json::json!({ + "result": "muted", + "message": msg + }); + json(&response); + } else { + let line = msg.dimmed(); + write(&line); + } +} + pub fn success_with_data(msg: &str, data: Option) { let mut response = serde_json::json!({ "result": "success", diff --git a/crates/tower-cmd/src/util/deploy.rs b/crates/tower-cmd/src/util/deploy.rs index cc19fcb9..9931a395 100644 --- a/crates/tower-cmd/src/util/deploy.rs +++ b/crates/tower-cmd/src/util/deploy.rs @@ -19,6 +19,7 @@ pub async fn upload_file_with_progress( endpoint_url: String, file_path: PathBuf, content_type: &str, + idempotency_key: Option<&str>, progress_cb: Box, ) -> Result> { let package_hash = match compute_sha256_file(&file_path).await { @@ -57,6 +58,13 @@ pub async fn upload_file_with_progress( .header("Content-Encoding", "gzip") .body(Body::wrap_stream(progress_stream)); + // When supplied, ask the server to reuse an existing AppVersion that was + // deployed with the same key (e.g. a git commit SHA) instead of creating a + // new one. + if let Some(key) = idempotency_key { + req = req.header("X-Tower-Idempotency-Key", key); + } + // Add authorization if available. Mirrors the generated tower-api client: prefer a // bearer token (interactive session), otherwise fall back to the API key header set // when TOWER_API_KEY is configured. @@ -107,6 +115,7 @@ pub async fn deploy_app_package( package: Package, environment: Option<&str>, all_environments: bool, + idempotency_key: Option<&str>, ) -> Result> { let progress_bar = Arc::new(Mutex::new(output::progress_bar("Deploying to Tower..."))); @@ -150,6 +159,7 @@ pub async fn deploy_app_package( url, package_path, "application/tar", + idempotency_key, progress_callback, ) .await?; diff --git a/crates/tower-cmd/src/util/git.rs b/crates/tower-cmd/src/util/git.rs new file mode 100644 index 00000000..d39181ee --- /dev/null +++ b/crates/tower-cmd/src/util/git.rs @@ -0,0 +1,136 @@ +use std::path::Path; +use std::process::Command; +use tower_telemetry::debug; + +/// Resolves the commit SHA of `HEAD` for the git worktree containing `dir`, but +/// only when the working tree is clean. Returns `None` when: +/// +/// - `dir` is not inside a git worktree, +/// - the working tree has uncommitted changes (we must not claim provenance the +/// bundle doesn't actually have), +/// - `git` isn't installed or any git invocation fails. +/// +/// This is used to auto-populate the `X-Tower-Idempotency-Key` header on deploy. +pub fn clean_head_sha(dir: &Path) -> Option { + if !is_inside_work_tree(dir) { + debug!("{:?} is not inside a git worktree; skipping idempotency key", dir); + return None; + } + + if is_dirty(dir) { + debug!("git worktree at {:?} is dirty; omitting idempotency key", dir); + return None; + } + + let sha = head_sha(dir)?; + debug!("resolved clean git HEAD {} for {:?}", sha, dir); + Some(sha) +} + +fn git(dir: &Path, args: &[&str]) -> Option { + match Command::new("git").arg("-C").arg(dir).args(args).output() { + Ok(output) => Some(output), + Err(err) => { + debug!("failed to invoke git {:?}: {}", args, err); + None + } + } +} + +fn is_inside_work_tree(dir: &Path) -> bool { + let output = match git(dir, &["rev-parse", "--is-inside-work-tree"]) { + Some(output) => output, + None => return false, + }; + + output.status.success() + && String::from_utf8_lossy(&output.stdout).trim() == "true" +} + +/// Returns `true` when there are staged, unstaged, or untracked changes in the +/// worktree. Anything other than a confidently-clean tree is treated as dirty. +fn is_dirty(dir: &Path) -> bool { + let output = match git(dir, &["status", "--porcelain"]) { + Some(output) => output, + None => return true, + }; + + if !output.status.success() { + return true; + } + + !String::from_utf8_lossy(&output.stdout).trim().is_empty() +} + +fn head_sha(dir: &Path) -> Option { + let output = git(dir, &["rev-parse", "HEAD"])?; + + if !output.status.success() { + return None; + } + + let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + if sha.is_empty() { + None + } else { + Some(sha) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + use tempfile::TempDir; + + fn run(dir: &Path, args: &[&str]) { + let status = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .status() + .expect("git invocation failed"); + assert!(status.success(), "git {:?} failed", args); + } + + fn init_repo() -> TempDir { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + run(dir, &["init", "--quiet"]); + run(dir, &["config", "user.email", "test@example.com"]); + run(dir, &["config", "user.name", "Test User"]); + tmp + } + + #[test] + fn non_git_dir_returns_none() { + let tmp = TempDir::new().unwrap(); + assert_eq!(clean_head_sha(tmp.path()), None); + } + + #[test] + fn clean_tree_returns_head_sha() { + let tmp = init_repo(); + let dir = tmp.path(); + std::fs::write(dir.join("file.txt"), "hello").unwrap(); + run(dir, &["add", "."]); + run(dir, &["commit", "--quiet", "-m", "initial"]); + + let sha = clean_head_sha(dir).expect("expected a sha on a clean tree"); + assert_eq!(sha.len(), 40, "expected a full 40-char sha, got {sha}"); + } + + #[test] + fn dirty_tree_returns_none() { + let tmp = init_repo(); + let dir = tmp.path(); + std::fs::write(dir.join("file.txt"), "hello").unwrap(); + run(dir, &["add", "."]); + run(dir, &["commit", "--quiet", "-m", "initial"]); + + // Untracked file makes the tree dirty. + std::fs::write(dir.join("dirty.txt"), "uncommitted").unwrap(); + assert_eq!(clean_head_sha(dir), None); + } +} diff --git a/crates/tower-cmd/src/util/mod.rs b/crates/tower-cmd/src/util/mod.rs index 3ce9f5bc..f947e70e 100644 --- a/crates/tower-cmd/src/util/mod.rs +++ b/crates/tower-cmd/src/util/mod.rs @@ -2,6 +2,7 @@ pub mod apps; pub mod cmd; pub mod dates; pub mod deploy; +pub mod git; pub mod progress; pub mod prompt; pub mod text; diff --git a/plugin/skills/tower/SKILL.md b/plugin/skills/tower/SKILL.md index 760e46e1..8d1b31e9 100644 --- a/plugin/skills/tower/SKILL.md +++ b/plugin/skills/tower/SKILL.md @@ -346,6 +346,8 @@ Deploy your latest code to Tower - `-f`, `--create` — Automatically force creation of the app if it doesn't already exist - `-e`, `--environment` — The environment to deploy to - `--all` — Deploy to all environments +- `--idempotency-key ` — Reuse the existing version deployed with this key instead of creating a new one. Defaults to the current git commit SHA when the working tree is clean, so deploying unchanged source to multiple environments collapses to a single version. +- `--no-idempotency-key` — Never send an idempotency key, even on a clean git tree ### `tower run` diff --git a/tests/integration/features/cli_deploy_idempotency.feature b/tests/integration/features/cli_deploy_idempotency.feature new file mode 100644 index 00000000..0fe6ca2f --- /dev/null +++ b/tests/integration/features/cli_deploy_idempotency.feature @@ -0,0 +1,40 @@ +@serial @deploy +Feature: CLI Deploy Idempotency Key + As a developer promoting unchanged source across environments + I want tower deploy to send an X-Tower-Idempotency-Key + So that consecutive deploys of identical source reuse a single version + + Scenario: Deploy forwards an explicit idempotency key to the server + Given I have a valid Towerfile in the current directory + And the deploy log is reset + When I run "tower deploy --create --idempotency-key explicit-key-123" via CLI + Then the last deploy should have been sent with idempotency key "explicit-key-123" + + Scenario: Deploy with --no-idempotency-key suppresses the header + Given I have a valid Towerfile in the current directory + And the current directory is a clean git repository + And the deploy log is reset + When I run "tower deploy --create --no-idempotency-key" via CLI + Then the last deploy should have been sent without an idempotency key + + Scenario: Deploy auto-detects the git commit SHA on a clean tree + Given I have a valid Towerfile in the current directory + And the current directory is a clean git repository + And the deploy log is reset + When I run "tower deploy --create" via CLI + Then the last deploy should have been sent with the current git commit SHA + + Scenario: Deploy omits the key on a dirty git tree + Given I have a valid Towerfile in the current directory + And the current directory is a clean git repository + And the working tree has uncommitted changes + And the deploy log is reset + When I run "tower deploy --create" via CLI + Then the last deploy should have been sent without an idempotency key + + Scenario: Re-deploying with the same key reuses the existing version + Given I have a valid Towerfile in the current directory + And the deploy log is reset + When I run "tower deploy --create --idempotency-key reuse-key" via CLI + And I run "tower deploy --idempotency-key reuse-key" via CLI + Then the CLI output should indicate the version was reused diff --git a/tests/integration/features/steps/cli_steps.py b/tests/integration/features/steps/cli_steps.py index 24105289..a6f2d471 100644 --- a/tests/integration/features/steps/cli_steps.py +++ b/tests/integration/features/steps/cli_steps.py @@ -526,3 +526,84 @@ def step_json_should_contain_all_created_apps(context): listed.add(entry.get("name")) missing = [name for name in context.created_app_names if name not in listed] assert not missing, f"Missing {len(missing)} apps from JSON: {missing}" + + +# Deploy idempotency key steps (X-Tower-Idempotency-Key) + + +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", text) + + +def _git(context, *args): + """Run a git command in the scenario's temp working directory.""" + result = subprocess.run( + ["git", "-C", context.temp_dir, *args], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, f"git {args} failed: {result.stderr}" + return result.stdout.strip() + + +def _last_deploy(context): + resp = requests.get(f"{context.tower_url}/test/deploy-log", timeout=10) + resp.raise_for_status() + deploys = resp.json().get("deploys", []) + # Filter to this scenario's app (names are unique per scenario) so the + # assertion can't pick up a deploy from another scenario. + if hasattr(context, "app_name"): + deploys = [d for d in deploys if d.get("name") == context.app_name] + assert ( + deploys + ), "Expected at least one deploy to have been recorded by the mock server" + return deploys[-1] + + +@given("the deploy log is reset") +def step_reset_deploy_log(context): + """Clear the mock server's record of received deploys/idempotency keys.""" + resp = requests.post(f"{context.tower_url}/test/reset-deploy-log", timeout=10) + resp.raise_for_status() + + +@given("the current directory is a clean git repository") +def step_init_clean_git_repo(context): + """Initialize a git repo in the temp dir and commit everything so the tree is clean.""" + _git(context, "init", "--quiet") + _git(context, "config", "user.email", "test@example.com") + _git(context, "config", "user.name", "Test User") + _git(context, "add", "-A") + _git(context, "commit", "--quiet", "-m", "initial") + + +@given("the working tree has uncommitted changes") +def step_make_working_tree_dirty(context): + """Add an untracked file so the git working tree is dirty.""" + Path(context.temp_dir, "uncommitted.txt").write_text("dirty") + + +@then('the last deploy should have been sent with idempotency key "{key}"') +def step_last_deploy_has_key(context, key): + actual = _last_deploy(context).get("idempotency_key") + assert actual == key, f"Expected idempotency key '{key}', got '{actual}'" + + +@then("the last deploy should have been sent without an idempotency key") +def step_last_deploy_has_no_key(context): + actual = _last_deploy(context).get("idempotency_key") + assert actual is None, f"Expected no idempotency key, got '{actual}'" + + +@then("the last deploy should have been sent with the current git commit SHA") +def step_last_deploy_has_git_sha(context): + expected = _git(context, "rev-parse", "HEAD") + actual = _last_deploy(context).get("idempotency_key") + assert actual == expected, f"Expected git SHA '{expected}', got '{actual}'" + + +@then("the CLI output should indicate the version was reused") +def step_output_indicates_reuse(context): + output = _strip_ansi(context.cli_output) + assert "No changes since commit" in output, f"Expected a reuse hint, got: {output}" diff --git a/tests/mock-api-server/main.py b/tests/mock-api-server/main.py index ce477eec..2cce0c64 100644 --- a/tests/mock-api-server/main.py +++ b/tests/mock-api-server/main.py @@ -49,6 +49,14 @@ async def log_requests(request: Request, call_next): mock_schedules_db = {} mock_deployed_apps = set() # Track which apps have been deployed +# Idempotency support (mirrors the real server's X-Tower-Idempotency-Key behavior). +# mock_deploy_log records the idempotency key seen on every deploy (None when the +# header was absent) so tests can assert exactly what the CLI sent. +mock_deploy_log = [] # list of {"name": str, "idempotency_key": Optional[str]} +# mock_idempotent_versions maps (app_name, key) -> a stored version dict that is +# returned verbatim on a repeat deploy with the same key. +mock_idempotent_versions = {} + # Pre-populate with test-app for CLI validation/spinner tests mock_apps_db["predeployed-test-app"] = { "name": "predeployed-test-app", @@ -219,23 +227,62 @@ async def delete_app(name: str): @app.post("/v1/apps/{name}/deploy") -async def deploy_app(name: str, response: Response): +async def deploy_app(name: str, request: Request, response: Response): if name not in mock_apps_db: raise HTTPException(status_code=404, detail=f"App '{name}' not found") + + # Capture the idempotency key the CLI sent (None when the header is absent) + # so tests can assert provenance behavior. + idempotency_key = request.headers.get("x-tower-idempotency-key") + mock_deploy_log.append({"name": name, "idempotency_key": idempotency_key}) + + # Idempotency hit: a prior deploy supplied the same key for this app. Return + # the stored version verbatim, including its original (past) created_at, which + # is what lets the CLI recognize the reuse and print its hint. + if idempotency_key and (name, idempotency_key) in mock_idempotent_versions: + return {"version": mock_idempotent_versions[(name, idempotency_key)]} + # Simulate a successful deployment version_num = "1.0.0" # Simplified versioning deployed_version = { "version": version_num, "parameters": [], - "created_at": datetime.datetime.now().isoformat(), + "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "towerfile": "mock_towerfile_content", + "idempotency_key": idempotency_key, + "content_checksum": "mock-content-checksum", } # Update app's version and mark as deployed mock_apps_db[name]["version"] = version_num mock_deployed_apps.add(name) + + # Remember this version under its key so the next deploy with the same key is + # treated as a reuse. The stored created_at is backdated so the reuse is + # unambiguously "older than now" regardless of how fast the test runs. + if idempotency_key: + stored = dict(deployed_version) + stored["created_at"] = ( + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=1) + ).isoformat() + mock_idempotent_versions[(name, idempotency_key)] = stored + return {"version": deployed_version} +@app.get("/test/deploy-log") +async def get_deploy_log(): + """Test-only: return the idempotency key seen on every deploy so far.""" + return {"deploys": mock_deploy_log} + + +@app.post("/test/reset-deploy-log") +async def reset_deploy_log(): + """Test-only: clear deploy bookkeeping so each scenario starts clean.""" + mock_deploy_log.clear() + mock_idempotent_versions.clear() + return {"ok": True} + + @app.post("/v1/apps/{name}/runs", status_code=201) async def run_app(name: str, run_params: Dict[str, Any]): if name not in mock_apps_db: From 1b5dfd6dee79fe2fe0306dcca354b41aaabca8b8 Mon Sep 17 00:00:00 2001 From: Ben Lovell Date: Wed, 15 Jul 2026 14:54:58 +0200 Subject: [PATCH 5/6] refactor(cli): replace the output globals with an explicit `Out` (#322) * refactor(cli): replace the output globals with an explicit `Out` Where CLI output went was controlled by two mutable globals: an OUTPUT_MODE and a CURRENT_SENDER that the MCP server set and cleared around each tool call. Every output:: function read them to choose between stdout, JSON, and the MCP peer, so a command never said where its own output went. Pass the destination in as a value instead: commands take an Out and the writing functions become methods on it. The CLI builds one over stdout; an MCP tool builds one whose writer forwards each line to that call's channel. The sender and the notification flag live on the value, not in globals. * chore: rustfmt util/git.rs --- crates/tower-cmd/src/apps.rs | 241 +++---- crates/tower-cmd/src/beta.rs | 37 +- crates/tower-cmd/src/catalogs.rs | 42 +- crates/tower-cmd/src/deploy.rs | 40 +- crates/tower-cmd/src/environments.rs | 46 +- crates/tower-cmd/src/lib.rs | 76 ++- crates/tower-cmd/src/mcp.rs | 84 +-- crates/tower-cmd/src/output.rs | 980 ++++++++++++++------------- crates/tower-cmd/src/package.rs | 20 +- crates/tower-cmd/src/run.rs | 165 +++-- crates/tower-cmd/src/schedules.rs | 68 +- crates/tower-cmd/src/secrets.rs | 47 +- crates/tower-cmd/src/session.rs | 45 +- crates/tower-cmd/src/teams.rs | 54 +- crates/tower-cmd/src/util/apps.rs | 15 +- crates/tower-cmd/src/util/cmd.rs | 4 +- crates/tower-cmd/src/util/deploy.rs | 11 +- crates/tower-cmd/src/util/git.rs | 13 +- crates/tower-cmd/src/version.rs | 6 +- 19 files changed, 1056 insertions(+), 938 deletions(-) diff --git a/crates/tower-cmd/src/apps.rs b/crates/tower-cmd/src/apps.rs index 5fc707c1..ad7256b2 100644 --- a/crates/tower-cmd/src/apps.rs +++ b/crates/tower-cmd/src/apps.rs @@ -1,4 +1,5 @@ use clap::{value_parser, Arg, ArgMatches, Command}; +use colored::Colorize; use config::Config; use tokio::sync::oneshot; use tokio::time::{sleep, Duration, Instant}; @@ -116,123 +117,118 @@ pub fn apps_cmd() -> Command { ) } -pub async fn do_logs(config: Config, cmd: &ArgMatches) { +pub async fn do_logs(out: &output::Out, config: Config, cmd: &ArgMatches) { let app_name_raw = cmd .get_one::("app_name") .expect("app_name is required"); let (name, seq) = if let Some((name, num_str)) = app_name_raw.split_once('#') { let num = num_str .parse::() - .unwrap_or_else(|_| output::die("Run number must be a number")); + .unwrap_or_else(|_| out.die("Run number must be a number")); (name.to_string(), num) } else { let num = match cmd.get_one::("run_number").copied() { Some(n) => n, - None => latest_run_number(&config, app_name_raw).await, + None => latest_run_number(out, &config, app_name_raw).await, }; (app_name_raw.clone(), num) }; let follow = cmd.get_one::("follow").copied().unwrap_or(false); if follow { - follow_logs(config, name, seq).await; + follow_logs(out, config, name, seq).await; return; } if let Ok(resp) = api::describe_run_logs(&config, &name, seq).await { for line in resp.log_lines { - output::remote_log_event(&line); + out.remote_log_event(&line); } } } -pub async fn do_show(config: Config, cmd: &ArgMatches) { +pub async fn do_show(out: &output::Out, config: Config, cmd: &ArgMatches) { let name = cmd .get_one::("app_name") .expect("app_name is required"); let env = cmd::get_string_flag(cmd, "environment"); match api::describe_app(&config, &name, Some(&env)).await { - Ok(app_response) => { - if output::get_output_mode().is_json() { - output::json(&app_response); - return; - } + Ok(app_response) => out.text(&app_details_text(&app_response), &app_response), + Err(err) => out.tower_error_and_die(err, "Fetching app details failed"), + } +} - let app = &app_response.app; - let runs = &app_response.runs; - - output::detail("Name", &app.name); - output::header("Description"); - let line = output::paragraph(&app.short_description); - output::write(&line); - output::newline(); - output::newline(); - output::header("Recent runs"); - - let headers = vec!["#", "Status", "Start Time", "Elapsed Time"] - .into_iter() - .map(str::to_string) - .collect(); - - let rows = runs - .iter() - .map(|run: &Run| { - let status = &run.status; - let status_str = format!("{:?}", status); - - // Format start time - let start_time = if let Some(started_at) = &run.started_at { - if !started_at.is_empty() { - started_at.to_string() - } else { - format!("Scheduled at {}", &run.scheduled_at) - } - } else { - format!("Scheduled at {}", &run.scheduled_at) - }; - - // Calculate elapsed time - let elapsed_time = if let Some(ended_at) = &run.ended_at { - if !ended_at.is_empty() { - if let (Some(started_at), Some(ended_at)) = - (&run.started_at, &run.ended_at) - { - let start = - started_at.parse::>().ok(); - let end = ended_at.parse::>().ok(); - if let (Some(start), Some(end)) = (start, end) { - format!("{:.1}s", (end - start).num_seconds()) - } else { - "Invalid time".into() - } - } else { - "Invalid time".into() - } - } else if run.started_at.is_some() { - "Running".into() +fn app_details_text(response: &tower_api::models::DescribeAppResponse) -> String { + let app = &response.app; + let mut text = String::new(); + + text.push_str(&format!("{} {}\n", "Name:".bold().green(), app.name)); + text.push_str(&format!("{}\n", "Description".bold().green())); + text.push_str(&output::paragraph(&app.short_description)); + text.push_str("\n\n"); + text.push_str(&format!("{}\n", "Recent runs".bold().green())); + + let headers = vec!["#", "Status", "Start Time", "Elapsed Time"] + .into_iter() + .map(str::to_string) + .collect(); + + let rows = response + .runs + .iter() + .map(|run: &Run| { + let status_str = format!("{:?}", &run.status); + + // Format start time + let start_time = if let Some(started_at) = &run.started_at { + if !started_at.is_empty() { + started_at.to_string() + } else { + format!("Scheduled at {}", &run.scheduled_at) + } + } else { + format!("Scheduled at {}", &run.scheduled_at) + }; + + // Calculate elapsed time + let elapsed_time = if let Some(ended_at) = &run.ended_at { + if !ended_at.is_empty() { + if let (Some(started_at), Some(ended_at)) = (&run.started_at, &run.ended_at) { + let start = started_at.parse::>().ok(); + let end = ended_at.parse::>().ok(); + if let (Some(start), Some(end)) = (start, end) { + format!("{:.1}s", (end - start).num_seconds()) } else { - "Pending".into() + "Invalid time".into() } - } else if run.started_at.is_some() { - "Running".into() } else { - "Pending".into() - }; + "Invalid time".into() + } + } else if run.started_at.is_some() { + "Running".into() + } else { + "Pending".into() + } + } else if run.started_at.is_some() { + "Running".into() + } else { + "Pending".into() + }; - vec![run.number.to_string(), status_str, start_time, elapsed_time] - }) - .collect(); + vec![run.number.to_string(), status_str, start_time, elapsed_time] + }) + .collect(); - output::table(headers, rows, Some(&app_response)); - } - Err(err) => output::tower_error_and_die(err, "Fetching app details failed"), - } + text.push_str(&format!("{}\n", output::table_text(headers, rows))); + text } -pub async fn do_list_apps(config: Config, args: &ArgMatches) { +pub async fn do_list_apps(out: &output::Out, config: Config, args: &ArgMatches) { let env = args.get_one::("environment").map(|s| s.as_str()); - let apps = output::with_spinner("Listing apps", api::list_apps(&config, env)).await; + let apps = out + .with_spinner("Listing apps", api::list_apps(&config, env)) + .await; let items = apps .iter() @@ -246,31 +242,33 @@ pub async fn do_list_apps(config: Config, args: &ArgMatches) { format!("{}\n{}", output::title(&app.name), desc) }) .collect(); - output::list(items, Some(&apps)); + out.list(items, Some(&apps)); } -pub async fn do_create(config: Config, args: &ArgMatches) { +pub async fn do_create(out: &output::Out, config: Config, args: &ArgMatches) { let name = args.get_one::("name").unwrap_or_else(|| { - output::die("App name (--name) is required"); + out.die("App name (--name) is required"); }); let description = args.get_one::("description").unwrap(); - let app = - output::with_spinner("Creating app", api::create_app(&config, name, description)).await; + let app = out + .with_spinner("Creating app", api::create_app(&config, name, description)) + .await; - output::success_with_data(&format!("App '{}' created", name), Some(app)); + out.success_with_data(&format!("App '{}' created", name), Some(app)); } -pub async fn do_delete(config: Config, cmd: &ArgMatches) { +pub async fn do_delete(out: &output::Out, config: Config, cmd: &ArgMatches) { let name = cmd .get_one::("app_name") .expect("app_name is required"); - output::with_spinner("Deleting app", api::delete_app(&config, name)).await; + out.with_spinner("Deleting app", api::delete_app(&config, name)) + .await; } -pub async fn do_cancel(config: Config, cmd: &ArgMatches) { +pub async fn do_cancel(out: &output::Out, config: Config, cmd: &ArgMatches) { let name = cmd .get_one::("app_name") .expect("app_name should be required"); @@ -279,26 +277,27 @@ pub async fn do_cancel(config: Config, cmd: &ArgMatches) { .copied() .expect("run_number should be required"); - let response = - output::with_spinner("Cancelling run", api::cancel_run(&config, name, seq)).await; + let response = out + .with_spinner("Cancelling run", api::cancel_run(&config, name, seq)) + .await; let run = &response.run; let status = format!("{:?}", run.status); - output::success_with_data( + out.success_with_data( &format!("Run #{} for '{}' cancelled (status: {})", seq, name, status), Some(response), ); } -async fn latest_run_number(config: &Config, name: &str) -> i64 { +async fn latest_run_number(out: &output::Out, config: &Config, name: &str) -> i64 { match api::describe_app(config, name, None).await { Ok(resp) => resp .runs .iter() .map(|r| r.number) .max() - .unwrap_or_else(|| output::die(&format!("No runs found for app '{}'", name))), - Err(err) => output::tower_error_and_die(err, "Fetching app details failed"), + .unwrap_or_else(|| out.die(&format!("No runs found for app '{}'", name))), + Err(err) => out.tower_error_and_die(err, "Fetching app details failed"), } } @@ -309,8 +308,7 @@ const RUN_START_POLL_INTERVAL: Duration = Duration::from_millis(500); const RUN_START_MESSAGE_DELAY: Duration = Duration::from_secs(3); const RUN_START_TIMEOUT: Duration = Duration::from_secs(30); -async fn follow_logs(config: Config, name: String, seq: i64) { - let enable_ctrl_c = !output::get_output_mode().is_mcp(); +async fn follow_logs(out: &output::Out, config: Config, name: String, seq: i64) { let mut backoff = FOLLOW_BACKOFF_INITIAL; let mut cancel_monitor: Option> = None; let mut last_line_num: Option = None; @@ -318,13 +316,13 @@ async fn follow_logs(config: Config, name: String, seq: i64) { loop { let mut run = match api::describe_run(&config, &name, seq).await { Ok(res) => res.run, - Err(err) => output::tower_error_and_die(err, "Fetching run details failed"), + Err(err) => out.tower_error_and_die(err, "Fetching run details failed"), }; if is_run_finished(&run) { if let Ok(resp) = api::describe_run_logs(&config, &name, seq).await { for line in resp.log_lines { - emit_log_if_new(&line, &mut last_line_num); + emit_log_if_new(out, &line, &mut last_line_num); } } return; @@ -337,25 +335,23 @@ async fn follow_logs(config: Config, name: String, seq: i64) { sleep(RUN_START_POLL_INTERVAL).await; if wait_started.elapsed() > RUN_START_TIMEOUT { - output::error( - "Timed out waiting for run to start. The runner may be unavailable.", - ); + out.error("Timed out waiting for run to start. The runner may be unavailable."); return; } // Avoid blank output on slow starts while keeping fast starts quiet. if should_notify_run_wait(notified, wait_started.elapsed()) { - output::write("Waiting for run to start...\n"); + out.write("Waiting for run to start...\n"); notified = true; } run = match api::describe_run(&config, &name, seq).await { Ok(res) => res.run, - Err(err) => output::tower_error_and_die(err, "Fetching run details failed"), + Err(err) => out.tower_error_and_die(err, "Fetching run details failed"), }; if is_run_finished(&run) { if let Ok(resp) = api::describe_run_logs(&config, &name, seq).await { for line in resp.log_lines { - emit_log_if_new(&line, &mut last_line_num); + emit_log_if_new(out, &line, &mut last_line_num); } } return; @@ -378,9 +374,10 @@ async fn follow_logs(config: Config, name: String, seq: i64) { // Reset after a successful connection so transient drops recover quickly. backoff = FOLLOW_BACKOFF_INITIAL; match stream_logs_until_complete( + out, log_stream, run_complete, - enable_ctrl_c, + out.foreground(), &run.dollar_link, &mut last_line_num, ) @@ -409,10 +406,10 @@ async fn follow_logs(config: Config, name: String, seq: i64) { } Err(err) => { if is_fatal_stream_error(&err) { - output::error(&format!("Failed to stream run logs: {}", err)); + out.error(&format!("Failed to stream run logs: {}", err)); return; } - output::error(&format!("Failed to stream run logs: {}", err)); + out.error(&format!("Failed to stream run logs: {}", err)); sleep(backoff).await; backoff = next_backoff(backoff); continue; @@ -421,7 +418,7 @@ async fn follow_logs(config: Config, name: String, seq: i64) { let latest = match api::describe_run(&config, &name, seq).await { Ok(res) => res.run, - Err(err) => output::tower_error_and_die(err, "Fetching run details failed"), + Err(err) => out.tower_error_and_die(err, "Fetching run details failed"), }; if is_run_finished(&latest) { return; @@ -448,6 +445,7 @@ enum LogFollowOutcome { } async fn stream_logs_until_complete( + out: &output::Out, mut log_stream: tokio::sync::mpsc::Receiver, mut run_complete: oneshot::Receiver, enable_ctrl_c: bool, @@ -458,17 +456,17 @@ async fn stream_logs_until_complete( tokio::select! { event = log_stream.recv() => match event { Some(api::LogStreamEvent::EventLog(log)) => { - emit_log_if_new(&log, last_line_num); + emit_log_if_new(out, &log, last_line_num); }, Some(api::LogStreamEvent::EventWarning(warning)) => { - output::write(&format!("Warning: {}\n", warning.data.content)); + out.write(&format!("Warning: {}\n", warning.data.content)); } None => return Ok(LogFollowOutcome::Disconnected), }, res = &mut run_complete => { match res { Ok(_) => { - drain_remaining_logs(log_stream, last_line_num).await; + drain_remaining_logs(out, log_stream, last_line_num).await; return Ok(LogFollowOutcome::Completed); } // If monitoring failed, keep following and let the caller retry. @@ -476,9 +474,9 @@ async fn stream_logs_until_complete( } }, _ = tokio::signal::ctrl_c(), if enable_ctrl_c => { - output::write("Received Ctrl+C, stopping log streaming...\n"); - output::write("Note: The run will continue in Tower cloud\n"); - output::write(&format!(" See more: {}\n", run_link)); + out.write("Received Ctrl+C, stopping log streaming...\n"); + out.write("Note: The run will continue in Tower cloud\n"); + out.write(&format!(" See more: {}\n", run_link)); return Ok(LogFollowOutcome::Interrupted); }, } @@ -486,6 +484,7 @@ async fn stream_logs_until_complete( } async fn drain_remaining_logs( + out: &output::Out, mut log_stream: tokio::sync::mpsc::Receiver, last_line_num: &mut Option, ) { @@ -493,10 +492,10 @@ async fn drain_remaining_logs( while let Some(event) = log_stream.recv().await { match event { api::LogStreamEvent::EventLog(log) => { - emit_log_if_new(&log, last_line_num); + emit_log_if_new(out, &log, last_line_num); } api::LogStreamEvent::EventWarning(warning) => { - output::write(&format!("Warning: {}\n", warning.data.content)); + out.write(&format!("Warning: {}\n", warning.data.content)); } } } @@ -504,9 +503,9 @@ async fn drain_remaining_logs( .await; } -fn emit_log_if_new(log: &RunLogLine, last_line_num: &mut Option) { +fn emit_log_if_new(out: &output::Out, log: &RunLogLine, last_line_num: &mut Option) { if should_emit_line(last_line_num, log.line_num) { - output::remote_log_event(log); + out.remote_log_event(log); } } @@ -555,7 +554,7 @@ fn monitor_run_completion( Err(_) => { failures += 1; if failures >= 5 { - output::error( + output::background_error( "Failed to monitor run completion after repeated errors", ); return; @@ -744,7 +743,9 @@ mod tests { drop(tx); }); - let res = stream_logs_until_complete(rx, done_rx, false, "link", &mut last_line_num).await; + let out = crate::output::Out::sink(); + let res = + stream_logs_until_complete(&out, rx, done_rx, false, "link", &mut last_line_num).await; done_task.await.unwrap(); assert!(matches!(res, Ok(LogFollowOutcome::Completed))); @@ -757,7 +758,9 @@ mod tests { let (_done_tx, done_rx) = oneshot::channel::(); let mut last_line_num = None; - let res = stream_logs_until_complete(rx, done_rx, false, "link", &mut last_line_num).await; + let out = crate::output::Out::sink(); + let res = + stream_logs_until_complete(&out, rx, done_rx, false, "link", &mut last_line_num).await; assert!(matches!(res, Ok(LogFollowOutcome::Disconnected))); } diff --git a/crates/tower-cmd/src/beta.rs b/crates/tower-cmd/src/beta.rs index fb6c0c29..09e746c4 100644 --- a/crates/tower-cmd/src/beta.rs +++ b/crates/tower-cmd/src/beta.rs @@ -2,7 +2,7 @@ use std::io::{self, IsTerminal}; use tower_telemetry::debug; -use crate::output::{self, OutputMode}; +use crate::output::{self, Out}; pub(crate) struct BetaFeature { id: &'static str, @@ -31,12 +31,10 @@ pub(crate) const STORAGE: BetaFeature = BetaFeature { docs_url: None, }; -pub(crate) fn notify_once(feature: &BetaFeature) { - let output_mode = output::get_output_mode(); - let stdout_is_terminal = io::stdout().is_terminal(); +pub(crate) fn notify_once(out: &Out, feature: &BetaFeature) { let stderr_is_terminal = io::stderr().is_terminal(); - if !should_notify(output_mode, stdout_is_terminal, stderr_is_terminal) { + if !should_notify(out.interactive(), out.foreground(), stderr_is_terminal) { return; } @@ -47,18 +45,16 @@ pub(crate) fn notify_once(feature: &BetaFeature) { } } -fn should_notify( - output_mode: OutputMode, - stdout_is_terminal: bool, - stderr_is_terminal: bool, -) -> bool { - output_mode.is_normal() && stdout_is_terminal && stderr_is_terminal +/// The notice only goes out for a foreground CLI driving an interactive terminal: +/// human output on a stdout TTY (never JSON or MCP capture), with stderr also a +/// TTY so the notice itself is seen. +fn should_notify(interactive: bool, foreground: bool, stderr_is_terminal: bool) -> bool { + interactive && foreground && stderr_is_terminal } #[cfg(test)] mod tests { use super::{should_notify, BetaFeature, STORAGE, STORAGE_BETA_MESSAGE}; - use crate::output::OutputMode; #[test] fn short_about_has_one_beta_suffix() { @@ -91,13 +87,14 @@ mod tests { } #[test] - fn notice_requires_normal_output_and_two_terminals() { - assert!(should_notify(OutputMode::Normal, true, true)); - assert!(!should_notify(OutputMode::Normal, false, true)); - assert!(!should_notify(OutputMode::Normal, true, false)); - assert!(!should_notify(OutputMode::Normal, false, false)); - assert!(!should_notify(OutputMode::Json, true, true)); - assert!(!should_notify(OutputMode::McpStdio, true, true)); - assert!(!should_notify(OutputMode::McpStreaming, true, true)); + fn notice_requires_interactive_foreground_and_stderr_terminal() { + assert!(should_notify(true, true, true)); + // stdout not an interactive terminal (redirected, JSON, or MCP capture) + assert!(!should_notify(false, true, true)); + // not a foreground CLI (MCP or discarded output) + assert!(!should_notify(true, false, true)); + // stderr not a terminal + assert!(!should_notify(true, true, false)); + assert!(!should_notify(false, false, false)); } } diff --git a/crates/tower-cmd/src/catalogs.rs b/crates/tower-cmd/src/catalogs.rs index 4c0a3801..4c1677fb 100644 --- a/crates/tower-cmd/src/catalogs.rs +++ b/crates/tower-cmd/src/catalogs.rs @@ -118,7 +118,7 @@ pub fn catalogs_cmd() -> Command { ) } -pub async fn do_list(config: Config, args: &ArgMatches) { +pub async fn do_list(out: &output::Out, config: Config, args: &ArgMatches) { let all = cmd::get_bool_flag(args, "all"); let env = cmd::get_string_flag(args, "environment"); let catalog_type = if cmd::get_bool_flag(args, "storage") { @@ -128,14 +128,15 @@ pub async fn do_list(config: Config, args: &ArgMatches) { }; if is_storage_catalog_type(catalog_type) { - beta::notify_once(&beta::STORAGE); + beta::notify_once(out, &beta::STORAGE); } - let catalogs = output::with_spinner( - "Listing catalogs", - api::list_catalogs(&config, &env, all, catalog_type), - ) - .await; + let catalogs = out + .with_spinner( + "Listing catalogs", + api::list_catalogs(&config, &env, all, catalog_type), + ) + .await; let headers = vec!["Name", "Type", "Environment"] .into_iter() @@ -151,11 +152,11 @@ pub async fn do_list(config: Config, args: &ArgMatches) { ] }) .collect(); - output::table(headers, data, Some(&catalogs)); + out.table(headers, data, Some(&catalogs)); } -pub async fn do_credentials(config: Config, args: &ArgMatches) { - beta::notify_once(&beta::STORAGE); +pub async fn do_credentials(out: &output::Out, config: Config, args: &ArgMatches) { + beta::notify_once(out, &beta::STORAGE); let name = args .get_one::("catalog_name") @@ -171,11 +172,12 @@ pub async fn do_credentials(config: Config, args: &ArgMatches) { .unwrap_or("all"); let show_token = cmd::get_bool_flag(args, "show_token"); - let response = output::with_spinner( - "Vending catalog credentials", - api::vend_catalog_credentials(&config, name, &env, parse_mode(mode)), - ) - .await; + let response = out + .with_spinner( + "Vending catalog credentials", + api::vend_catalog_credentials(&config, name, &env, parse_mode(mode)), + ) + .await; let human = credentials_text( name, @@ -186,10 +188,10 @@ pub async fn do_credentials(config: Config, args: &ArgMatches) { format, show_token, ); - output::text(&human, &response); + out.text(&human, &response); } -pub async fn do_show(config: Config, args: &ArgMatches) { +pub async fn do_show(out: &output::Out, config: Config, args: &ArgMatches) { let name = args .get_one::("catalog_name") .expect("catalog_name is required"); @@ -198,12 +200,12 @@ pub async fn do_show(config: Config, args: &ArgMatches) { match api::describe_catalog(&config, name, &env).await { Ok(response) => { if is_storage_catalog_type(Some(&response.catalog.r#type)) { - beta::notify_once(&beta::STORAGE); + beta::notify_once(out, &beta::STORAGE); } let human = catalog_details_text(&response); - output::text(&human, &response); + out.text(&human, &response); } - Err(err) => output::tower_error_and_die(err, "Fetching catalog details failed"), + Err(err) => out.tower_error_and_die(err, "Fetching catalog details failed"), } } diff --git a/crates/tower-cmd/src/deploy.rs b/crates/tower-cmd/src/deploy.rs index 81cb4d7f..74c864d9 100644 --- a/crates/tower-cmd/src/deploy.rs +++ b/crates/tower-cmd/src/deploy.rs @@ -101,7 +101,7 @@ fn resolve_idempotency_key(args: &ArgMatches, dir: &Path) -> Option { util::git::clean_head_sha(dir) } -pub async fn do_deploy(config: Config, args: &ArgMatches) { +pub async fn do_deploy(out: &output::Out, config: Config, args: &ArgMatches) { let dir = resolve_path(args); let create_app = args.get_flag("create"); let idempotency_key = resolve_idempotency_key(args, &dir); @@ -114,31 +114,32 @@ pub async fn do_deploy(config: Config, args: &ArgMatches) { DeployTarget::Environment("default".to_string()) }; - if let Err(err) = deploy_from_dir(config, dir, create_app, target, idempotency_key).await { + if let Err(err) = deploy_from_dir(out, config, dir, create_app, target, idempotency_key).await { match err { crate::Error::ApiDeployError { source } => { - output::tower_error_and_die(source, "Deploying app failed") + out.tower_error_and_die(source, "Deploying app failed") } crate::Error::ApiCreateAppError { source } => { - output::tower_error_and_die(source, "Creating app failed") + out.tower_error_and_die(source, "Creating app failed") } crate::Error::ApiDescribeAppError { source } => { - output::tower_error_and_die(source, "Fetching app details failed") + out.tower_error_and_die(source, "Fetching app details failed") } crate::Error::PackageError { source } => { - output::package_error(source); + out.package_error(source); std::process::exit(1); } crate::Error::TowerfileLoadFailed { source, .. } => { - output::package_error(source); + out.package_error(source); std::process::exit(1); } - _ => output::die(&err.to_string()), + _ => out.die(&err.to_string()), } } } pub async fn deploy_from_dir( + out: &output::Out, config: Config, dir: PathBuf, create_app: bool, @@ -159,6 +160,7 @@ pub async fn deploy_from_dir( // Add app existence check before proceeding util::apps::ensure_app_exists( + out, &api_config, &towerfile.app.name, towerfile.app.description.as_deref(), @@ -167,22 +169,31 @@ pub async fn deploy_from_dir( .await?; let spec = PackageSpec::from_towerfile(&towerfile); - let mut spinner = output::spinner("Building package..."); + let mut spinner = out.spinner("Building package..."); let package = match Package::build(spec).await { Ok(package) => package, Err(err) => { - spinner.failure(); + spinner.failure(out); let error = crate::Error::PackageError { source: err }; return Err(error); } }; - spinner.success(); - do_deploy_package(api_config, package, &towerfile, target, idempotency_key).await + spinner.success(out); + do_deploy_package( + out, + api_config, + package, + &towerfile, + target, + idempotency_key, + ) + .await } async fn do_deploy_package( + out: &output::Out, api_config: Configuration, package: Package, towerfile: &Towerfile, @@ -195,6 +206,7 @@ async fn do_deploy_package( }; let res = util::deploy::deploy_app_package( + out, &api_config, &towerfile.app.name, package, @@ -217,10 +229,10 @@ async fn do_deploy_package( version.version, env ), }; - output::success(&line); + out.success(&line); if let Some(hint) = reuse_hint(&version, idempotency_key.as_deref()) { - output::muted(&hint); + out.muted(&hint); } Ok(()) diff --git a/crates/tower-cmd/src/environments.rs b/crates/tower-cmd/src/environments.rs index 87e2b1fb..aef9369c 100644 --- a/crates/tower-cmd/src/environments.rs +++ b/crates/tower-cmd/src/environments.rs @@ -41,9 +41,10 @@ pub fn environments_cmd() -> Command { ) } -pub async fn do_list(config: Config) { - let environments = - output::with_spinner("Listing environments", api::list_environments(&config)).await; +pub async fn do_list(out: &output::Out, config: Config) { + let environments = out + .with_spinner("Listing environments", api::list_environments(&config)) + .await; let headers = vec!["Name".to_string()]; @@ -53,21 +54,21 @@ pub async fn do_list(config: Config) { .collect(); // Display the table using the existing table function - output::table(headers, envs_data, Some(&environments)); + out.table(headers, envs_data, Some(&environments)); } -pub async fn do_create(config: Config, args: &ArgMatches) { +pub async fn do_create(out: &output::Out, config: Config, args: &ArgMatches) { let name = args.get_one::("name").unwrap_or_else(|| { - output::die("Environment name (--name) is required"); + out.die("Environment name (--name) is required"); }); - output::with_spinner( + out.with_spinner( "Creating environment", api::create_environment(&config, name), ) .await; - output::success(&format!("Environment '{}' created", name)); + out.success(&format!("Environment '{}' created", name)); } fn env_resources_description(env: DescribeEnvironmentResponse) -> Option { @@ -88,24 +89,25 @@ fn env_resources_description(env: DescribeEnvironmentResponse) -> Option return None; } -pub async fn do_delete(config: Config, args: &ArgMatches) { +pub async fn do_delete(out: &output::Out, config: Config, args: &ArgMatches) { let name = args.get_one::("name").unwrap_or_else(|| { - output::die("Environment name (--name) is required"); + out.die("Environment name (--name) is required"); }); - let env = output::with_spinner( - "Retrieving environment...", - api::describe_environment(&config, name), - ) - .await; + let env = out + .with_spinner( + "Retrieving environment...", + api::describe_environment(&config, name), + ) + .await; if !env.environment.is_deletable { - output::error(&format!("You cannot delete the {name} environment.")); + out.error(&format!("You cannot delete the {name} environment.")); return; } if let Some(desc) = env_resources_description(env) { - output::write(&format!("Warning! Your environment contains {desc}.\n")) + out.write(&format!("Warning! Your environment contains {desc}.\n")) } let ans = prompt::confirm( @@ -115,20 +117,20 @@ pub async fn do_delete(config: Config, args: &ArgMatches) { match ans { Ok(true) => { - output::with_spinner( + out.with_spinner( &format!("Deleting environment {name}"), api::delete_environment(&config, name), ) .await; - output::success(&format!("Environment '{name}' deleted")); + out.success(&format!("Environment '{name}' deleted")); } - Ok(false) => output::write("Aborting environment deletion.\n"), + Ok(false) => out.write("Aborting environment deletion.\n"), Err(prompt::Error::ConfirmationPromptCancelled) => { - output::write("Aborting environment deletion.\n") + out.write("Aborting environment deletion.\n") } Err(_) => { - output::error( + out.error( "Something went wrong. Please try again, and contact us if the issue persists.", ); } diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index b45bc38e..2fb916d1 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -83,9 +83,11 @@ impl App { config.clone() }; - if config.json { - output::set_output_mode(output::OutputMode::Json); - } + let out = if config.json { + output::Out::json_stdout() + } else { + output::Out::human() + }; if config.debug { // Set log level to "DEBUG" @@ -120,18 +122,20 @@ impl App { } match matches.subcommand() { - Some(("login", args)) => session::do_login(config, args).await, - Some(("version", _)) => version::do_version().await, + Some(("login", args)) => session::do_login(&out, config, args).await, + Some(("version", _)) => version::do_version(&out).await, Some(("apps", sub_matches)) => { let apps_command = sub_matches.subcommand(); match apps_command { - Some(("list", args)) => apps::do_list_apps(sessionized_config, args).await, - Some(("create", args)) => apps::do_create(sessionized_config, args).await, - Some(("show", args)) => apps::do_show(sessionized_config, args).await, - Some(("logs", args)) => apps::do_logs(sessionized_config, args).await, - Some(("delete", args)) => apps::do_delete(sessionized_config, args).await, - Some(("cancel", args)) => apps::do_cancel(sessionized_config, args).await, + Some(("list", args)) => { + apps::do_list_apps(&out, sessionized_config, args).await + } + Some(("create", args)) => apps::do_create(&out, sessionized_config, args).await, + Some(("show", args)) => apps::do_show(&out, sessionized_config, args).await, + Some(("logs", args)) => apps::do_logs(&out, sessionized_config, args).await, + Some(("delete", args)) => apps::do_delete(&out, sessionized_config, args).await, + Some(("cancel", args)) => apps::do_cancel(&out, sessionized_config, args).await, _ => { apps::apps_cmd().print_help().unwrap(); std::process::exit(2); @@ -142,10 +146,10 @@ impl App { let catalogs_command = sub_matches.subcommand(); match catalogs_command { - Some(("list", args)) => catalogs::do_list(sessionized_config, args).await, - Some(("show", args)) => catalogs::do_show(sessionized_config, args).await, + Some(("list", args)) => catalogs::do_list(&out, sessionized_config, args).await, + Some(("show", args)) => catalogs::do_show(&out, sessionized_config, args).await, Some(("credentials", args)) => { - catalogs::do_credentials(sessionized_config, args).await + catalogs::do_credentials(&out, sessionized_config, args).await } _ => { catalogs::catalogs_cmd().print_help().unwrap(); @@ -157,9 +161,13 @@ impl App { let secrets_command = sub_matches.subcommand(); match secrets_command { - Some(("list", args)) => secrets::do_list(sessionized_config, args).await, - Some(("create", args)) => secrets::do_create(sessionized_config, args).await, - Some(("delete", args)) => secrets::do_delete(sessionized_config, args).await, + Some(("list", args)) => secrets::do_list(&out, sessionized_config, args).await, + Some(("create", args)) => { + secrets::do_create(&out, sessionized_config, args).await + } + Some(("delete", args)) => { + secrets::do_delete(&out, sessionized_config, args).await + } _ => { secrets::secrets_cmd().print_help().unwrap(); std::process::exit(2); @@ -170,12 +178,12 @@ impl App { let environments_command = sub_matches.subcommand(); match environments_command { - Some(("list", _)) => environments::do_list(sessionized_config).await, + Some(("list", _)) => environments::do_list(&out, sessionized_config).await, Some(("create", args)) => { - environments::do_create(sessionized_config, args).await + environments::do_create(&out, sessionized_config, args).await } Some(("delete", args)) => { - environments::do_delete(sessionized_config, args).await + environments::do_delete(&out, sessionized_config, args).await } _ => { environments::environments_cmd().print_help().unwrap(); @@ -186,25 +194,35 @@ impl App { let schedules_command = sub_matches.subcommand(); match schedules_command { - Some(("list", args)) => schedules::do_list(sessionized_config, args).await, - Some(("create", args)) => schedules::do_create(sessionized_config, args).await, - Some(("update", args)) => schedules::do_update(sessionized_config, args).await, - Some(("delete", args)) => schedules::do_delete(sessionized_config, args).await, + Some(("list", args)) => { + schedules::do_list(&out, sessionized_config, args).await + } + Some(("create", args)) => { + schedules::do_create(&out, sessionized_config, args).await + } + Some(("update", args)) => { + schedules::do_update(&out, sessionized_config, args).await + } + Some(("delete", args)) => { + schedules::do_delete(&out, sessionized_config, args).await + } _ => { schedules::schedules_cmd().print_help().unwrap(); std::process::exit(2); } } } - Some(("deploy", args)) => deploy::do_deploy(sessionized_config, args).await, - Some(("package", args)) => package::do_package(sessionized_config, args).await, - Some(("run", args)) => run::do_run(sessionized_config, args).await, + Some(("deploy", args)) => deploy::do_deploy(&out, sessionized_config, args).await, + Some(("package", args)) => package::do_package(&out, sessionized_config, args).await, + Some(("run", args)) => run::do_run(&out, sessionized_config, args).await, Some(("teams", sub_matches)) => { let teams_command = sub_matches.subcommand(); match teams_command { - Some(("list", _)) => teams::do_list(sessionized_config).await, - Some(("switch", args)) => teams::do_switch(sessionized_config, args).await, + Some(("list", _)) => teams::do_list(&out, sessionized_config).await, + Some(("switch", args)) => { + teams::do_switch(&out, sessionized_config, args).await + } _ => { teams::teams_cmd().print_help().unwrap(); std::process::exit(2); diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs index 7d69056f..fc301457 100644 --- a/crates/tower-cmd/src/mcp.rs +++ b/crates/tower-cmd/src/mcp.rs @@ -236,11 +236,10 @@ pub async fn do_mcp_server(config: Config, args: &clap::ArgMatches) -> Result<() } async fn run_stdio_server(config: Config) -> Result<(), Error> { - // Set stdio MCP mode to prevent any non-JSON-RPC output from corrupting the protocol - crate::output::set_output_mode(crate::output::OutputMode::McpStdio); - + // stdio transport only collects tool output (no notifications) so nothing pollutes + // the JSON-RPC stream on stdout. let (stdin, stdout) = stdio(); - let service = TowerService::new(config); + let service = TowerService::new(config, false); let server = service.serve((stdin, stdout)).await.map_err(|e| { Error::from(std::io::Error::new( std::io::ErrorKind::Other, @@ -253,14 +252,11 @@ async fn run_stdio_server(config: Config) -> Result<(), Error> { async fn run_sse_server(config: Config, port: u16) -> Result<(), Error> { let bind_addr = format!("127.0.0.1:{}", port); - crate::output::write(&format!("SSE MCP server running on http://{}\n", bind_addr)); - - // Set streaming MCP mode to enable logging notifications - crate::output::set_output_mode(crate::output::OutputMode::McpStreaming); + println!("SSE MCP server running on http://{}", bind_addr); let ct = SseServer::serve(bind_addr.parse()?) .await? - .with_service_directly(move || TowerService::new(config.clone())); + .with_service_directly(move || TowerService::new(config.clone(), true)); tokio::signal::ctrl_c().await?; ct.cancel(); @@ -269,16 +265,10 @@ async fn run_sse_server(config: Config, port: u16) -> Result<(), Error> { async fn run_http_server(config: Config, port: u16) -> Result<(), Error> { let bind_addr = format!("127.0.0.1:{}", port); - crate::output::write(&format!( - "Streamable HTTP MCP server running on http://{}\n", - bind_addr - )); - - // Set streaming MCP mode to enable logging notifications - crate::output::set_output_mode(crate::output::OutputMode::McpStreaming); + println!("Streamable HTTP MCP server running on http://{}", bind_addr); let service = StreamableHttpService::new( - move || Ok(TowerService::new(config.clone())), + move || Ok(TowerService::new(config.clone(), true)), LocalSessionManager::default().into(), StreamableHttpServerConfig::default(), ); @@ -296,18 +286,24 @@ async fn run_http_server(config: Config, port: u16) -> Result<(), Error> { #[derive(Clone)] pub struct TowerService { config: Config, + /// Whether tool output should be streamed to the peer as logging notifications. + /// True for SSE/HTTP transports, false for stdio (which only collects output). + send_notifications: bool, tool_router: ToolRouter, } #[tool_router] impl TowerService { - pub fn new(config: Config) -> Self { + pub fn new(config: Config, send_notifications: bool) -> Self { + // MCP output is captured as plain text, so disable ANSI colours globally. + colored::control::set_override(false); Self { config: std::env::var("TOWER_JWT") .ok() .and_then(|token| Session::from_jwt(&token).ok()) .map(|session| config.clone().with_session(session)) .unwrap_or(config), + send_notifications, tool_router: Self::tool_router(), } } @@ -466,25 +462,23 @@ impl TowerService { } async fn execute_with_streaming( + &self, ctx: &RequestContext, operation: F, ) -> (Result, String) where - F: FnOnce() -> Fut, + F: FnOnce(crate::output::Out) -> Fut, Fut: std::future::Future>, { - // Check if we're in streaming mode (SSE/HTTP) where we send notifications - // vs stdio mode where we don't - let mode = crate::output::get_output_mode(); - let send_notifications = mode == crate::output::OutputMode::McpStreaming; - let streaming = Self::setup_output_capture(ctx, send_notifications); - - crate::output::set_current_sender(streaming.sender.clone()); + let streaming = Self::setup_output_capture(ctx, self.send_notifications); - let result = operation().await; + // The command writes through this `Out`; each line it produces is forwarded to + // the capture channel (and, in streaming mode, to the peer as a notification). + // The operation owns the `Out` and drops it when finished, which closes the + // channel so the drain task below can complete. + let out = crate::output::Out::mcp(streaming.sender); + let result = operation(out).await; - crate::output::clear_current_sender(); - drop(streaming.sender); streaming.task.await.ok(); let output = streaming @@ -817,7 +811,10 @@ impl TowerService { // collapse to a single AppVersion server-side. let idempotency_key = crate::util::git::clean_head_sha(&working_dir); + // The tool builds its own result message, so the deploy's own progress is discarded. + let out = crate::output::Out::sink(); match deploy::deploy_from_dir( + &out, self.config.clone(), working_dir, true, @@ -842,15 +839,17 @@ impl TowerService { let working_dir = Self::resolve_working_directory(&request.common); let config = self.config.clone(); - let (result, output) = Self::execute_with_streaming(&ctx, || { - run::do_run_local( - config, - working_dir, - "default", - std::collections::HashMap::new(), - ) - }) - .await; + let (result, output) = self + .execute_with_streaming(&ctx, |out| { + run::do_run_local( + out, + config, + working_dir, + "default", + std::collections::HashMap::new(), + ) + }) + .await; match result { Ok(_) => { if output.trim().is_empty() { @@ -894,10 +893,11 @@ impl TowerService { let app_name = towerfile.app.name.clone(); - let (result, output) = Self::execute_with_streaming(&ctx, || { - run::do_run_remote(config, path, &env, params, None, true) - }) - .await; + let (result, output) = self + .execute_with_streaming(&ctx, |out| { + run::do_run_remote(out, config, path, &env, params, None, true) + }) + .await; match result { Ok(_) => { if output.trim().is_empty() { diff --git a/crates/tower-cmd/src/output.rs b/crates/tower-cmd/src/output.rs index bd8b28ba..cb395739 100644 --- a/crates/tower-cmd/src/output.rs +++ b/crates/tower-cmd/src/output.rs @@ -1,13 +1,13 @@ pub use cli_table::{format::Justify, Cell}; use cli_table::{ format::{Border, HorizontalLine, Separator}, - print_stdout, Table, TableStruct, + Table, TableStruct, }; -use colored::{control, Colorize}; +use colored::Colorize; use http::StatusCode; use serde::Serialize; -use std::io::{self, Write}; -use std::sync::{Mutex, OnceLock}; +use std::io::{self, IsTerminal, Write}; +use std::sync::{Arc, Mutex}; use tokio::sync::mpsc::UnboundedSender; use tower_api::{ apis::{Error as ApiError, ResponseContent}, @@ -17,435 +17,546 @@ use tower_telemetry::debug; const BANNER_TEXT: &str = include_str!("./banner.txt"); +/// How results are rendered. `Human` produces the coloured, formatted output; +/// `Json` produces machine-parseable JSON. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OutputMode { - /// Normal CLI output with colors and formatting - Normal, - /// JSON output mode +pub enum Format { + Human, Json, - /// MCP server with stdio transport (no notifications, capture output) - McpStdio, - /// MCP server with streaming transport like SSE/HTTP (send notifications) - McpStreaming, } -impl OutputMode { - pub fn is_mcp(self) -> bool { - matches!(self, Self::McpStdio | Self::McpStreaming) - } +/// `Out` is the explicit output destination for a command. It carries the render +/// format and the writer to send results to, so nothing needs to consult a global +/// to decide where output goes. The CLI builds one over stdout; the MCP server +/// builds one over a channel that forwards each line to the connected peer. +/// +/// `Out` is cheap to clone and safe to send between tasks — clones share the same +/// underlying writer through an `Arc>`, so a spawned task (e.g. a log +/// monitor) can own a clone and write to the same destination as the foreground. +#[derive(Clone)] +pub struct Out { + format: Format, + writer: Arc>>, + /// True only when driving an interactive terminal, where spinners animate. + /// False for JSON and for MCP capture. + interactive: bool, + /// True when this is a foreground CLI process that owns terminal signals + /// (so it may install a Ctrl+C handler). False for MCP/captured output. + foreground: bool, +} - pub fn is_json(self) -> bool { - matches!(self, Self::Json) +impl Out { + fn new( + format: Format, + writer: Box, + interactive: bool, + foreground: bool, + ) -> Self { + Self { + format, + writer: Arc::new(Mutex::new(writer)), + interactive, + foreground, + } } - pub fn is_normal(self) -> bool { - matches!(self, Self::Normal) + /// Human-formatted output to stdout. Spinners animate when stdout is a TTY. + pub fn human() -> Self { + let interactive = io::stdout().is_terminal(); + Self::new(Format::Human, Box::new(io::stdout()), interactive, true) } -} -static OUTPUT_MODE: OnceLock = OnceLock::new(); -static CURRENT_SENDER: Mutex>> = Mutex::new(None); + /// JSON output to stdout. No spinner animation so stdout stays parseable. + pub fn json_stdout() -> Self { + Self::new(Format::Json, Box::new(io::stdout()), false, true) + } -fn write_to_stdout(msg: &str) { - let mut stdout = io::stdout(); - if let Err(err) = stdout.write_all(msg.as_bytes()) { - if err.kind() == io::ErrorKind::BrokenPipe { - std::process::exit(0); - } - panic!("failed writing to stdout: {err}"); + /// Output captured for an MCP tool call. Each written line is forwarded to the + /// supplied channel; the MCP server collects the lines and optionally streams + /// them to the peer as logging notifications. + pub fn mcp(sender: UnboundedSender) -> Self { + Self::new(Format::Human, Box::new(McpWriter { sender }), false, false) } - stdout.flush().ok(); -} -fn write_to_stderr(msg: &str) { - let mut stderr = io::stderr(); - if let Err(err) = stderr.write_all(msg.as_bytes()) { - if err.kind() == io::ErrorKind::BrokenPipe { - std::process::exit(0); - } - panic!("failed writing to stderr: {err}"); + /// Output that goes nowhere. Used where a command emits progress that the caller + /// discards (e.g. the MCP deploy tool builds its own result message). + pub fn sink() -> Self { + Self::new(Format::Human, Box::new(io::sink()), false, false) } - stderr.flush().ok(); -} -pub(crate) fn notice_to_stderr(label: &str, msg: &str) { - let line = format!("{} {}\n", label.bold().yellow(), msg); - write_to_stderr(&line); -} + fn is_json(&self) -> bool { + self.format == Format::Json + } -pub fn set_output_mode(mode: OutputMode) { - OUTPUT_MODE.set(mode).ok(); - if mode.is_mcp() { - control::set_override(false); + /// Whether this process owns terminal signal handling (CLI foreground). MCP and + /// other captured outputs return false, so they don't trap Ctrl+C. + pub fn foreground(&self) -> bool { + self.foreground } -} -pub fn get_output_mode() -> OutputMode { - OUTPUT_MODE.get().copied().unwrap_or(OutputMode::Normal) -} + /// Whether this output drives an interactive terminal: human format on a + /// stdout TTY. False for JSON, MCP capture, and redirected output. + pub fn interactive(&self) -> bool { + self.interactive + } -pub fn set_current_sender(sender: UnboundedSender) { - *CURRENT_SENDER.lock().unwrap() = Some(sender); -} + /// Writes raw text to the destination. On a broken pipe the process exits + /// cleanly, matching classic Unix tool behaviour. + pub fn write(&self, msg: &str) { + let mut writer = self.writer.lock().unwrap(); + if let Err(err) = writer.write_all(msg.as_bytes()) { + if err.kind() == io::ErrorKind::BrokenPipe { + std::process::exit(0); + } + panic!("failed writing output: {err}"); + } + writer.flush().ok(); + } -pub fn clear_current_sender() { - *CURRENT_SENDER.lock().unwrap() = None; -} + pub fn newline(&self) { + self.write("\n"); + } -fn send_to_current_sender(msg: String) { - if let Ok(sender_guard) = CURRENT_SENDER.lock() { - if let Some(tx) = sender_guard.as_ref() { - tx.send(msg).ok(); - } + pub fn banner(&self) { + self.write(BANNER_TEXT); } -} -pub fn json(data: &T) { - match serde_json::to_string_pretty(data) { - Ok(json_str) => { - write(&format!("{}\n", json_str)); - } - Err(e) => { - error(&format!("Failed to serialize to JSON: {}", e)); + fn json(&self, data: &T) { + match serde_json::to_string_pretty(data) { + Ok(json_str) => { + let line = format!("{}\n", json_str); + self.write(&line); + } + Err(e) => { + self.error(&format!("Failed to serialize to JSON: {}", e)); + } } } -} -pub fn success(msg: &str) { - success_with_data(msg, None::); -} + pub fn success(&self, msg: &str) { + self.success_with_data(msg, None::); + } -pub fn muted(msg: &str) { - if get_output_mode().is_json() { - let response = serde_json::json!({ - "result": "muted", + pub fn success_with_data(&self, msg: &str, data: Option) { + let mut response = serde_json::json!({ + "result": "success", "message": msg }); - json(&response); - } else { - let line = msg.dimmed(); - write(&line); - } -} -pub fn success_with_data(msg: &str, data: Option) { - let mut response = serde_json::json!({ - "result": "success", - "message": msg - }); + if let Some(data) = data { + response["data"] = serde_json::to_value(data).unwrap(); + } - if let Some(data) = data { - response["data"] = serde_json::to_value(data).unwrap(); + let line = format!("{} {}\n", "Success!".green(), msg); + self.text(&line, &response); } - let line = format!("{} {}\n", "Success!".green(), msg); - text(&line, &response); -} - -pub enum LogLineType { - Remote, - Local, -} - -fn format_timestamp(timestamp: &str, t: LogLineType) -> String { - let ts = timestamp.bold(); - - let sep = "|".bold(); - - match t { - LogLineType::Remote => format!("{} {}", ts.yellow(), sep.yellow()), - LogLineType::Local => format!("{} {}", ts.green(), sep.green()), + /// Writes a low-emphasis informational line, dimmed in human output. + pub fn muted(&self, msg: &str) { + if self.is_json() { + let response = serde_json::json!({ + "result": "muted", + "message": msg + }); + self.json(&response); + } else { + let line = format!("{}\n", msg.dimmed()); + self.write(&line); + } } -} -pub fn log_line(timestamp: &str, message: &str, t: LogLineType) { - let line = format!("{} {}\n", format_timestamp(timestamp, t), message); - write(&line); -} - -pub fn remote_log_event(log: &tower_api::models::RunLogLine) { - let ts = crate::util::dates::format_str(&log.reported_at); - log_line(&ts, &log.content, LogLineType::Remote); -} - -pub fn package_error(err: tower_package::Error) { - let msg = match err { - tower_package::Error::NoManifest => "No manifest was found".to_string(), - tower_package::Error::InvalidManifest => { - "Invalid manifest was found or created".to_string() - } - tower_package::Error::InvalidPath => { - "There was a problem determining exactly where your Towerfile was stored on disk" - .to_string() - } - tower_package::Error::InvalidGlob { message } => { - format!("Invalid file glob pattern: {}", message) - } - tower_package::Error::InvalidTowerfile { message } => { - format!("Invalid Towerfile: {}", message) - } - tower_package::Error::MissingTowerfile => { - "No Towerfile was found in the target directory".to_string() - } - tower_package::Error::MissingRequiredAppField { field } => { - format!("Missing required app field `{}` in Towerfile", field) - } - tower_package::Error::Io { source } => format!("IO error: {}", source), - tower_package::Error::MissingScript { script } => { - format!("Script '{}' not found. Check that the 'script' field in your Towerfile points to a file that exists in your project.", script) + pub fn error(&self, msg: &str) { + if self.is_json() { + let response = serde_json::json!({ + "result": "error", + "message": msg + }); + self.json(&response); + } else { + let line = format!("{} {}\n", "Oh no!".red(), msg); + self.write(&line); } - }; - - let line = format!("{} {}\n", "Package error:".red(), msg); - write(&line); -} - -pub fn header(text: &str) { - let line = format!("{}\n", text.bold().green()); - write(&line); -} + } -pub fn detail(label: &str, value: &str) { - let line = format!("{} {}\n", format!("{}:", label).bold().green(), value); - write(&line); -} + pub fn log_line(&self, timestamp: &str, message: &str, t: LogLineType) { + let line = format!("{} {}\n", format_timestamp(timestamp, t), message); + self.write(&line); + } -pub fn title(text: &str) -> String { - text.bold().green().to_string() -} + pub fn remote_log_event(&self, log: &tower_api::models::RunLogLine) { + let ts = crate::util::dates::format_str(&log.reported_at); + self.log_line(&ts, &log.content, LogLineType::Remote); + } -pub fn placeholder(text: &str) -> String { - text.white().dimmed().italic().to_string() -} + pub fn package_error(&self, err: tower_package::Error) { + let msg = match err { + tower_package::Error::NoManifest => "No manifest was found".to_string(), + tower_package::Error::InvalidManifest => { + "Invalid manifest was found or created".to_string() + } + tower_package::Error::InvalidPath => { + "There was a problem determining exactly where your Towerfile was stored on disk" + .to_string() + } + tower_package::Error::InvalidGlob { message } => { + format!("Invalid file glob pattern: {}", message) + } + tower_package::Error::InvalidTowerfile { message } => { + format!("Invalid Towerfile: {}", message) + } + tower_package::Error::MissingTowerfile => { + "No Towerfile was found in the target directory".to_string() + } + tower_package::Error::MissingRequiredAppField { field } => { + format!("Missing required app field `{}` in Towerfile", field) + } + tower_package::Error::Io { source } => format!("IO error: {}", source), + tower_package::Error::MissingScript { script } => { + format!("Script '{}' not found. Check that the 'script' field in your Towerfile points to a file that exists in your project.", script) + } + }; -pub fn paragraph(msg: &str) -> String { - msg.chars() - .collect::>() - .chunks(78) - .map(|c| c.iter().collect::()) - .map(|li| format!(" {}", li)) - .collect::>() - .join("\n") -} + let line = format!("{} {}\n", "Package error:".red(), msg); + self.write(&line); + } -pub fn config_error(err: config::Error) { - let msg = match err { - config::Error::ConfigDirNotFound => "Config directory not found".to_string(), - config::Error::NoHomeDir => "No home directory found".to_string(), - config::Error::Io { ref source } => format!("IO error: {}", source), - config::Error::NoSession => "No session".to_string(), - config::Error::TeamNotFound { ref team_name } => { - format!("Team with name `{}` not found!", team_name) - } - config::Error::UnknownDescribeSessionValue { value: _ } => { - "An error occured while describing the session associated with the JWT you provided. Maybe your CLI is out of date?".to_string() - } - config::Error::DescribeSessionError { ref err } => { - format!("An error occured while describing the session associated with the JWT you provided: {}", err) + pub fn config_error(&self, err: config::Error) { + let msg = match err { + config::Error::ConfigDirNotFound => "Config directory not found".to_string(), + config::Error::NoHomeDir => "No home directory found".to_string(), + config::Error::Io { ref source } => format!("IO error: {}", source), + config::Error::NoSession => "No session".to_string(), + config::Error::TeamNotFound { ref team_name } => { + format!("Team with name `{}` not found!", team_name) + } + config::Error::UnknownDescribeSessionValue { value: _ } => { + "An error occured while describing the session associated with the JWT you provided. Maybe your CLI is out of date?".to_string() + } + config::Error::DescribeSessionError { ref err } => { + format!("An error occured while describing the session associated with the JWT you provided: {}", err) + } + }; + + let line = format!("{} {}\n", "Config error:".red(), msg); + self.write(&line); + } + + // Outputs both the model.detail and the model.errors fields in a human readable format. + fn output_full_error_details(&self, model: &ErrorModel) { + // Show the main detail message if available + if let Some(detail) = &model.detail { + self.write(&format!("\n{}\n", "Error details:".yellow())); + self.write(&format!("{}\n", detail.red())); + } + + // Show any additional error details from the errors field + if let Some(errors) = &model.errors { + if !errors.is_empty() { + if model.detail.is_none() { + self.write(&format!("\n{}\n", "Error details:".yellow())); + } + for error in errors { + let msg = format!( + " • {}", + error.message.as_deref().unwrap_or("Unknown error") + ); + self.write(&format!("{}\n", msg.red())); + } + } } - }; + } - let line = format!("{} {}\n", "Config error:".red(), msg); - write(&line); -} + fn output_response_content_error(&self, err: ResponseContent) { + // Attempt to deserialize the error content into an ErrorModel. + let error_model = match serde_json::from_str::(&err.content) { + Ok(model) => { + debug!("Error model (status: {}): {:?}", err.status, model); + model + } + Err(e) => { + debug!("Failed to parse error content as JSON: {}", e); + debug!("Raw error content: {}", err.content); + // Show the raw error content if JSON parsing fails + self.write(&format!("\n{}\n", "API Error:".yellow())); + self.write(&format!("{}\n", err.content.red())); + return; + } + }; -pub fn write(msg: &str) { - if get_output_mode().is_mcp() { - let clean_msg = msg.trim_end().to_string(); - send_to_current_sender(clean_msg); - } else { - write_to_stdout(msg); + match err.status { + StatusCode::CONFLICT => { + self.output_full_error_details(&error_model); + } + StatusCode::UNPROCESSABLE_ENTITY => { + self.output_full_error_details(&error_model); + } + StatusCode::INTERNAL_SERVER_ERROR => { + self.error( + "The Tower API encountered an internal error. Maybe try again later on.", + ); + } + StatusCode::NOT_FOUND => { + self.output_full_error_details(&error_model); + } + StatusCode::UNAUTHORIZED => { + self.error( + "You aren't authorized to do that! Are you logged in? Run `tower login` to login.", + ); + } + _ => { + if error_model.detail.is_none() && error_model.errors.is_none() { + self.error("The Tower API returned an error that the Tower CLI doesn't know what to do with! Maybe try again in a bit."); + } + self.output_full_error_details(&error_model); + } + } } -} -pub fn error(msg: &str) { - if get_output_mode().is_json() { - let response = serde_json::json!({ - "result": "error", - "message": msg - }); - json(&response); - } else { - let line = format!("{} {}\n", "Oh no!".red(), msg); - write(&line); + fn tower_error(&self, err: ApiError) { + match err { + ApiError::ResponseError(resp) => { + self.output_response_content_error(resp); + } + ApiError::Reqwest(e) => { + debug!("Reqwest error: {:?}", e); + self.error("The Tower CLI wasn't able to talk to the Tower API! Are you offline? Try again later."); + } + ApiError::Serde(e) => { + debug!("Serde error: {:?}", e); + self.error("The Tower API returned something that the Tower CLI didn't understand. Maybe you need to upgrade Tower CLI?"); + } + ApiError::Io(e) => { + debug!("Io error: {:?}", e); + self.error("An error happened while talking to the Tower API. You can try that again in a bit."); + } + } } -} -pub fn runtime_error(err: tower_runtime::errors::Error) { - let line = format!("{} {}\n", "Runtime Error:".red(), err.to_string()); - write(&line); -} + /// Handles Tower API errors with context-specific authentication messages. + /// If the error is a 401 Unauthorized, provides a helpful message mentioning + /// the operation that failed and suggests running 'tower login'. + /// Always exits the process with error code 1. + pub fn tower_error_and_die(&self, err: ApiError, operation: &str) -> ! { + // Check if this is an authentication error + if let ApiError::ResponseError(ref resp) = err { + if resp.status == StatusCode::UNAUTHORIZED { + self.die(&format!( + "{} because you are not logged into Tower. Please run 'tower login' first.", + operation + )); + } + } -// Outputs both the model.detail and the model.errors fields in a human readable format. -pub fn output_full_error_details(model: &ErrorModel) { - // Show the main detail message if available - if let Some(detail) = &model.detail { - write(&format!("\n{}\n", "Error details:".yellow())); - write(&format!("{}\n", detail.red())); + // Show the detailed error first + self.tower_error(err); + self.die(operation); + } + + pub fn table( + &self, + headers: Vec, + data: Vec>, + json_data: Option<&T>, + ) { + if self.is_json() { + if let Some(data) = json_data { + self.json(data); + } else { + // Fallback: convert table data to JSON structure + let json_output: Vec> = data + .iter() + .map(|row| { + let mut obj = serde_json::Map::new(); + for (i, value) in row.iter().enumerate() { + let key = headers + .get(i) + .expect("header should have same number of columns as row"); + obj.insert(key.to_string(), serde_json::Value::String(value.clone())); + } + obj + }) + .collect(); + self.json(&json_output); + } + } else { + let line = format!("{}\n", table_text(headers, data)); + self.write(&line); + } } - // Show any additional error details from the errors field - if let Some(errors) = &model.errors { - if !errors.is_empty() { - if model.detail.is_none() { - write(&format!("\n{}\n", "Error details:".yellow())); + pub fn list(&self, items: Vec, json_data: Option<&T>) { + if self.is_json() { + if let Some(data) = json_data { + self.json(data); + } else { + self.json(&items); } - for error in errors { - let msg = format!( - " • {}", - error.message.as_deref().unwrap_or("Unknown error") - ); - write(&format!("{}\n", msg.red())); + } else { + for item in items { + let line = format!(" * {}\n", item); + let line = line.replace("\n", "\n "); + let line = format!("{}\n", line); + self.write(&line); } } } -} -fn output_response_content_error(err: ResponseContent) { - // Attempt to deserialize the error content into an ErrorModel. - let error_model = match serde_json::from_str::(&err.content) { - Ok(model) => { - debug!("Error model (status: {}): {:?}", err.status, model); - model - } - Err(e) => { - debug!("Failed to parse error content as JSON: {}", e); - debug!("Raw error content: {}", err.content); - // Show the raw error content if JSON parsing fails - write(&format!("\n{}\n", "API Error:".yellow())); - write(&format!("{}\n", err.content.red())); - return; + /// Writes a human-readable rendering of some data, or the data itself as JSON when + /// in JSON mode. Use this when a command's output is data that has both a plain text + /// and a JSON representation, mirroring `table` and `list`. + pub fn text(&self, msg: &str, json_data: &T) { + if self.is_json() { + self.json(json_data); + } else { + self.write(msg); } - }; + } - match err.status { - StatusCode::CONFLICT => { - output_full_error_details(&error_model); - } - StatusCode::UNPROCESSABLE_ENTITY => { - output_full_error_details(&error_model); + /// Writes presentation-only text that accompanies human-formatted output, like table + /// legends or hints. Suppressed in JSON mode so stdout stays machine-parseable. + pub fn note(&self, msg: &str) { + if !self.is_json() { + self.write(msg); } - StatusCode::INTERNAL_SERVER_ERROR => { - error("The Tower API encountered an internal error. Maybe try again later on."); - } - StatusCode::NOT_FOUND => { - output_full_error_details(&error_model); - } - StatusCode::UNAUTHORIZED => { - error( - "You aren't authorized to do that! Are you logged in? Run `tower login` to login.", - ); - } - _ => { - if error_model.detail.is_none() && error_model.errors.is_none() { - error("The Tower API returned an error that the Tower CLI doesn't know what to do with! Maybe try again in a bit."); + } + + pub fn die(&self, msg: &str) -> ! { + io::stdout().flush().ok(); + io::stderr().flush().ok(); + let line = format!("{} {}\n", "Error:".red(), msg); + self.write(&line); + // Flush output before exit to ensure "Error:" message is displayed + io::stdout().flush().ok(); + io::stderr().flush().ok(); + std::process::exit(1); + } + + /// Starts a spinner for a long running task. It animates only on an interactive + /// terminal; otherwise its completion messages are written like any other line so + /// they are still captured (e.g. by the MCP server). + pub fn spinner(&self, msg: &str) -> Spinner { + let anim = if self.interactive { + Some(spinners::Spinner::new( + spinners::Spinners::Dots, + msg.to_string(), + )) + } else { + None + }; + Spinner { + msg: msg.to_string(), + anim, + } + } + + /// Runs an async operation with a spinner and proper error handling. + /// + /// - Shows a spinner with "{operation}..." while the operation runs + /// - On success: stops the spinner with success indicator and returns the result + /// - On error: stops the spinner with failure indicator and shows an auth-aware + /// error message, then exits the process. + pub async fn with_spinner(&self, operation: &str, future: F) -> T + where + F: std::future::Future>>, + { + let spinner_msg = format!("{}...", operation); + let mut spinner = self.spinner(&spinner_msg); + match future.await { + Ok(result) => { + spinner.success(self); + result + } + Err(err) => { + spinner.failure(self); + let error_msg = format!("{} failed", operation); + self.tower_error_and_die(err, &error_msg); } - output_full_error_details(&error_model); } } -} -pub fn tower_error(err: ApiError) { - match err { - ApiError::ResponseError(resp) => { - output_response_content_error(resp); - } - ApiError::Reqwest(e) => { - debug!("Reqwest error: {:?}", e); - error("The Tower CLI wasn't able to talk to the Tower API! Are you offline? Try again later."); - } - ApiError::Serde(e) => { - debug!("Serde error: {:?}", e); - error("The Tower API returned something that the Tower CLI didn't understand. Maybe you need to upgrade Tower CLI?"); - } - ApiError::Io(e) => { - debug!("Io error: {:?}", e); - error("An error happened while talking to the Tower API. You can try that again in a bit."); + /// The MCP-safe version of `with_spinner`: returns errors instead of exiting. + /// Use this for operations that may be called from MCP or other contexts where + /// process exit is not acceptable. Returns the error without displaying it, so + /// the caller decides how to handle and display it. + pub async fn try_with_spinner( + &self, + operation: &str, + future: F, + ) -> Result> + where + F: std::future::Future>>, + { + let spinner_msg = format!("{}...", operation); + let mut spinner = self.spinner(&spinner_msg); + match future.await { + Ok(result) => { + spinner.success(self); + Ok(result) + } + Err(err) => { + spinner.failure(self); + // Just return the error - let the caller decide how to handle it + Err(err) + } } } } -/// Handles Tower API errors with context-specific authentication messages. -/// If the error is a 401 Unauthorized, provides a helpful message mentioning -/// the operation that failed and suggests running 'tower login'. -/// Always exits the process with error code 1. -pub fn tower_error_and_die(err: ApiError, operation: &str) -> ! { - // Check if this is an authentication error - if let ApiError::ResponseError(ref resp) = err { - if resp.status == StatusCode::UNAUTHORIZED { - die(&format!( - "{} because you are not logged into Tower. Please run 'tower login' first.", - operation - )); +/// A writer that forwards each written chunk to an MCP channel as one message +/// (trailing whitespace trimmed). This replaces the previous global sender: the +/// destination now lives in the `Out`. +struct McpWriter { + sender: UnboundedSender, +} + +impl Write for McpWriter { + fn write(&mut self, data: &[u8]) -> io::Result { + let text = String::from_utf8_lossy(data).trim_end().to_string(); + if !text.is_empty() { + self.sender.send(text).ok(); } + Ok(data.len()) } - // Show the detailed error first - tower_error(err); - die(operation); + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } } -/// Runs an async operation with a spinner and proper error handling. -/// -/// This helper provides consistent spinner behavior across all commands: -/// - Shows a spinner with "{operation}..." while the operation runs -/// - On success: stops the spinner with success indicator and returns the result -/// - On error: stops the spinner with failure indicator and shows auth-aware error message -/// -/// # Examples -/// -/// ```ignore -/// let envs = output::with_spinner( -/// "Listing environments", -/// api::list_environments(&config) -/// ).await; -/// ``` -pub async fn with_spinner(operation: &str, future: F) -> T -where - F: std::future::Future>>, -{ - let spinner_msg = format!("{}...", operation); - let mut spinner = self::spinner(&spinner_msg); - match future.await { - Ok(result) => { - spinner.success(); - result - } - Err(err) => { - spinner.failure(); - let error_msg = format!("{} failed", operation); - tower_error_and_die(err, &error_msg); - } - } +pub enum LogLineType { + Remote, + Local, } -/// Runs an async operation with a spinner, returning Result instead of exiting. -/// -/// This is the MCP-safe version of with_spinner that returns errors instead of exiting. -/// Use this for operations that may be called from MCP or other contexts where -/// process exit is not acceptable. Returns the error without displaying it, allowing -/// the caller to decide how to handle and display the error. -/// -/// Shows "{operation}..." during execution and stops the spinner on completion. -pub async fn try_with_spinner(operation: &str, future: F) -> Result> -where - F: std::future::Future>>, -{ - let spinner_msg = format!("{}...", operation); - let mut spinner = self::spinner(&spinner_msg); - match future.await { - Ok(result) => { - spinner.success(); - Ok(result) - } - Err(err) => { - spinner.failure(); - // Just return the error - let the caller decide how to handle it - Err(err) - } +fn format_timestamp(timestamp: &str, t: LogLineType) -> String { + let ts = timestamp.bold(); + + let sep = "|".bold(); + + match t { + LogLineType::Remote => format!("{} {}", ts.yellow(), sep.yellow()), + LogLineType::Local => format!("{} {}", ts.green(), sep.green()), } } +pub fn title(text: &str) -> String { + text.bold().green().to_string() +} + +pub fn placeholder(text: &str) -> String { + text.white().dimmed().italic().to_string() +} + +pub fn paragraph(msg: &str) -> String { + msg.chars() + .collect::>() + .chunks(78) + .map(|c| c.iter().collect::()) + .map(|li| format!(" {}", li)) + .collect::>() + .join("\n") +} + fn formatted_table(headers: Vec, data: Vec>) -> TableStruct { let separator = Separator::builder() .title(Some(HorizontalLine::default())) @@ -464,120 +575,62 @@ pub fn table_text(headers: Vec, data: Vec>) -> String { } } -pub fn table(headers: Vec, data: Vec>, json_data: Option<&T>) { - if get_output_mode().is_json() { - if let Some(data) = json_data { - json(data); - } else { - // Fallback: convert table data to JSON structure - let json_output: Vec> = data - .iter() - .map(|row| { - let mut obj = serde_json::Map::new(); - for (i, value) in row.iter().enumerate() { - let key = headers - .get(i) - .expect("header should have same number of columns as row"); - obj.insert(key.to_string(), serde_json::Value::String(value.clone())); - } - obj - }) - .collect(); - json(&json_output); - } - } else { - let table = formatted_table(headers, data); - - if let Err(err) = print_stdout(table) { - if err.kind() == io::ErrorKind::BrokenPipe { - std::process::exit(0); - } - panic!("failed writing table to stdout: {err}"); - } - } -} - -pub fn list(items: Vec, json_data: Option<&T>) { - if get_output_mode().is_json() { - if let Some(data) = json_data { - json(data); - } else { - json(&items); - } - } else { - for item in items { - let line = format!(" * {}\n", item); - let line = line.replace("\n", "\n "); - let line = format!("{}\n", line); - write(&line); - } - } -} - -/// Writes a human-readable rendering of some data, or the data itself as JSON when -/// in JSON mode. Use this when a command's output is data that has both a plain text -/// and a JSON representation, mirroring `table` and `list`. -pub fn text(msg: &str, json_data: &T) { - if get_output_mode().is_json() { - json(json_data); - } else { - write(msg); - } -} - -/// Writes presentation-only text that accompanies human-formatted output, like table -/// legends or hints. Suppressed in JSON mode so stdout stays machine-parseable. -pub fn note(msg: &str) { - if !get_output_mode().is_json() { - write(msg); - } -} - -pub fn banner() { - write(&BANNER_TEXT); -} - pub struct Spinner { msg: String, - spinner: Option, + anim: Option, } impl Spinner { - pub fn new(msg: String) -> Spinner { - if get_output_mode().is_normal() { - let spinner = spinners::Spinner::new(spinners::Spinners::Dots, msg.clone()); - Spinner { - spinner: Some(spinner), - msg, - } - } else { - Spinner { spinner: None, msg } - } - } - - pub fn success(&mut self) { - if let Some(ref mut spinner) = self.spinner { + pub fn success(&mut self, out: &Out) { + if let Some(ref mut spinner) = self.anim { let sym = "✔".bold().green().to_string(); spinner.stop_and_persist(&sym, format!("{} Done!", self.msg)); - } else if get_output_mode().is_mcp() { - send_to_current_sender(format!("{} Done!", self.msg)); + } else if out.format == Format::Human { + out.write(&format!("{} Done!\n", self.msg)); } } - pub fn failure(&mut self) { - if let Some(ref mut spinner) = self.spinner { + pub fn failure(&mut self, out: &Out) { + if let Some(ref mut spinner) = self.anim { let sym = "✘".bold().red().to_string(); spinner.stop_and_persist(&sym, format!("{} Failed!", self.msg)); - } else if get_output_mode().is_mcp() { - send_to_current_sender(format!("{} Failed!", self.msg)); + } else if out.format == Format::Human { + out.write(&format!("{} Failed!\n", self.msg)); } } } -/// spinner starts and returns a Spinner object. This is useful for long running tasks where you -/// want to demonstrate there's something happening. -pub fn spinner(msg: &str) -> Spinner { - Spinner::new(msg.into()) +/// Reports a fatal CLI usage error (such as a missing required flag) and exits. +/// Used during argument parsing, before a command's `Out` is in play. +pub fn die_usage(msg: &str) -> ! { + let line = format!("{} {}\n", "Error:".red(), msg); + let mut stdout = io::stdout(); + let _ = stdout.write_all(line.as_bytes()); + let _ = stdout.flush(); + std::process::exit(1); +} + +/// Writes a diagnostic error to stderr. For background tasks that have no `Out` +/// handle to write through (e.g. a spawned run-completion monitor). +pub fn background_error(msg: &str) { + write_to_stderr(&format!("{} {}\n", "Oh no!".red(), msg)); +} + +/// Writes a labelled notice to stderr, keeping stdout clean for command output. +pub(crate) fn notice_to_stderr(label: &str, msg: &str) { + let line = format!("{} {}\n", label.bold().yellow(), msg); + write_to_stderr(&line); +} + +fn write_to_stderr(msg: &str) { + let mut stderr = io::stderr(); + if let Err(err) = stderr.write_all(msg.as_bytes()) { + if err.kind() == io::ErrorKind::BrokenPipe { + std::process::exit(0); + } + panic!("failed writing to stderr: {err}"); + } + stderr.flush().ok(); } pub fn write_update_available_message(latest: &str, current: &str) { @@ -608,23 +661,6 @@ pub fn write_dev_version_message(current: &str, latest: &str) { write_to_stderr(&line); } -/// newline just outputs a newline. This is useful when you have a very specific formatting you -/// want to maintain and you don't want to use println!. -pub fn newline() { - write("\n"); -} - -pub fn die(msg: &str) -> ! { - io::stdout().flush().ok(); - io::stderr().flush().ok(); - let line = format!("{} {}\n", "Error:".red(), msg); - write(&line); - // Flush output before exit to ensure "Error:" message is displayed - io::stdout().flush().ok(); - io::stderr().flush().ok(); - std::process::exit(1); -} - pub struct ProgressBar { inner: indicatif::ProgressBar, } diff --git a/crates/tower-cmd/src/package.rs b/crates/tower-cmd/src/package.rs index c325bd26..b01b3f13 100644 --- a/crates/tower-cmd/src/package.rs +++ b/crates/tower-cmd/src/package.rs @@ -3,7 +3,6 @@ use config::{Config, Towerfile}; use std::path::PathBuf; use tokio::fs; -use crate::output; use tower_package::{Package, PackageSpec}; use tower_telemetry::debug; @@ -28,7 +27,7 @@ pub fn package_cmd() -> Command { .about("Create a package tar.gz file from your app without deploying") } -pub async fn do_package(_config: Config, args: &ArgMatches) { +pub async fn do_package(out: &crate::output::Out, _config: Config, args: &ArgMatches) { // Determine the directory to build the package from let dir = PathBuf::from( args.get_one::("dir") @@ -41,11 +40,11 @@ pub async fn do_package(_config: Config, args: &ArgMatches) { match Towerfile::from_path(path) { Ok(towerfile) => { let spec = PackageSpec::from_towerfile(&towerfile); - let mut spinner = output::spinner("Building package..."); + let mut spinner = out.spinner("Building package..."); match Package::build(spec).await { Ok(package) => { - spinner.success(); + spinner.success(out); // Get the output path let output_path = args @@ -55,24 +54,21 @@ pub async fn do_package(_config: Config, args: &ArgMatches) { // Save the package match save_package(&package, output_path).await { Ok(_) => { - output::success(&format!( - "Package created successfully: {}", - output_path - )); + out.success(&format!("Package created successfully: {}", output_path)); } Err(err) => { - output::error(&format!("Failed to save package: {}", err)); + out.error(&format!("Failed to save package: {}", err)); } } } Err(err) => { - spinner.failure(); - output::package_error(err); + spinner.failure(out); + out.package_error(err); } } } Err(err) => { - output::package_error(err); + out.package_error(err); } } } diff --git a/crates/tower-cmd/src/run.rs b/crates/tower-cmd/src/run.rs index 9d909a7b..514df320 100644 --- a/crates/tower-cmd/src/run.rs +++ b/crates/tower-cmd/src/run.rs @@ -77,8 +77,8 @@ pub fn run_cmd() -> Command { .about("Run your code in Tower or locally") } -pub async fn do_run(config: Config, args: &ArgMatches) { - if let Err(e) = do_run_inner(config, args).await { +pub async fn do_run(out: &output::Out, config: Config, args: &ArgMatches) { + if let Err(e) = do_run_inner(out, config, args).await { match e { Error::ApiRunError { ref source } => { let is_not_found = matches!( @@ -86,7 +86,7 @@ pub async fn do_run(config: Config, args: &ArgMatches) { tower_api::apis::Error::ResponseError(resp) if resp.status == reqwest::StatusCode::NOT_FOUND ); if is_not_found { - output::error(concat!( + out.error(concat!( "App not found. It may not exist or hasn't been deployed yet.\n", "\n", "To fix this:\n", @@ -97,12 +97,12 @@ pub async fn do_run(config: Config, args: &ArgMatches) { std::process::exit(1); } if let Error::ApiRunError { source } = e { - output::tower_error_and_die(source, "Scheduling run failed"); + out.tower_error_and_die(source, "Scheduling run failed"); } unreachable!(); } _ => { - output::die(&e.to_string()); + out.die(&e.to_string()); } } } @@ -110,8 +110,12 @@ pub async fn do_run(config: Config, args: &ArgMatches) { /// do_run is the primary entrypoint into running apps both locally and remotely in Tower. It will /// use the configuration to determine the requested way of running a Tower app. -pub async fn do_run_inner(config: Config, args: &ArgMatches) -> Result<(), Error> { - let res = get_run_parameters(args); +pub async fn do_run_inner( + out: &output::Out, + config: Config, + args: &ArgMatches, +) -> Result<(), Error> { + let res = get_run_parameters(out, args); // We always expect there to be an environment due to the fact that there is a // default value. @@ -128,13 +132,13 @@ pub async fn do_run_inner(config: Config, args: &ArgMatches) -> Result<(), Error if local { // For the time being, we should report that we can't run an app by name locally. if app_name.is_some() { - output::die("Running apps by name locally is not supported yet."); + out.die("Running apps by name locally is not supported yet."); } else { - do_run_local(config, path, env, params).await + do_run_local(out.clone(), config, path, env, params).await } } else { let follow = should_follow_run(args); - do_run_remote(config, path, env, params, app_name, follow).await + do_run_remote(out.clone(), config, path, env, params, app_name, follow).await } } Err(err) => Err(err.into()), @@ -143,6 +147,7 @@ pub async fn do_run_inner(config: Config, args: &ArgMatches) -> Result<(), Error /// Core implementation for running an app locally with configurable output handling async fn do_run_local_impl( + out: &output::Out, config: Config, path: PathBuf, env: &str, @@ -155,25 +160,25 @@ where T: Send + 'static, { // Load all the secrets and catalogs from the server - let mut spinner = output::spinner("Setting up runtime environment..."); + let mut spinner = out.spinner("Setting up runtime environment..."); let secrets = match get_secrets(&config, &env).await { Ok(s) => s, Err(err) => { - spinner.failure(); - output::tower_error_and_die(err, "Fetching secrets failed"); + spinner.failure(out); + out.tower_error_and_die(err, "Fetching secrets failed"); } }; let catalogs = match get_catalogs(&config, &env).await { Ok(c) => c, Err(err) => { - spinner.failure(); - output::tower_error_and_die(err, "Fetching catalogs failed"); + spinner.failure(out); + out.tower_error_and_die(err, "Fetching catalogs failed"); } }; - spinner.success(); + spinner.success(out); // We prepare all the other misc environment variables that we need to inject let mut env_vars = HashMap::new(); @@ -198,8 +203,8 @@ where } // Build the package (creates tar.gz) - let package = build_package(&towerfile).await?; - output::success(&format!("Launching app `{}`", towerfile.app.name)); + let package = build_package(out, &towerfile).await?; + out.success(&format!("Launching app `{}`", towerfile.app.name)); // Open the tar.gz file as a stream let package_path = package @@ -232,7 +237,7 @@ where // Monitor app status concurrently let handle = Arc::new(Mutex::new(handle)); - let status_task = tokio::spawn(monitor_cli_status(Arc::clone(&handle))); + let status_task = tokio::spawn(monitor_cli_status(out.clone(), Arc::clone(&handle))); // Wait for app to complete or SIGTERM let status_result = tokio::select! { @@ -240,8 +245,8 @@ where debug!("Status task completed, result: {:?}", status); status.unwrap() }, - _ = tokio::signal::ctrl_c(), if !output::get_output_mode().is_mcp() => { - output::write("\nReceived Ctrl+C, stopping local run...\n"); + _ = tokio::signal::ctrl_c(), if out.foreground() => { + out.write("\nReceived Ctrl+C, stopping local run...\n"); handle.lock().await.terminate().await.ok(); return Ok(output_task.await.unwrap()); } @@ -250,13 +255,13 @@ where // And if we crashed, err out match status_result { - Status::Exited => output::success("Your local run exited cleanly."), + Status::Exited => out.success("Your local run exited cleanly."), Status::Crashed { code } => { - output::error(&format!("Your local run crashed with exit code: {}", code)); + out.error(&format!("Your local run crashed with exit code: {}", code)); return Err(Error::AppCrashed); } Status::Cancelled => { - output::error("Your local run was cancelled."); + out.error("Your local run was cancelled."); return Err(Error::AppCrashed); } Status::Failed(failure) => { @@ -268,7 +273,7 @@ where error_message, } => format!("{} ({})", error_message, error_code), }; - output::error(&format!( + out.error(&format!( "Your local run failed due to a platform error: {}", detail )); @@ -276,7 +281,7 @@ where } _ => { debug!("Unexpected status after monitoring: {:?}", status_result); - output::error("An unexpected error occurred while monitoring your local run status!"); + out.error("An unexpected error occurred while monitoring your local run status!"); return Err(Error::AppCrashed); } } @@ -334,21 +339,30 @@ fn build_cli_execution_spec( /// the package, and launch the app. The relevant package is cleaned up after execution is /// complete. pub async fn do_run_local( + out: output::Out, config: Config, path: PathBuf, env: &str, params: HashMap, ) -> Result<(), Error> { - do_run_local_impl(config, path, env, params, |receiver| async { - monitor_output(receiver).await; - () - }) + let task_out = out.clone(); + do_run_local_impl( + &out, + config, + path, + env, + params, + move |receiver| async move { + monitor_output(task_out, receiver).await; + }, + ) .await } /// do_run_remote is the entrypoint for running an app remotely. It uses the Towerfile in the /// supplied directory (locally or remotely) to sort out what application to run exactly. pub async fn do_run_remote( + out: output::Out, config: Config, path: PathBuf, env: &str, @@ -365,28 +379,30 @@ pub async fn do_run_remote( towerfile.app.name }; - let res = output::try_with_spinner( - "Scheduling run", - api::run_app(&config, &app_slug, env, params), - ) - .await - .map_err(|source| Error::ApiRunError { source })?; + let res = out + .try_with_spinner( + "Scheduling run", + api::run_app(&config, &app_slug, env, params), + ) + .await + .map_err(|source| Error::ApiRunError { source })?; let run = res.run; if should_follow_run { - do_follow_run(config, &run).await?; + do_follow_run(&out, config, &run).await?; } else { let line = format!( "Run #{} for app `{}` has been scheduled\n See more: {}", run.number, app_slug, run.dollar_link ); - output::success_with_data(&line, Some(&run)); + out.success_with_data(&line, Some(&run)); } Ok(()) } async fn stream_logs_until_complete( + out: &output::Out, mut log_stream: MpscReceiver, mut run_complete: OneshotReceiver, enable_ctrl_c: bool, @@ -396,49 +412,52 @@ async fn stream_logs_until_complete( tokio::select! { event = log_stream.recv() => match event { Some(api::LogStreamEvent::EventLog(log)) => { - output::remote_log_event(&log); + out.remote_log_event(&log); }, None => return Ok(None), _ => {}, }, res = &mut run_complete => { let completed_run = res?; - drain_remaining_logs(log_stream).await; + drain_remaining_logs(out, log_stream).await; return Ok(Some(completed_run)); }, _ = tokio::signal::ctrl_c(), if enable_ctrl_c => { - output::write("Received Ctrl+C, stopping log streaming...\n"); - output::write("Note: The run will continue in Tower cloud\n"); - output::write(&format!(" See more: {}\n", run_link)); + out.write("Received Ctrl+C, stopping log streaming...\n"); + out.write("Note: The run will continue in Tower cloud\n"); + out.write(&format!(" See more: {}\n", run_link)); return Ok(None); }, } } } -async fn drain_remaining_logs(mut log_stream: MpscReceiver) { +async fn drain_remaining_logs( + out: &output::Out, + mut log_stream: MpscReceiver, +) { let drain_duration = Duration::from_secs(5); let _ = timeout(drain_duration, async { while let Some(event) = log_stream.recv().await { if let api::LogStreamEvent::EventLog(log) = event { - output::remote_log_event(&log); + out.remote_log_event(&log); } } }) .await; } -async fn do_follow_run(config: Config, run: &Run) -> Result<(), Error> { - let enable_ctrl_c = !output::get_output_mode().is_mcp(); - let mut spinner = output::spinner("Waiting for run to start..."); +async fn do_follow_run(out: &output::Out, config: Config, run: &Run) -> Result<(), Error> { + let enable_ctrl_c = out.foreground(); + let mut spinner = out.spinner("Waiting for run to start..."); match wait_for_run_start(&config, &run).await { Err(err) => { - spinner.failure(); + spinner.failure(out); return Err(err); } Ok(()) => { - spinner.success(); - output::write("Run started, streaming logs...\n"); + spinner.success(out); + out.write("Run started, streaming logs...\n"); // We do this here, explicitly, to not double-monitor our API via the // `wait_for_run_start` function above. @@ -449,6 +468,7 @@ async fn do_follow_run(config: Config, run: &Run) -> Result<(), Error> { match api::stream_run_logs(&config, &run.app_name, run.number).await { Ok(log_stream) => { let completed_run = stream_logs_until_complete( + out, log_stream, run_complete, enable_ctrl_c, @@ -457,11 +477,11 @@ async fn do_follow_run(config: Config, run: &Run) -> Result<(), Error> { .await?; if let Some(run) = completed_run { - handle_run_completion(Ok(run))?; + handle_run_completion(out, Ok(run))?; } } Err(err) => { - output::error(&format!("Failed to stream run logs: {:?}", err)); + out.error(&format!("Failed to stream run logs: {:?}", err)); return Err(Error::LogStreamFailed); } } @@ -471,32 +491,35 @@ async fn do_follow_run(config: Config, run: &Run) -> Result<(), Error> { Ok(()) } -fn handle_run_completion(res: Result) -> Result<(), Error> { +fn handle_run_completion( + out: &output::Out, + res: Result, +) -> Result<(), Error> { match res { Ok(completed_run) => match completed_run.status { tower_api::models::run::Status::Errored => { - output::error(&format!( + out.error(&format!( "Run #{} for app '{}' had an error", completed_run.number, completed_run.app_name )); Err(Error::RunFailed) } tower_api::models::run::Status::Crashed => { - output::error(&format!( + out.error(&format!( "Run #{} for app '{}' crashed", completed_run.number, completed_run.app_name )); Err(Error::RunCrashed) } tower_api::models::run::Status::Cancelled => { - output::error(&format!( + out.error(&format!( "Run #{} for app '{}' was cancelled", completed_run.number, completed_run.app_name )); Err(Error::RunCancelled) } _ => { - output::success(&format!( + out.success(&format!( "Run #{} for app '{}' completed successfully", completed_run.number, completed_run.app_name )); @@ -504,7 +527,7 @@ fn handle_run_completion(res: Result) -> Result< } }, Err(err) => { - output::error(&format!("Failed to monitor run completion: {:?}", err)); + out.error(&format!("Failed to monitor run completion: {:?}", err)); Err(err.into()) } } @@ -513,11 +536,12 @@ fn handle_run_completion(res: Result) -> Result< /// Extracts the local/remote flag, Towerfile directory, parameters, and optional app name /// from the parsed CLI args. fn get_run_parameters( + out: &output::Out, args: &ArgMatches, ) -> Result<(bool, PathBuf, HashMap, Option), crate::Error> { let local = *args.get_one::("local").unwrap(); let path = resolve_path(args); - let params = parse_parameters(args); + let params = parse_parameters(out, args); let app_name = args.get_one::("app_name").cloned(); Ok((local, path, params, app_name)) @@ -530,7 +554,7 @@ fn should_follow_run(args: &ArgMatches) -> bool { /// Parses `--parameter` arguments into a HashMap of key-value pairs. /// Handles format like "--parameter key=value" -fn parse_parameters(args: &ArgMatches) -> HashMap { +fn parse_parameters(out: &output::Out, args: &ArgMatches) -> HashMap { let mut param_map = HashMap::new(); if let Some(parameters) = args.get_many::("parameters") { @@ -538,7 +562,7 @@ fn parse_parameters(args: &ArgMatches) -> HashMap { match param.split_once('=') { Some((key, value)) => { if key.is_empty() { - output::error(&format!( + out.error(&format!( "Invalid parameter format: '{}'. Key cannot be empty.", param )); @@ -547,7 +571,7 @@ fn parse_parameters(args: &ArgMatches) -> HashMap { param_map.insert(key.to_string(), value.to_string()); } None => { - output::error(&format!( + out.error(&format!( "Invalid parameter format: '{}'. Expected 'key=value'.", param )); @@ -664,16 +688,16 @@ fn load_towerfile(path: &PathBuf) -> Result { /// build_package manages the process of building a package in an interactive way for local app /// execution. If the pacakge fails to build for wahatever reason, the app will exit. -async fn build_package(towerfile: &Towerfile) -> Result { - let mut spinner = output::spinner("Building package..."); +async fn build_package(out: &output::Out, towerfile: &Towerfile) -> Result { + let mut spinner = out.spinner("Building package..."); let package_spec = PackageSpec::from_towerfile(towerfile); match Package::build(package_spec).await { Ok(package) => { - spinner.success(); + spinner.success(out); Ok(package) } Err(err) => { - spinner.failure(); + spinner.failure(out); debug!("Failed to build package: {}", err); Err(err.into()) } @@ -682,11 +706,11 @@ async fn build_package(towerfile: &Towerfile) -> Result { /// monitor_output is a helper function that will monitor the output of a given output channel and /// plops it down on stdout. -async fn monitor_output(mut output: OutputReceiver) { +async fn monitor_output(out: output::Out, mut output: OutputReceiver) { loop { if let Some(line) = output.recv().await { let ts = dates::format(line.time); - output::log_line(&ts, &line.line, output::LogLineType::Local); + out.log_line(&ts, &line.line, output::LogLineType::Local); } else { break; } @@ -696,6 +720,7 @@ async fn monitor_output(mut output: OutputReceiver) { /// monitor_local_status is a helper function that will monitor the status of a given app and waits for /// it to progress to a terminal state. async fn monitor_cli_status( + out: output::Out, handle: Arc>, ) -> Status { use tower_runtime::execution::ExecutionHandle as _; @@ -744,7 +769,7 @@ async fn monitor_cli_status( // If we get five errors in a row, we abandon monitoring. if err_count >= 5 { debug!("Failed to get handle status after 5 attempts, giving up"); - output::error("An error occured while monitoring your local run status!"); + out.error("An error occured while monitoring your local run status!"); return Status::Crashed { code: -1 }; } diff --git a/crates/tower-cmd/src/schedules.rs b/crates/tower-cmd/src/schedules.rs index 7acc75ce..493d39e8 100644 --- a/crates/tower-cmd/src/schedules.rs +++ b/crates/tower-cmd/src/schedules.rs @@ -3,7 +3,7 @@ use colored::Colorize; use config::Config; use std::collections::HashMap; -use crate::{api, output}; +use crate::api; use tower_api::models::schedule::Status; @@ -110,18 +110,19 @@ pub fn schedules_cmd() -> Command { ) } -pub async fn do_list(config: Config, args: &ArgMatches) { +pub async fn do_list(out: &crate::output::Out, config: Config, args: &ArgMatches) { let app = args.get_one::("app").map(|s| s.as_str()); let environment = args.get_one::("environment").map(|s| s.as_str()); - let schedules = output::with_spinner( - "Listing schedules", - api::list_schedules(&config, app, environment), - ) - .await; + let schedules = out + .with_spinner( + "Listing schedules", + api::list_schedules(&config, app, environment), + ) + .await; if schedules.is_empty() { - output::text("No schedules found.\n", &schedules); + out.text("No schedules found.\n", &schedules); return; } @@ -148,60 +149,64 @@ pub async fn do_list(config: Config, args: &ArgMatches) { }) .collect(); - output::table(headers, rows, Some(&schedules)); + out.table(headers, rows, Some(&schedules)); } -pub async fn do_create(config: Config, args: &ArgMatches) { +pub async fn do_create(out: &crate::output::Out, config: Config, args: &ArgMatches) { let app_name = args.get_one::("app").unwrap(); let environment = args.get_one::("environment").unwrap(); let cron = args.get_one::("cron").unwrap(); - let parameters = parse_parameters(args); + let parameters = parse_parameters(out, args); - let response = output::with_spinner( - "Creating schedule", - api::create_schedule(&config, app_name, environment, cron, parameters), - ) - .await; + let response = out + .with_spinner( + "Creating schedule", + api::create_schedule(&config, app_name, environment, cron, parameters), + ) + .await; - output::success(&format!( + out.success(&format!( "Schedule created with ID: {}", response.schedule.id )); } -pub async fn do_update(config: Config, args: &ArgMatches) { +pub async fn do_update(out: &crate::output::Out, config: Config, args: &ArgMatches) { let id_or_name = args .get_one::("id_or_name") .expect("id_or_name is required"); let cron = args.get_one::("cron"); - let parameters = parse_parameters(args); + let parameters = parse_parameters(out, args); - output::with_spinner( + out.with_spinner( "Updating schedule", api::update_schedule(&config, id_or_name, cron, parameters), ) .await; - output::success(&format!("Schedule {} updated", id_or_name)); + out.success(&format!("Schedule {} updated", id_or_name)); } -pub async fn do_delete(config: Config, args: &ArgMatches) { +pub async fn do_delete(out: &crate::output::Out, config: Config, args: &ArgMatches) { let schedule_id = args .get_one::("schedule_id") .expect("schedule_id is required"); - output::with_spinner( + out.with_spinner( "Deleting schedule", api::delete_schedule(&config, schedule_id), ) .await; - output::success(&format!("Schedule {} deleted", schedule_id)); + out.success(&format!("Schedule {} deleted", schedule_id)); } /// Parses `--parameter` arguments into a HashMap of key-value pairs. /// Handles format like "--parameter key=value" -fn parse_parameters(args: &ArgMatches) -> Option> { +fn parse_parameters( + out: &crate::output::Out, + args: &ArgMatches, +) -> Option> { let mut param_map = HashMap::new(); if let Some(parameters) = args.get_many::("parameters") { @@ -209,7 +214,7 @@ fn parse_parameters(args: &ArgMatches) -> Option> { match param.split_once('=') { Some((key, value)) => { if key.is_empty() { - output::error(&format!( + out.error(&format!( "Invalid parameter format: '{}'. Key cannot be empty.", param )); @@ -218,7 +223,7 @@ fn parse_parameters(args: &ArgMatches) -> Option> { param_map.insert(key.to_string(), value.to_string()); } None => { - output::error(&format!( + out.error(&format!( "Invalid parameter format: '{}'. Expected 'key=value'.", param )); @@ -239,6 +244,7 @@ fn parse_parameters(args: &ArgMatches) -> Option> { #[cfg(test)] mod tests { use super::{parse_parameters, schedules_cmd}; + use crate::output::Out; #[test] fn update_accepts_positional_schedule_id_and_flags() { @@ -349,7 +355,8 @@ mod tests { panic!("expected update subcommand"); }; - let params = parse_parameters(update_args).expect("expected parsed parameters"); + let params = + parse_parameters(&Out::sink(), update_args).expect("expected parsed parameters"); assert_eq!(params.get("env"), Some(&"prod".to_string())); assert_eq!(params.get("team"), Some(&"platform".to_string())); } @@ -373,7 +380,7 @@ mod tests { panic!("expected update subcommand"); }; - assert_eq!(parse_parameters(update_args), None); + assert_eq!(parse_parameters(&Out::sink(), update_args), None); } #[test] @@ -395,7 +402,8 @@ mod tests { panic!("expected update subcommand"); }; - let params = parse_parameters(update_args).expect("expected parsed parameters"); + let params = + parse_parameters(&Out::sink(), update_args).expect("expected parsed parameters"); assert_eq!(params.get("env"), Some(&"prod".to_string())); assert_eq!(params.len(), 1); } diff --git a/crates/tower-cmd/src/secrets.rs b/crates/tower-cmd/src/secrets.rs index 16c67009..25acafbb 100644 --- a/crates/tower-cmd/src/secrets.rs +++ b/crates/tower-cmd/src/secrets.rs @@ -7,7 +7,7 @@ use rsa::pkcs1::DecodeRsaPublicKey; use tower_api::models::CreateSecretResponse; use tower_telemetry::debug; -use crate::{api, output, util::cmd}; +use crate::{api, util::cmd}; pub fn secrets_cmd() -> Command { Command::new("secrets") @@ -93,7 +93,7 @@ pub fn secrets_cmd() -> Command { ) } -pub async fn do_list(config: Config, args: &ArgMatches) { +pub async fn do_list(out: &crate::output::Out, config: Config, args: &ArgMatches) { let all = cmd::get_bool_flag(args, "all"); let show = cmd::get_bool_flag(args, "show"); let env = cmd::get_string_flag(args, "environment"); @@ -106,11 +106,12 @@ pub async fn do_list(config: Config, args: &ArgMatches) { if show { let (private_key, public_key) = crypto::generate_key_pair(); - let list_response = output::with_spinner( - "Listing secrets", - api::export_secrets(&config, &env, all, public_key), - ) - .await; + let list_response = out + .with_spinner( + "Listing secrets", + api::export_secrets(&config, &env, all, public_key), + ) + .await; let headers = vec!["Secret", "Environment", "Value"] .into_iter() @@ -131,10 +132,11 @@ pub async fn do_list(config: Config, args: &ArgMatches) { ] }) .collect(); - output::table(headers, data, Some(&list_response.secrets)); + out.table(headers, data, Some(&list_response.secrets)); } else { - let secrets = - output::with_spinner("Listing secrets", api::list_secrets(&config, &env, all)).await; + let secrets = out + .with_spinner("Listing secrets", api::list_secrets(&config, &env, all)) + .await; let headers = vec!["Secret", "Environment", "Preview"] .into_iter() @@ -150,40 +152,40 @@ pub async fn do_list(config: Config, args: &ArgMatches) { ] }) .collect(); - output::table(headers, data, Some(&secrets)); + out.table(headers, data, Some(&secrets)); } } -pub async fn do_create(config: Config, args: &ArgMatches) { +pub async fn do_create(out: &crate::output::Out, config: Config, args: &ArgMatches) { let name = cmd::get_string_flag(args, "name"); let environment = cmd::get_string_flag(args, "environment"); let value = cmd::get_string_flag(args, "value"); - let mut spinner = output::spinner("Creating secret..."); + let mut spinner = out.spinner("Creating secret..."); - match encrypt_and_create_secret(&config, &name, &value, &environment).await { + match encrypt_and_create_secret(out, &config, &name, &value, &environment).await { Ok(_) => { - spinner.success(); + spinner.success(out); let line = format!("Secret {} created in environment {}", name, environment,); - output::success(&line); + out.success(&line); } Err(err) => { - spinner.failure(); + spinner.failure(out); match err { SecretCreationError::FetchKeyFailed(e) => { - output::tower_error_and_die(e, "Fetching secrets key failed"); + out.tower_error_and_die(e, "Fetching secrets key failed"); } SecretCreationError::CreateFailed(e) => { - output::tower_error_and_die(e, "Creating secret failed"); + out.tower_error_and_die(e, "Creating secret failed"); } } } } } -pub async fn do_delete(config: Config, args: &ArgMatches) { +pub async fn do_delete(out: &crate::output::Out, config: Config, args: &ArgMatches) { let secret_name_arg = args .get_one::("secret_name") .expect("secret_name is required"); @@ -197,7 +199,7 @@ pub async fn do_delete(config: Config, args: &ArgMatches) { }; debug!("deleting secret, environment={} name={}", environment, name); - output::with_spinner( + out.with_spinner( "Deleting secret", api::delete_secret(&config, &name, &environment), ) @@ -223,6 +225,7 @@ enum SecretCreationError { } async fn encrypt_and_create_secret( + out: &crate::output::Out, config: &Config, name: &str, value: &str, @@ -233,7 +236,7 @@ async fn encrypt_and_create_secret( .map_err(SecretCreationError::FetchKeyFailed)?; let public_key = rsa::RsaPublicKey::from_pkcs1_pem(&res.public_key).unwrap_or_else(|_| { - output::die("Failed to parse public key"); + out.die("Failed to parse public key"); }); let encrypted_value = encrypt(public_key, value.to_string()).unwrap(); diff --git a/crates/tower-cmd/src/session.rs b/crates/tower-cmd/src/session.rs index 32d88987..509b7bd1 100644 --- a/crates/tower-cmd/src/session.rs +++ b/crates/tower-cmd/src/session.rs @@ -20,11 +20,11 @@ pub fn login_cmd() -> Command { .about("Create a session with Tower") } -pub async fn do_login(config: Config, args: &ArgMatches) { - output::banner(); +pub async fn do_login(out: &crate::output::Out, config: Config, args: &ArgMatches) { + out.banner(); if std::env::var("TOWER_API_KEY").is_ok() { - output::write(&format!( + out.write(&format!( "{} TOWER_API_KEY is set. As long as this environment variable is present, \ the CLI will authenticate using the API key and ignore the session \ created by this login flow.\n", @@ -43,21 +43,22 @@ pub async fn do_login(config: Config, args: &ArgMatches) { // Open a browser by default, unless the --no-browser flag is set. let open_browser = !args.get_flag("no-browser"); - let mut spinner = output::spinner("Starting device login..."); + let mut spinner = out.spinner("Starting device login..."); match api::create_device_login_ticket(&config).await { Ok(resp) => { - spinner.success(); - handle_device_login(config, open_browser, resp).await; + spinner.success(out); + handle_device_login(out, config, open_browser, resp).await; } Err(err) => { - spinner.failure(); - output::error(&format!("Failed to create device login ticket: {}", err)); + spinner.failure(out); + out.error(&format!("Failed to create device login ticket: {}", err)); } } } async fn handle_device_login( + out: &crate::output::Out, config: Config, open_browser: bool, claim: CreateDeviceLoginTicketResponse, @@ -74,23 +75,24 @@ async fn handle_device_login( if open_browser { if let Err(err) = webbrowser::open(&claim.login_url) { debug!("failed to open web browser: {}", err); - output::write(&login_instructions); + out.write(&login_instructions); } else { debug!("opened browser to {}", claim.login_url); } } else { - output::write(&login_instructions); + out.write(&login_instructions); } - let mut spinner = output::spinner("Waiting for login..."); + let mut spinner = out.spinner("Waiting for login..."); - if !poll_for_login(&config, &claim, &mut spinner).await { - spinner.failure(); - output::error("Login request expired. Please try again."); + if !poll_for_login(out, &config, &claim, &mut spinner).await { + spinner.failure(out); + out.error("Login request expired. Please try again."); } } async fn poll_for_login( + out: &crate::output::Out, config: &Config, claim: &CreateDeviceLoginTicketResponse, spinner: &mut output::Spinner, @@ -104,17 +106,17 @@ async fn poll_for_login( while chrono::Utc::now() < expires_at { match api::describe_device_login_session(&config, &claim.device_code).await { Ok(resp) => { - finalize_session(config, &resp, spinner); + finalize_session(out, config, &resp, spinner); return true; } Err(err) => { if let Some(api_err) = extract_api_error(&err) { if api_err.status != 404 && !api_err.is_incomplete_device_login { - output::error(&format!("{}", api_err.content)); + out.error(&format!("{}", api_err.content)); return false; } } else { - output::error(&format!("An unexpected error happened! Error: {}", err)); + out.error(&format!("An unexpected error happened! Error: {}", err)); return false; } } @@ -127,6 +129,7 @@ async fn poll_for_login( } fn finalize_session( + out: &crate::output::Out, config: &Config, session_response: &tower_api::models::DescribeDeviceLoginSessionResponse, spinner: &mut output::Spinner, @@ -141,12 +144,12 @@ fn finalize_session( session.tower_url = url; if let Err(err) = session.save() { - spinner.failure(); - output::error(&format!("Failed to save session: {}", err)); + spinner.failure(out); + out.error(&format!("Failed to save session: {}", err)); } else { - spinner.success(); + spinner.success(out); let message = format!("Hello, {}!", session_response.session.user.email.clone()); - output::success(&message); + out.success(&message); } } diff --git a/crates/tower-cmd/src/teams.rs b/crates/tower-cmd/src/teams.rs index 7e180a70..d607d9ad 100644 --- a/crates/tower-cmd/src/teams.rs +++ b/crates/tower-cmd/src/teams.rs @@ -2,7 +2,7 @@ use clap::{value_parser, Arg, ArgMatches, Command}; use colored::*; use config::Config; -use crate::{api, output}; +use crate::api; pub fn teams_cmd() -> Command { Command::new("teams") @@ -23,53 +23,57 @@ pub fn teams_cmd() -> Command { } /// Refreshes the session with the Tower API and returns the updated session -async fn refresh_session(config: &Config) -> config::Session { +async fn refresh_session(out: &crate::output::Out, config: &Config) -> config::Session { // First get the current session let current_session = match config.get_current_session() { Ok(session) => session, Err(e) => { - output::config_error(e); + out.config_error(e); std::process::exit(1); } }; - let resp = output::with_spinner("Refreshing session", api::refresh_session(&config)).await; + let resp = out + .with_spinner("Refreshing session", api::refresh_session(&config)) + .await; // Create a mutable copy of the session to update let mut session = current_session; // Update it with the API response if let Err(e) = session.update_from_api_response(&resp) { - output::config_error(e); + out.config_error(e); std::process::exit(1); } session } -pub async fn do_list(config: Config) { +pub async fn do_list(out: &crate::output::Out, config: Config) { if config.api_key.is_some() { - do_list_via_api(&config).await; + do_list_via_api(out, &config).await; } else { - do_list_via_session(&config).await; + do_list_via_session(out, &config).await; } } -async fn do_list_via_api(config: &Config) { - let teams = output::with_spinner("Fetching teams", api::list_teams(config)).await; +async fn do_list_via_api(out: &crate::output::Out, config: &Config) { + let teams = out + .with_spinner("Fetching teams", api::list_teams(config)) + .await; let headers = vec!["Name".to_string()]; let teams_data: Vec> = teams.iter().map(|team| vec![team.name.clone()]).collect(); - output::newline(); - output::table(headers, teams_data, None::<&Vec>); - output::newline(); + out.newline(); + out.table(headers, teams_data, None::<&Vec>); + out.newline(); } -async fn do_list_via_session(config: &Config) { +async fn do_list_via_session(out: &crate::output::Out, config: &Config) { // Refresh the session and get the updated data - let session = refresh_session(config).await; + let session = refresh_session(out, config).await; // Get the current active team from the session let active_team = session.active_team.clone(); @@ -94,26 +98,26 @@ async fn do_list_via_session(config: &Config) { }) .collect(); - output::newline(); + out.newline(); // Display the table using the existing table function - output::table(headers, teams_data, Some(&teams)); - output::newline(); + out.table(headers, teams_data, Some(&teams)); + out.newline(); // Add a legend for the asterisk - output::note(&format!( + out.note(&format!( "{}\n", "* indicates currently active team".dimmed() )); - output::newline(); + out.newline(); } -pub async fn do_switch(config: Config, args: &ArgMatches) { +pub async fn do_switch(out: &crate::output::Out, config: Config, args: &ArgMatches) { let name = args .get_one::("team_name") .expect("team_name is required"); // Refresh the session first to ensure we have the latest teams data - let session = refresh_session(&config).await; + let session = refresh_session(out, &config).await; // Check if the provided team name exists in the refreshed session let team = session.teams.iter().find(|team| team.name == *name); @@ -123,17 +127,17 @@ pub async fn do_switch(config: Config, args: &ArgMatches) { // Team found, set it as active match config.set_active_team_by_name(name) { Ok(_) => { - output::success(&format!("Switched to team: {}", team.name)); + out.success(&format!("Switched to team: {}", team.name)); } Err(e) => { - output::config_error(e); + out.config_error(e); std::process::exit(1); } } } None => { // Team not found - output::error(&format!( + out.error(&format!( "Team '{}' not found. Use 'tower teams list' to see all your teams.", name, )); diff --git a/crates/tower-cmd/src/util/apps.rs b/crates/tower-cmd/src/util/apps.rs index 3b6cf49c..4cadce8a 100644 --- a/crates/tower-cmd/src/util/apps.rs +++ b/crates/tower-cmd/src/util/apps.rs @@ -7,13 +7,14 @@ use tower_api::apis::{ use tower_api::models::CreateAppParams as CreateAppParamsModel; pub async fn ensure_app_exists( + out: &output::Out, api_config: &Configuration, app_name: &str, description: Option<&str>, create_app: bool, ) -> Result<(), crate::Error> { // Try to describe the app first (with spinner) - let mut spinner = output::spinner("Checking app..."); + let mut spinner = out.spinner("Checking app..."); let describe_result = default_api::describe_app( api_config, DescribeAppParams { @@ -29,7 +30,7 @@ pub async fn ensure_app_exists( // If the app exists, return Ok (description is create-only). if describe_result.is_ok() { - spinner.success(); + spinner.success(out); return Ok(()); } @@ -48,7 +49,7 @@ pub async fn ensure_app_exists( // If it's not a 404 error, fail the spinner and return the error if !is_not_found { - spinner.failure(); + spinner.failure(out); return Err(crate::Error::ApiDescribeAppError { source: err }); } @@ -72,7 +73,7 @@ pub async fn ensure_app_exists( } // Try to create the app (with a new spinner) - let mut spinner = output::spinner("Creating app..."); + let mut spinner = out.spinner("Creating app..."); let create_result = default_api::create_app( api_config, CreateAppParams { @@ -91,12 +92,12 @@ pub async fn ensure_app_exists( match create_result { Ok(_) => { - spinner.success(); - output::success(&format!("Created app '{}'", app_name)); + spinner.success(out); + out.success(&format!("Created app '{}'", app_name)); Ok(()) } Err(create_err) => { - spinner.failure(); + spinner.failure(out); Err(crate::Error::ApiCreateAppError { source: create_err }) } } diff --git a/crates/tower-cmd/src/util/cmd.rs b/crates/tower-cmd/src/util/cmd.rs index 49148410..22ae71d5 100644 --- a/crates/tower-cmd/src/util/cmd.rs +++ b/crates/tower-cmd/src/util/cmd.rs @@ -4,7 +4,7 @@ use clap::ArgMatches; pub fn get_string_flag(args: &ArgMatches, name: &str) -> String { args.get_one::(name) .unwrap_or_else(|| { - output::die(&format!("{} is required", name)); + output::die_usage(&format!("{} is required", name)); }) .to_string() } @@ -12,7 +12,7 @@ pub fn get_string_flag(args: &ArgMatches, name: &str) -> String { pub fn get_bool_flag(args: &ArgMatches, name: &str) -> bool { args.get_one::(name) .unwrap_or_else(|| { - output::die(&format!("{} is required", name)); + output::die_usage(&format!("{} is required", name)); }) .to_owned() } diff --git a/crates/tower-cmd/src/util/deploy.rs b/crates/tower-cmd/src/util/deploy.rs index 9931a395..3ce5c5de 100644 --- a/crates/tower-cmd/src/util/deploy.rs +++ b/crates/tower-cmd/src/util/deploy.rs @@ -15,6 +15,7 @@ use tower_api::apis::ResponseContent; use tower_api::models::DeployAppResponse; pub async fn upload_file_with_progress( + out: &output::Out, api_config: &Configuration, endpoint_url: String, file_path: PathBuf, @@ -26,7 +27,7 @@ pub async fn upload_file_with_progress( Ok(hash) => hash, Err(e) => { debug!("Failed to compute package hash: {}", e); - output::die("Tower CLI failed to properly prepare your package for deployment. Check that you have permissions to read/write to your temporary directory, and if it keeps happening contact Tower support at https://tower.dev"); + out.die("Tower CLI failed to properly prepare your package for deployment. Check that you have permissions to read/write to your temporary directory, and if it keeps happening contact Tower support at https://tower.dev"); } }; @@ -39,7 +40,7 @@ pub async fn upload_file_with_progress( if file_size > tower_package::MAX_PACKAGE_SIZE { let size_mb = file_size as f64 / (1024.0 * 1024.0); let max_mb = tower_package::MAX_PACKAGE_SIZE as f64 / (1024.0 * 1024.0); - output::die(&format!( + out.die(&format!( "Your App is too big! ({:.2} MB) exceeds maximum allowed size ({:.0} MB). Please consider reducing app size by removing unnecessary files or import_paths in the Towerfile.", size_mb, max_mb )); @@ -110,6 +111,7 @@ pub async fn upload_file_with_progress( } pub async fn deploy_app_package( + out: &output::Out, api_config: &tower_api::apis::configuration::Configuration, app_name: &str, package: Package, @@ -131,7 +133,7 @@ pub async fn deploy_app_package( // Get the package file path let package_path = package.package_file_path.unwrap_or_else(|| { debug!("No package file path found"); - output::die("An error happened in Tower CLI that it couldn't recover from."); + out.die("An error happened in Tower CLI that it couldn't recover from."); }); // Create the URL for the API endpoint @@ -155,6 +157,7 @@ pub async fn deploy_app_package( // Upload the package let response = upload_file_with_progress( + out, api_config, url, package_path, @@ -167,7 +170,7 @@ pub async fn deploy_app_package( // Finish the progress bar let progress_bar = progress_bar.lock().unwrap(); progress_bar.finish(); - output::newline(); + out.newline(); Ok(response) } diff --git a/crates/tower-cmd/src/util/git.rs b/crates/tower-cmd/src/util/git.rs index d39181ee..7ae6955b 100644 --- a/crates/tower-cmd/src/util/git.rs +++ b/crates/tower-cmd/src/util/git.rs @@ -13,12 +13,18 @@ use tower_telemetry::debug; /// This is used to auto-populate the `X-Tower-Idempotency-Key` header on deploy. pub fn clean_head_sha(dir: &Path) -> Option { if !is_inside_work_tree(dir) { - debug!("{:?} is not inside a git worktree; skipping idempotency key", dir); + debug!( + "{:?} is not inside a git worktree; skipping idempotency key", + dir + ); return None; } if is_dirty(dir) { - debug!("git worktree at {:?} is dirty; omitting idempotency key", dir); + debug!( + "git worktree at {:?} is dirty; omitting idempotency key", + dir + ); return None; } @@ -43,8 +49,7 @@ fn is_inside_work_tree(dir: &Path) -> bool { None => return false, }; - output.status.success() - && String::from_utf8_lossy(&output.stdout).trim() == "true" + output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "true" } /// Returns `true` when there are staged, unstaged, or untracked changes in the diff --git a/crates/tower-cmd/src/version.rs b/crates/tower-cmd/src/version.rs index 92682dcb..b356931e 100644 --- a/crates/tower-cmd/src/version.rs +++ b/crates/tower-cmd/src/version.rs @@ -1,13 +1,13 @@ -use crate::output; +use crate::output::Out; use clap::Command; pub fn version_cmd() -> Command { Command::new("version").about("Print the current version of Tower") } -pub async fn do_version() { +pub async fn do_version(out: &Out) { let version = tower_version::current_version(); - output::text( + out.text( &format!("v{}\n", version), &serde_json::json!({ "version": version }), ); From 0377f82fbca0af8c085fc168442235a493ce1f15 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Thu, 16 Jul 2026 15:02:56 +0200 Subject: [PATCH 6/6] Add support for querying Iceberg from the CLI (#325) * chore: Add support for querying Iceberg from the CLI * chore: Combine stages together in spinners, and require queries be read only * chore: Add explicit write mode for queries, so they can't be done accidentally * chore: Address some feedback from CodeRabbit * refactor: harden catalog SQL generation against injection Escaping now lives in SqlLiteral/SqlIdent Display wrappers instead of free functions each call site must remember, the table listing binds the catalog name as a real prepared-statement parameter, and attach_statements takes the vended credential Mode so call sites no longer pass an invertible read_only bool. * fix: convert nested DuckDB values to JSON and tilde-pin duckdb List/Array/Struct/Map/Union/Enum columns now render as proper JSON arrays and objects in --json output instead of Rust debug strings. The duckdb requirement uses a tilde constraint per upstream guidance, since the crate's second version component encodes the bundled DuckDB engine and a caret requirement would let cargo update swap engines silently. --- Cargo.lock | 950 ++++++++++++++++++++++++++++++- Cargo.toml | 1 + crates/tower-cmd/Cargo.toml | 1 + crates/tower-cmd/src/catalogs.rs | 728 ++++++++++++++++++++++- crates/tower-cmd/src/lib.rs | 3 + 5 files changed, 1646 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0e39ade0..4025f0c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,7 +35,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -52,6 +52,31 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.3", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -132,6 +157,184 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "arrow" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378530e55cd479eda3c14eb345310799717e6f76d0c332041e8487022166b471" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ab212d2c1886e802f51c5212d78ebbcbb0bec980fff9dadc1eb8d45cd0b738" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd33d3e92f207444098c75b42de99d329562be0cf686b307b097cc52b4e999e" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6cd424c2693bcdbc150d843dc9d4d137dd2de4782ce6df491ad11a3a0416c0" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c5aefb56a2c02e9e2b30746241058b85f8983f0fcff2ba0c6d09006e1cded7f" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64", + "chrono", + "comfy-table", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c88210023a2bfee1896af366309a3028fc3bcbd6515fa29a7990ee1baa08ee0" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ord" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bffd8fd2579286a5d63bac898159873e5094a79009940bcb42bbfce4f19f1d0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab5994731204603c73ba69267616c50f80780774c6bb0476f1f830625115e0c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed" +dependencies = [ + "bitflags 2.9.1", +] + +[[package]] +name = "arrow-select" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cd065c54172ac787cf3f2f8d4107e0d3fdc26edba76fdf4f4cc170258942222" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29dd7cda3ab9692f43a2e4acc444d760cc17b12bb6d8232ddf64e9bab7c06b42" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax 0.8.5", +] + [[package]] name = "async-compression" version = "0.4.27" @@ -194,6 +397,15 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -293,6 +505,18 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -302,6 +526,30 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "bstr" version = "1.12.0" @@ -318,6 +566,28 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bytecount" version = "0.6.9" @@ -336,12 +606,20 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" dependencies = [ + "jobserver", + "libc", "shlex", ] @@ -363,6 +641,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.41" @@ -489,6 +778,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "comfy-table" +version = "7.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" +dependencies = [ + "crossterm 0.28.1", + "unicode-segmentation", + "unicode-width 0.2.1", +] + [[package]] name = "config" version = "0.3.69" @@ -529,6 +829,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -563,6 +883,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -606,6 +935,19 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.9.1", + "crossterm_winapi", + "parking_lot", + "rustix 0.38.44", + "winapi", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -633,6 +975,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto" version = "0.3.69" @@ -848,6 +1196,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -966,6 +1325,24 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" +[[package]] +name = "duckdb" +version = "1.10504.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9b997383221efd999a362448a0866c54b9a2cb7d4da8cd4903ead4df9f8eaa" +dependencies = [ + "arrow", + "cast", + "comfy-table", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libduckdb-sys", + "num-integer", + "rust_decimal", + "strum 0.27.2", +] + [[package]] name = "dyn-clone" version = "1.0.19" @@ -1027,6 +1404,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1063,6 +1452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -1072,6 +1462,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -1091,6 +1487,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.31" @@ -1225,8 +1627,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1237,10 +1641,24 @@ checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasi 0.14.2+wasi-0.2.4", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + [[package]] name = "ghash" version = "0.5.1" @@ -1304,17 +1722,50 @@ dependencies = [ "walkdir", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] [[package]] name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.4", +] [[package]] name = "heck" @@ -1657,7 +2108,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" dependencies = [ "bitflags 2.9.1", - "crossterm", + "crossterm 0.29.0", "dyn-clone", "fuzzy-matcher", "unicode-segmentation", @@ -1743,6 +2194,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -1785,12 +2246,86 @@ dependencies = [ "spin", ] -[[package]] -name = "libc" -version = "0.2.174" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" - +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "libduckdb-sys" +version = "1.10504.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e67523a40ee3da30411e00e243990b3760d91877202ab2f39c03ab34f8dbfc" +dependencies = [ + "cc", + "flate2", + "pkg-config", + "reqwest", + "serde", + "serde_json", + "tar", + "vcpkg", + "zip", +] + [[package]] name = "libm" version = "0.2.15" @@ -1808,6 +2343,15 @@ dependencies = [ "redox_syscall 0.5.15", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1850,9 +2394,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "maplit" @@ -2004,6 +2554,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -2021,6 +2581,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2267,6 +2836,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "polyval" version = "0.6.2" @@ -2274,7 +2849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2309,6 +2884,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.5", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -2327,6 +2911,26 @@ dependencies = [ "rustyline", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "pyo3" version = "0.28.0" @@ -2385,6 +2989,62 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.0", + "thiserror 2.0.12", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.12", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.0", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.40" @@ -2400,6 +3060,18 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "radix_trie" version = "0.2.1" @@ -2431,6 +3103,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2469,6 +3152,21 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "redox_syscall" version = "0.3.5" @@ -2568,6 +3266,15 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.12.22" @@ -2576,6 +3283,7 @@ checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -2589,6 +3297,7 @@ dependencies = [ "mime_guess", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "serde", @@ -2639,6 +3348,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rmcp" version = "0.5.0" @@ -2724,12 +3462,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "rustc-demangle" version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2785,6 +3546,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -3067,7 +3829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3126,6 +3888,18 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.10" @@ -3211,7 +3985,7 @@ checksum = "a0ef947f358b9c238923f764c72a4a9d42f2d637c46e059dbd319d6e7cfb4f82" dependencies = [ "lazy_static", "maplit", - "strum", + "strum 0.24.1", ] [[package]] @@ -3261,7 +4035,16 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" dependencies = [ - "strum_macros", + "strum_macros 0.24.3", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", ] [[package]] @@ -3277,6 +4060,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3358,6 +4153,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.45" @@ -3506,6 +4307,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -3516,6 +4326,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tmpdir" version = "1.0.0" @@ -3629,8 +4454,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -3642,6 +4467,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3651,9 +4485,30 @@ dependencies = [ "indexmap 2.10.0", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.12", +] + +[[package]] +name = "toml_edit" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ad0b7ae9cfeef5605163839cb9221f453399f15cfb5c10be9885fcf56611f9" +dependencies = [ + "indexmap 2.10.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.12", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -3716,6 +4571,7 @@ dependencies = [ "crypto", "ctrlc", "cucumber", + "duckdb", "futures", "futures-util", "http", @@ -3739,7 +4595,7 @@ dependencies = [ "tokio-test", "tokio-util", "toml", - "toml_edit", + "toml_edit 0.22.27", "tower-api", "tower-package", "tower-runtime", @@ -4076,6 +4932,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -4125,6 +4987,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", ] @@ -4631,6 +5494,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -4646,6 +5515,15 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "xattr" version = "1.5.1" @@ -4759,3 +5637,35 @@ dependencies = [ "quote", "syn 2.0.104", ] + +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.10.0", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index f89fb323..c17ee29f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ config = { path = "crates/config" } crypto = { path = "crates/crypto" } ctrlc = "3" dirs = "5" +duckdb = { version = "~1.10504.0", features = ["bundled"] } flate2 = "1" fs2 = "0.4" futures = "0.3" diff --git a/crates/tower-cmd/Cargo.toml b/crates/tower-cmd/Cargo.toml index dac7d31b..6d5bcf5e 100644 --- a/crates/tower-cmd/Cargo.toml +++ b/crates/tower-cmd/Cargo.toml @@ -11,6 +11,7 @@ clap = { workspace = true } cli-table = { workspace = true } colored = { workspace = true } config = { workspace = true } +duckdb = { workspace = true } crypto = { workspace = true } ctrlc = { workspace = true } futures-util = { workspace = true } diff --git a/crates/tower-cmd/src/catalogs.rs b/crates/tower-cmd/src/catalogs.rs index 4c1677fb..eba2a6e0 100644 --- a/crates/tower-cmd/src/catalogs.rs +++ b/crates/tower-cmd/src/catalogs.rs @@ -1,6 +1,7 @@ use clap::{value_parser, Arg, ArgAction, ArgMatches, Command}; use colored::Colorize; use config::Config; +use std::io::{IsTerminal, Read}; use tower_api::models::{ vend_catalog_credentials_body, CatalogCredentials, DescribeCatalogResponse, }; @@ -69,7 +70,7 @@ pub fn catalogs_cmd() -> Command { .help("Environment the catalog belongs to") .action(ArgAction::Set), ) - .about("Show the details of a catalog, including its property names"), + .about("Show the details of a catalog, including its properties and tables"), ) .subcommand( Command::new("credentials") @@ -116,6 +117,44 @@ pub fn catalogs_cmd() -> Command { .short_about("Vend short-lived catalog credentials for external tools"), ), ) + .subcommand( + Command::new("query") + .arg( + Arg::new("catalog_name") + .value_parser(value_parser!(String)) + .index(1) + .required(true) + .help("Name of the catalog to query"), + ) + .arg( + Arg::new("sql") + .short('s') + .long("sql") + .value_parser(value_parser!(String)) + .help("SQL statement to execute; read from stdin when omitted") + .action(ArgAction::Set), + ) + .arg( + Arg::new("environment") + .short('e') + .long("environment") + .default_value("default") + .value_parser(value_parser!(String)) + .help("Environment the catalog belongs to") + .action(ArgAction::Set), + ) + .arg( + Arg::new("write") + .short('w') + .long("write") + .help("Allow write statements by vending read-write credentials; queries are read-only by default") + .action(ArgAction::SetTrue), + ) + .about(beta::STORAGE.short_about("Run a SQL query against a catalog using DuckDB")) + .after_help( + "Reference tables as .., e.g.:\n tower catalogs query default --sql 'SELECT * FROM \"default\".my_namespace.my_table LIMIT 10'", + ), + ) } pub async fn do_list(out: &output::Out, config: Config, args: &ArgMatches) { @@ -197,15 +236,435 @@ pub async fn do_show(out: &output::Out, config: Config, args: &ArgMatches) { .expect("catalog_name is required"); let env = cmd::get_string_flag(args, "environment"); - match api::describe_catalog(&config, name, &env).await { - Ok(response) => { - if is_storage_catalog_type(Some(&response.catalog.r#type)) { - beta::notify_once(out, &beta::STORAGE); - } - let human = catalog_details_text(&response); - out.text(&human, &response); + let response = match api::describe_catalog(&config, name, &env).await { + Ok(response) => response, + Err(err) => out.tower_error_and_die(err, "Fetching catalog details failed"), + }; + + let is_storage = is_storage_catalog_type(Some(&response.catalog.r#type)); + if is_storage { + beta::notify_once(out, &beta::STORAGE); + } + + let tables = if is_storage { + Some(fetch_catalog_tables(out, &config, name, &env).await) + } else { + None + }; + + let mut human = catalog_details_text(&response); + human.push('\n'); + human.push_str(&header_line("Tables")); + match &tables { + None => { + human.push_str(&format!( + " Table listing is not supported for {} catalogs.\n", + response.catalog.r#type + )); + } + Some(Ok(result)) if result.rows.is_empty() => { + human.push_str(" No tables found.\n"); + } + Some(Ok(result)) => { + let headers = vec!["Schema".to_string(), "Table".to_string()]; + let data = result + .rows + .iter() + .map(|row| row.iter().map(json_value_to_cell).collect()) + .collect(); + human.push_str(&output::table_text(headers, data)); + } + Some(Err(err)) => { + human.push_str(&format!(" Unable to list tables: {}\n", err)); + } + } + + // `tables` is an array (possibly empty) on success and null otherwise; + // `tables_error` distinguishes a failed listing (message) from a catalog + // type that doesn't support listing (null). + let (json_tables, tables_error) = match tables { + None => (serde_json::Value::Null, None), + Some(Err(err)) => (serde_json::Value::Null, Some(err)), + Some(Ok(result)) => ( + serde_json::Value::Array( + result + .rows + .iter() + .map(|row| { + serde_json::json!({ + "schema": row.first().cloned().unwrap_or(serde_json::Value::Null), + "table": row.get(1).cloned().unwrap_or(serde_json::Value::Null), + }) + }) + .collect(), + ), + None, + ), + }; + let json_data = serde_json::json!({ + "catalog": response.catalog, + "tables": json_tables, + "tables_error": tables_error, + }); + out.text(&human, &json_data); +} + +/// Lists the tables in a catalog by attaching it in DuckDB. Unlike the query +/// path this never exits: `show` should still render catalog details when the +/// tables can't be fetched. +async fn fetch_catalog_tables( + out: &output::Out, + config: &Config, + name: &str, + env: &str, +) -> Result { + let mut spinner = out.spinner("Listing tables..."); + + let response = match api::vend_catalog_credentials( + config, + name, + env, + vend_catalog_credentials_body::Mode::Read, + ) + .await + { + Ok(response) => response, + Err(err) => { + spinner.failure(out); + return Err(err.to_string()); } + }; + + let token = response.credentials.oauth_token.clone(); + let setup = attach_statements( + name, + &response.credentials, + vend_catalog_credentials_body::Mode::Read, + ); + let db_name = name.to_string(); + + let result = tokio::task::spawn_blocking(move || { + run_duckdb_query( + &setup, + "SELECT \"schema\", name FROM (SHOW ALL TABLES) WHERE database = ? ORDER BY \"schema\", name", + duckdb::params![db_name], + ) + }) + .await; + match result { + Ok(Ok(query_result)) => { + spinner.success(out); + Ok(query_result) + } + Ok(Err(err)) => { + spinner.failure(out); + Err(redact_token(&err.to_string(), &token)) + } + Err(err) => { + spinner.failure(out); + Err(redact_token(&err.to_string(), &token)) + } + } +} + +/// DuckDB errors can echo the failing statement, and the setup batch contains +/// the vended OAuth token — scrub it before the message reaches any output. +fn redact_token(message: &str, token: &str) -> String { + if token.is_empty() { + return message.to_string(); + } + message.replace(token, "[REDACTED]") +} + +pub async fn do_query(out: &output::Out, config: Config, args: &ArgMatches) { + beta::notify_once(out, &beta::STORAGE); + + let name = args + .get_one::("catalog_name") + .expect("catalog_name is required"); + let env = cmd::get_string_flag(args, "environment"); + + let sql = match args.get_one::("sql") { + Some(sql) => sql.clone(), + None => read_sql_from_stdin(out), + }; + let sql = sql.trim().to_string(); + if sql.is_empty() { + out.die("No SQL statement provided. Pass one with --sql or pipe it via stdin."); + } + + let response = match api::describe_catalog(&config, name, &env).await { + Ok(response) => response, Err(err) => out.tower_error_and_die(err, "Fetching catalog details failed"), + }; + if !is_storage_catalog_type(Some(&response.catalog.r#type)) { + out.die(&format!( + "Querying is only supported for {} catalogs; '{}' has type '{}'.", + STORAGE_CATALOG_TYPE, name, response.catalog.r#type + )); + } + + let write = cmd::get_bool_flag(args, "write"); + let query_result = execute_catalog_query(out, &config, name, &env, sql, write).await; + output_query_result(out, &query_result); +} + +/// Vends credentials for the catalog, attaches it in an in-memory DuckDB, and +/// runs `sql` against it. Read-only unless `write` is set, in which case +/// read-write credentials are vended and the attach allows writes. Dies with a +/// user-facing error on failure. +async fn execute_catalog_query( + out: &output::Out, + config: &Config, + name: &str, + env: &str, + sql: String, + write: bool, +) -> QueryResult { + let mode = if write { + vend_catalog_credentials_body::Mode::ReadWrite + } else { + vend_catalog_credentials_body::Mode::Read + }; + + let mut spinner = out.spinner("Running query..."); + + let response = match api::vend_catalog_credentials(config, name, env, mode).await { + Ok(response) => response, + Err(err) => { + spinner.failure(out); + out.tower_error_and_die(err, "Running query failed"); + } + }; + + let token = response.credentials.oauth_token.clone(); + let setup = attach_statements(name, &response.credentials, mode); + let result = tokio::task::spawn_blocking(move || run_duckdb_query(&setup, &sql, [])).await; + + match result { + Ok(Ok(query_result)) => { + spinner.success(out); + query_result + } + Ok(Err(err)) => { + spinner.failure(out); + out.die(&format!( + "Query failed: {}", + redact_token(&err.to_string(), &token) + )); + } + Err(err) => { + spinner.failure(out); + out.die(&format!( + "Query execution panicked: {}", + redact_token(&err.to_string(), &token) + )); + } + } +} + +fn read_sql_from_stdin(out: &output::Out) -> String { + let mut stdin = std::io::stdin(); + if stdin.is_terminal() { + out.die("No SQL statement provided. Pass one with --sql or pipe it via stdin."); + } + let mut sql = String::new(); + if let Err(err) = stdin.read_to_string(&mut sql) { + out.die(&format!("Failed reading SQL from stdin: {}", err)); + } + sql +} + +struct QueryResult { + columns: Vec, + rows: Vec>, +} + +/// Statements that install the Iceberg support and attach the catalog under +/// its Tower name — mirrors `templates/duckdb.sql.tmpl`. The attach is +/// READ_ONLY unless read-write credentials were vended. No `USE`: DuckDB's +/// `USE` needs a `main` schema, which Iceberg catalogs don't have, so queries +/// must qualify tables as ..
. +fn attach_statements( + name: &str, + credentials: &CatalogCredentials, + mode: vend_catalog_credentials_body::Mode, +) -> Vec { + let read_only = match mode { + vend_catalog_credentials_body::Mode::Read => "READ_ONLY, ", + vend_catalog_credentials_body::Mode::ReadWrite => "", + }; + vec![ + "INSTALL httpfs".to_string(), + "LOAD httpfs".to_string(), + "INSTALL iceberg".to_string(), + "LOAD iceberg".to_string(), + "SET s3_region='eu-central-1'".to_string(), + format!( + "CREATE OR REPLACE SECRET tower_cat (TYPE iceberg, TOKEN {})", + SqlLiteral(&credentials.oauth_token), + ), + format!( + "ATTACH {warehouse} AS {name} (TYPE iceberg, {read_only}SECRET tower_cat, ENDPOINT {uri}, DEFAULT_REGION 'eu-central-1')", + warehouse = SqlLiteral(&credentials.warehouse), + name = SqlIdent(name), + uri = SqlLiteral(&credentials.catalog_uri), + ), + ] +} + +/// Runs `setup` statements one at a time, then `query` as a prepared statement +/// with `params` bound. Values that fit a bind position should go through +/// `params` rather than into the query text. +fn run_duckdb_query( + setup: &[String], + query: &str, + params: P, +) -> Result { + let conn = duckdb::Connection::open_in_memory()?; + for statement in setup { + conn.execute_batch(statement)?; + } + + let mut stmt = conn.prepare(query)?; + let mut columns: Vec = Vec::new(); + let mut rows = Vec::new(); + + { + let mut result_rows = stmt.query(params)?; + while let Some(row) = result_rows.next()? { + if columns.is_empty() { + columns = row.as_ref().column_names(); + } + let mut record = Vec::with_capacity(columns.len()); + for idx in 0..columns.len() { + let value: duckdb::types::Value = row.get(idx)?; + record.push(duckdb_value_to_json(value)); + } + rows.push(record); + } + } + + // A query with no result rows never populates columns above. + if columns.is_empty() { + columns = stmt.column_names(); + } + + Ok(QueryResult { columns, rows }) +} + +fn duckdb_value_to_json(value: duckdb::types::Value) -> serde_json::Value { + use duckdb::types::{TimeUnit, Value}; + use serde_json::json; + + match value { + Value::Null => serde_json::Value::Null, + Value::Boolean(v) => json!(v), + Value::TinyInt(v) => json!(v), + Value::SmallInt(v) => json!(v), + Value::Int(v) => json!(v), + Value::BigInt(v) => json!(v), + Value::HugeInt(v) => json!(v.to_string()), + Value::UTinyInt(v) => json!(v), + Value::USmallInt(v) => json!(v), + Value::UInt(v) => json!(v), + Value::UBigInt(v) => json!(v), + Value::Float(v) => json!(v), + Value::Double(v) => json!(v), + Value::Decimal(v) => json!(v.to_string()), + Value::Text(v) => json!(v), + Value::Timestamp(unit, v) => { + let micros = match unit { + TimeUnit::Second => v.checked_mul(1_000_000), + TimeUnit::Millisecond => v.checked_mul(1_000), + TimeUnit::Microsecond => Some(v), + TimeUnit::Nanosecond => Some(v / 1_000), + }; + match micros.and_then(chrono::DateTime::from_timestamp_micros) { + Some(ts) => json!(ts.naive_utc().to_string()), + None => json!(format!("{:?}", Value::Timestamp(unit, v))), + } + } + Value::Date32(days) => { + let date = chrono::DateTime::from_timestamp(i64::from(days) * 86_400, 0); + match date { + Some(d) => json!(d.date_naive().to_string()), + None => json!(format!("{:?}", Value::Date32(days))), + } + } + Value::Time64(unit, v) => { + let micros = match unit { + TimeUnit::Second => v.checked_mul(1_000_000), + TimeUnit::Millisecond => v.checked_mul(1_000), + TimeUnit::Microsecond => Some(v), + TimeUnit::Nanosecond => Some(v / 1_000), + }; + let time = micros.and_then(|m| { + chrono::NaiveTime::from_num_seconds_from_midnight_opt( + (m / 1_000_000) as u32, + ((m % 1_000_000) * 1_000) as u32, + ) + }); + match time { + Some(t) => json!(t.to_string()), + None => json!(format!("{:?}", Value::Time64(unit, v))), + } + } + Value::Enum(v) => json!(v), + Value::List(items) | Value::Array(items) => { + serde_json::Value::Array(items.into_iter().map(duckdb_value_to_json).collect()) + } + Value::Struct(fields) => serde_json::Value::Object( + fields + .iter() + .map(|(name, value)| (name.clone(), duckdb_value_to_json(value.clone()))) + .collect(), + ), + Value::Map(entries) => serde_json::Value::Object( + entries + .iter() + .map(|(key, value)| { + ( + json_value_to_cell(&duckdb_value_to_json(key.clone())), + duckdb_value_to_json(value.clone()), + ) + }) + .collect(), + ), + Value::Union(inner) => duckdb_value_to_json(*inner), + other => json!(format!("{:?}", other)), + } +} + +fn output_query_result(out: &output::Out, result: &QueryResult) { + let json_rows: Vec> = result + .rows + .iter() + .map(|row| { + result + .columns + .iter() + .cloned() + .zip(row.iter().cloned()) + .collect() + }) + .collect(); + + let data = result + .rows + .iter() + .map(|row| row.iter().map(json_value_to_cell).collect()) + .collect(); + + out.table(result.columns.clone(), data, Some(&json_rows)); + out.note(&format!("\n{} row(s)\n", result.rows.len())); +} + +fn json_value_to_cell(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => String::new(), + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), } } @@ -228,12 +687,24 @@ fn quote(value: &str) -> String { serde_json::to_string(value).expect("serializing a string should not fail") } -fn sql_string(value: &str) -> String { - format!("'{}'", value.replace('\'', "''")) +/// A value embedded in generated SQL as a single-quoted string literal. +/// Escaping lives in the `Display` impl, so a value can only appear in the +/// generated text in its escaped form. +struct SqlLiteral<'a>(&'a str); + +impl std::fmt::Display for SqlLiteral<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "'{}'", self.0.replace('\'', "''")) + } } -fn sql_ident(value: &str) -> String { - format!("\"{}\"", value.replace('"', "\"\"")) +/// A value embedded in generated SQL as a double-quoted identifier. +struct SqlIdent<'a>(&'a str); + +impl std::fmt::Display for SqlIdent<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "\"{}\"", self.0.replace('"', "\"\"")) + } } fn token_export_command(name: &str, environment: &str, mode: &str, tower_url: &str) -> String { @@ -369,7 +840,7 @@ fn snippets( "os.environ[\"TOWER_CATALOG_TOKEN\"]".to_string() }; let sql_token = if show_token { - sql_string(&credentials.oauth_token) + SqlLiteral(&credentials.oauth_token).to_string() } else { "'${TOWER_CATALOG_TOKEN}'".to_string() }; @@ -414,9 +885,15 @@ fn snippets( body: render( DUCKDB_TMPL, &[ - ("__TOWER_NAME__", sql_ident(name)), - ("__TOWER_URI__", sql_string(&credentials.catalog_uri)), - ("__TOWER_WAREHOUSE__", sql_string(&credentials.warehouse)), + ("__TOWER_NAME__", SqlIdent(name).to_string()), + ( + "__TOWER_URI__", + SqlLiteral(&credentials.catalog_uri).to_string(), + ), + ( + "__TOWER_WAREHOUSE__", + SqlLiteral(&credentials.warehouse).to_string(), + ), ("__TOWER_TOKEN__", sql_token.clone()), ], ), @@ -443,7 +920,8 @@ fn snippets( #[cfg(test)] mod tests { use super::{ - catalogs_cmd, is_storage_catalog_type, parse_mode, snippets, token_export_command, + attach_statements, catalogs_cmd, duckdb_value_to_json, is_storage_catalog_type, parse_mode, + run_duckdb_query, snippets, token_export_command, }; use tower_api::models::{vend_catalog_credentials_body, CatalogCredentials}; @@ -637,6 +1115,222 @@ mod tests { ); } + #[test] + fn redact_token_scrubs_secret_from_error_text() { + let msg = "Parser Error near 'CREATE SECRET tower_cat (TYPE iceberg, TOKEN 'sekret-123')'"; + assert_eq!( + super::redact_token(msg, "sekret-123"), + "Parser Error near 'CREATE SECRET tower_cat (TYPE iceberg, TOKEN '[REDACTED]')'" + ); + assert_eq!(super::redact_token(msg, ""), msg); + assert_eq!( + super::redact_token("no secret here", "sekret-123"), + "no secret here" + ); + } + + #[test] + fn show_all_tables_query_lists_tables() { + let setup = vec![ + "CREATE SCHEMA s; CREATE TABLE s.t1 (i INTEGER); CREATE TABLE s.t2 (i INTEGER);" + .to_string(), + ]; + let result = run_duckdb_query( + &setup, + "SELECT \"schema\", name FROM (SHOW ALL TABLES) WHERE database = ? ORDER BY \"schema\", name", + duckdb::params!["memory"], + ) + .expect("query should succeed"); + + assert_eq!(result.columns, vec!["schema", "name"]); + assert_eq!( + result.rows, + vec![ + vec![serde_json::json!("s"), serde_json::json!("t1")], + vec![serde_json::json!("s"), serde_json::json!("t2")], + ] + ); + } + + #[test] + fn query_requires_catalog_name() { + let result = catalogs_cmd().try_get_matches_from(["catalogs", "query"]); + assert!(result.is_err()); + } + + #[test] + fn query_accepts_sql_flag_and_environment() { + let matches = catalogs_cmd() + .try_get_matches_from([ + "catalogs", + "query", + "my-catalog", + "--sql", + "SELECT 1", + "-e", + "production", + ]) + .expect("query with --sql should parse"); + + let (_, query_args) = matches.subcommand().expect("expected query subcommand"); + + assert_eq!( + query_args.get_one::("catalog_name").unwrap(), + "my-catalog" + ); + assert_eq!(query_args.get_one::("sql").unwrap(), "SELECT 1"); + assert_eq!( + query_args.get_one::("environment").unwrap(), + "production" + ); + } + + #[test] + fn query_sql_flag_is_optional() { + let matches = catalogs_cmd() + .try_get_matches_from(["catalogs", "query", "my-catalog"]) + .expect("query without --sql should parse"); + + let (_, query_args) = matches.subcommand().expect("expected query subcommand"); + + assert!(query_args.get_one::("sql").is_none()); + assert_eq!( + query_args.get_one::("environment").unwrap(), + "default" + ); + } + + #[test] + fn attach_statements_escapes_values_and_uses_catalog() { + let credentials = CatalogCredentials::new( + "https://catalog.example.com".to_string(), + "2026-06-26T12:00:00Z".to_string(), + "read".to_string(), + "secret'token".to_string(), + "warehouse-id".to_string(), + ); + + let sql = attach_statements( + "my\"catalog", + &credentials, + vend_catalog_credentials_body::Mode::Read, + ) + .join("\n"); + + assert!(sql.contains("TOKEN 'secret''token'")); + assert!( + sql.contains("ATTACH 'warehouse-id' AS \"my\"\"catalog\" (TYPE iceberg, READ_ONLY,") + ); + assert!(sql.contains("ENDPOINT 'https://catalog.example.com'")); + assert!(!sql.contains("USE ")); + + let write_sql = attach_statements( + "my\"catalog", + &credentials, + vend_catalog_credentials_body::Mode::ReadWrite, + ) + .join("\n"); + assert!(!write_sql.contains("READ_ONLY")); + assert!(write_sql.contains( + "ATTACH 'warehouse-id' AS \"my\"\"catalog\" (TYPE iceberg, SECRET tower_cat," + )); + } + + #[test] + fn query_write_flag_defaults_to_false() { + let matches = catalogs_cmd() + .try_get_matches_from(["catalogs", "query", "my-catalog", "--sql", "SELECT 1"]) + .expect("query should parse"); + let (_, query_args) = matches.subcommand().expect("expected query subcommand"); + assert_eq!(query_args.get_one::("write").copied(), Some(false)); + + let matches = catalogs_cmd() + .try_get_matches_from([ + "catalogs", + "query", + "my-catalog", + "--sql", + "DELETE FROM t", + "--write", + ]) + .expect("query --write should parse"); + let (_, query_args) = matches.subcommand().expect("expected query subcommand"); + assert_eq!(query_args.get_one::("write").copied(), Some(true)); + } + + #[test] + fn duckdb_values_convert_to_json() { + use duckdb::types::{TimeUnit, Value}; + + assert_eq!(duckdb_value_to_json(Value::Null), serde_json::Value::Null); + assert_eq!( + duckdb_value_to_json(Value::BigInt(42)), + serde_json::json!(42) + ); + assert_eq!( + duckdb_value_to_json(Value::Text("hi".to_string())), + serde_json::json!("hi") + ); + assert_eq!( + duckdb_value_to_json(Value::Timestamp(TimeUnit::Microsecond, 0)), + serde_json::json!("1970-01-01 00:00:00") + ); + assert_eq!( + duckdb_value_to_json(Value::Date32(1)), + serde_json::json!("1970-01-02") + ); + } + + #[test] + fn run_duckdb_query_returns_columns_and_rows() { + let setup = vec![ + "CREATE TABLE t (id INTEGER, name VARCHAR); INSERT INTO t VALUES (1, 'a'), (2, NULL);" + .to_string(), + ]; + let result = run_duckdb_query(&setup, "SELECT id, name FROM t ORDER BY id", []) + .expect("query should succeed"); + + assert_eq!(result.columns, vec!["id", "name"]); + assert_eq!(result.rows.len(), 2); + assert_eq!( + result.rows[0], + vec![serde_json::json!(1), serde_json::json!("a")] + ); + assert_eq!( + result.rows[1], + vec![serde_json::json!(2), serde_json::Value::Null] + ); + } + + #[test] + fn nested_duckdb_values_convert_to_json_structures() { + let result = run_duckdb_query( + &[], + "SELECT [1, 2] AS l, {'a': 1, 'b': 'x'} AS s, MAP {'k': 2} AS m", + [], + ) + .expect("query should succeed"); + + assert_eq!(result.columns, vec!["l", "s", "m"]); + assert_eq!( + result.rows[0], + vec![ + serde_json::json!([1, 2]), + serde_json::json!({"a": 1, "b": "x"}), + serde_json::json!({"k": 2}), + ] + ); + } + + #[test] + fn run_duckdb_query_reports_columns_for_empty_results() { + let result = + run_duckdb_query(&[], "SELECT 1 AS x WHERE 1 = 0", []).expect("query should succeed"); + + assert_eq!(result.columns, vec!["x"]); + assert!(result.rows.is_empty()); + } + #[test] fn token_export_command_fetches_token_without_printing_it() { let credentials = CatalogCredentials::new( diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index 2fb916d1..a4d8b48f 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -151,6 +151,9 @@ impl App { Some(("credentials", args)) => { catalogs::do_credentials(&out, sessionized_config, args).await } + Some(("query", args)) => { + catalogs::do_query(&out, sessionized_config, args).await + } _ => { catalogs::catalogs_cmd().print_help().unwrap(); std::process::exit(2);