diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index bf9e6eb5..fa9006dc 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -42,7 +42,12 @@ jobs: - uses: actions/checkout@v6 with: 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 }} @@ -72,6 +77,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 @@ -92,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 }} @@ -117,6 +127,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 @@ -137,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 }} @@ -170,7 +185,12 @@ jobs: - uses: actions/checkout@v6 with: 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 }} @@ -204,16 +224,20 @@ jobs: - uses: actions/checkout@v6 with: 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 }} - 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 +261,24 @@ 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 + - 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 }} @@ -262,35 +287,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,11 +313,14 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - 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: @@ -337,40 +344,41 @@ 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 }} + - 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: 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 diff --git a/Cargo.lock b/Cargo.lock index 013fb4b7..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" @@ -501,6 +801,7 @@ dependencies = [ "serde", "serde_json", "snafu", + "tempfile", "testutils", "tokio", "tower-api", @@ -528,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" @@ -562,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" @@ -605,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" @@ -632,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" @@ -847,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" @@ -965,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" @@ -1026,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" @@ -1062,6 +1452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -1071,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" @@ -1090,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" @@ -1224,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]] @@ -1236,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" @@ -1303,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" @@ -1656,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", @@ -1742,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" @@ -1784,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" @@ -1807,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" @@ -1849,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" @@ -2003,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" @@ -2020,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" @@ -2266,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" @@ -2273,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", ] @@ -2308,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" @@ -2326,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" @@ -2384,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" @@ -2399,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" @@ -2430,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" @@ -2468,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" @@ -2567,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" @@ -2575,6 +3283,7 @@ checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -2588,6 +3297,7 @@ dependencies = [ "mime_guess", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "serde", @@ -2638,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" @@ -2723,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" @@ -2784,6 +3546,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -3066,7 +3829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3125,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" @@ -3210,7 +3985,7 @@ checksum = "a0ef947f358b9c238923f764c72a4a9d42f2d637c46e059dbd319d6e7cfb4f82" dependencies = [ "lazy_static", "maplit", - "strum", + "strum 0.24.1", ] [[package]] @@ -3260,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]] @@ -3276,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" @@ -3357,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" @@ -3505,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" @@ -3515,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" @@ -3628,8 +4454,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -3641,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" @@ -3650,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]] @@ -3715,6 +4571,7 @@ dependencies = [ "crypto", "ctrlc", "cucumber", + "duckdb", "futures", "futures-util", "http", @@ -3738,7 +4595,7 @@ dependencies = [ "tokio-test", "tokio-util", "toml", - "toml_edit", + "toml_edit 0.22.27", "tower-api", "tower-package", "tower-runtime", @@ -4075,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" @@ -4124,6 +4987,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", ] @@ -4630,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" @@ -4645,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" @@ -4758,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/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/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/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 new file mode 100644 index 00000000..09e746c4 --- /dev/null +++ b/crates/tower-cmd/src/beta.rs @@ -0,0 +1,100 @@ +use std::io::{self, IsTerminal}; + +use tower_telemetry::debug; + +use crate::output::{self, Out}; + +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(out: &Out, feature: &BetaFeature) { + let stderr_is_terminal = io::stderr().is_terminal(); + + if !should_notify(out.interactive(), out.foreground(), 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), + } +} + +/// 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}; + + #[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_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 e52a3f11..eba2a6e0 100644 --- a/crates/tower-cmd/src/catalogs.rs +++ b/crates/tower-cmd/src/catalogs.rs @@ -1,17 +1,22 @@ 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, }; -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 +46,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), ) @@ -65,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") @@ -107,11 +112,52 @@ 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"), + ), + ) + .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(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") { @@ -120,11 +166,16 @@ pub async fn do_list(config: Config, args: &ArgMatches) { args.get_one::("type").map(String::as_str) }; - let catalogs = output::with_spinner( - "Listing catalogs", - api::list_catalogs(&config, &env, all, catalog_type), - ) - .await; + if is_storage_catalog_type(catalog_type) { + beta::notify_once(out, &beta::STORAGE); + } + + let catalogs = out + .with_spinner( + "Listing catalogs", + api::list_catalogs(&config, &env, all, catalog_type), + ) + .await; let headers = vec!["Name", "Type", "Environment"] .into_iter() @@ -140,10 +191,12 @@ 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) { +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") .expect("catalog_name is required"); @@ -158,11 +211,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, @@ -173,24 +227,451 @@ 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"); let env = cmd::get_string_flag(args, "environment"); - match api::describe_catalog(&config, name, &env).await { - Ok(response) => { - let human = catalog_details_text(&response); - output::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)) } - Err(err) => output::tower_error_and_die(err, "Fetching catalog details failed"), } } +/// 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(), + } +} + +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, @@ -206,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 { @@ -347,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() }; @@ -392,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()), ], ), @@ -420,7 +919,10 @@ fn snippets( #[cfg(test)] mod tests { - use super::{catalogs_cmd, parse_mode, snippets, token_export_command}; + use super::{ + 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}; #[test] @@ -495,6 +997,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"]); @@ -575,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/deploy.rs b/crates/tower-cmd/src/deploy.rs index c5328f38..74c864d9 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, } -pub async fn do_deploy(config: Config, args: &ArgMatches) { +/// 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(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); let target = if args.get_flag("all") { DeployTarget::All @@ -71,35 +114,37 @@ 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(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, target: DeployTarget, + idempotency_key: Option, ) -> Result<(), crate::Error> { debug!("Building package from directory: {:?}", dir); @@ -115,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(), @@ -123,26 +169,36 @@ 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).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, target: DeployTarget, + idempotency_key: Option, ) -> Result<(), crate::Error> { let (environment, all_environments) = match &target { DeployTarget::All => (None, true), @@ -150,11 +206,13 @@ async fn do_deploy_package( }; let res = util::deploy::deploy_app_package( + out, &api_config, &towerfile.app.name, package, environment, all_environments, + idempotency_key.as_deref(), ) .await; @@ -171,16 +229,50 @@ 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()) { + out.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 +280,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 +379,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/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 9eb84280..a4d8b48f 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; @@ -82,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" @@ -119,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); @@ -141,10 +146,13 @@ 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 + } + Some(("query", args)) => { + catalogs::do_query(&out, sessionized_config, args).await } _ => { catalogs::catalogs_cmd().print_help().unwrap(); @@ -156,9 +164,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); @@ -169,12 +181,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(); @@ -185,25 +197,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); @@ -304,3 +326,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/mcp.rs b/crates/tower-cmd/src/mcp.rs index 6d406c22..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); + let streaming = Self::setup_output_capture(ctx, self.send_notifications); - crate::output::set_current_sender(streaming.sender.clone()); + // 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; - let result = operation().await; - - crate::output::clear_current_sender(); - drop(streaming.sender); streaming.task.await.ok(); let output = streaming @@ -812,7 +806,23 @@ 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); + + // 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, + deploy_target, + idempotency_key, + ) + .await + { Ok(_) => Self::text_success("Deploy completed successfully".to_string()), Err(e) => Self::error_result("Deploy failed", e), } @@ -829,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() { @@ -881,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 862e8839..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,417 +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, +} + +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_json(self) -> bool { - matches!(self, Self::Json) + /// 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) } - pub fn is_normal(self) -> bool { - matches!(self, Self::Normal) + /// 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) } -} - -static OUTPUT_MODE: OnceLock = OnceLock::new(); -static CURRENT_SENDER: Mutex>> = Mutex::new(None); -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 fn set_output_mode(mode: OutputMode) { - OUTPUT_MODE.set(mode).ok(); - if mode.is_mcp() { - control::set_override(false); + fn is_json(&self) -> bool { + self.format == Format::Json } -} - -pub fn get_output_mode() -> OutputMode { - OUTPUT_MODE.get().copied().unwrap_or(OutputMode::Normal) -} - -pub fn set_current_sender(sender: UnboundedSender) { - *CURRENT_SENDER.lock().unwrap() = Some(sender); -} -pub fn clear_current_sender() { - *CURRENT_SENDER.lock().unwrap() = None; -} + /// 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 + } -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(); - } + /// 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 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)); + /// 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 success(msg: &str) { - success_with_data(msg, None::); -} - -pub fn success_with_data(msg: &str, data: Option) { - let mut response = serde_json::json!({ - "result": "success", - "message": msg - }); + pub fn newline(&self) { + self.write("\n"); + } - if let Some(data) = data { - response["data"] = serde_json::to_value(data).unwrap(); + pub fn banner(&self) { + self.write(BANNER_TEXT); } - let line = format!("{} {}\n", "Success!".green(), msg); - text(&line, &response); -} + 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 enum LogLineType { - Remote, - Local, -} + pub fn success(&self, msg: &str) { + self.success_with_data(msg, None::); + } -fn format_timestamp(timestamp: &str, t: LogLineType) -> String { - let ts = timestamp.bold(); + pub fn success_with_data(&self, msg: &str, data: Option) { + let mut response = serde_json::json!({ + "result": "success", + "message": msg + }); - let sep = "|".bold(); + if let Some(data) = data { + response["data"] = serde_json::to_value(data).unwrap(); + } - match t { - LogLineType::Remote => format!("{} {}", ts.yellow(), sep.yellow()), - LogLineType::Local => format!("{} {}", ts.green(), sep.green()), + let line = format!("{} {}\n", "Success!".green(), msg); + self.text(&line, &response); } -} - -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) + /// 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); } - 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 log_line(&self, timestamp: &str, message: &str, t: LogLineType) { + let line = format!("{} {}\n", format_timestamp(timestamp, t), message); + self.write(&line); + } -pub fn header(text: &str) { - let line = format!("{}\n", text.bold().green()); - write(&line); -} + 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 detail(label: &str, value: &str) { - let line = format!("{} {}\n", format!("{}:", label).bold().green(), value); - write(&line); -} + 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 title(text: &str) -> String { - text.bold().green().to_string() -} + let line = format!("{} {}\n", "Package error:".red(), msg); + self.write(&line); + } -pub fn placeholder(text: &str) -> String { - text.white().dimmed().italic().to_string() -} + 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) + } + }; -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", "Config 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) + // 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); } - // 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())); - } - for error in errors { - let msg = format!( - " • {}", - error.message.as_deref().unwrap_or("Unknown error") - ); - write(&format!("{}\n", msg.red())); + 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); } } -} -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; + 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); + } + } else { + for item in items { + let line = format!(" * {}\n", item); + let line = line.replace("\n", "\n "); + let line = format!("{}\n", line); + self.write(&line); + } } - }; + } - match err.status { - StatusCode::CONFLICT => { - output_full_error_details(&error_model); - } - StatusCode::UNPROCESSABLE_ENTITY => { - output_full_error_details(&error_model); - } - StatusCode::INTERNAL_SERVER_ERROR => { - error("The Tower API encountered an internal error. Maybe try again later on."); + /// 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); } - StatusCode::NOT_FOUND => { - 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::UNAUTHORIZED => { - error( - "You aren't authorized to do that! Are you logged in? Run `tower login` to login.", - ); + } + + 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, } - _ => { - 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."); + } + + /// 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())) @@ -446,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) { @@ -590,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 cc19fcb9..3ce5c5de 100644 --- a/crates/tower-cmd/src/util/deploy.rs +++ b/crates/tower-cmd/src/util/deploy.rs @@ -15,17 +15,19 @@ 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, content_type: &str, + idempotency_key: Option<&str>, progress_cb: Box, ) -> Result> { let package_hash = match compute_sha256_file(&file_path).await { 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"); } }; @@ -38,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 )); @@ -57,6 +59,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. @@ -102,11 +111,13 @@ 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, environment: Option<&str>, all_environments: bool, + idempotency_key: Option<&str>, ) -> Result> { let progress_bar = Arc::new(Mutex::new(output::progress_bar("Deploying to Tower..."))); @@ -122,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 @@ -146,10 +157,12 @@ pub async fn deploy_app_package( // Upload the package let response = upload_file_with_progress( + out, api_config, url, package_path, "application/tar", + idempotency_key, progress_callback, ) .await?; @@ -157,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 new file mode 100644 index 00000000..7ae6955b --- /dev/null +++ b/crates/tower-cmd/src/util/git.rs @@ -0,0 +1,141 @@ +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/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 }), ); 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: