diff --git a/.github/workflows/cube-cli.yml b/.github/workflows/cube-cli.yml new file mode 100644 index 0000000000000..fe23a02fd80a1 --- /dev/null +++ b/.github/workflows/cube-cli.yml @@ -0,0 +1,78 @@ +name: Cube CLI CI + +on: + push: + branches: [master] + paths: + - 'rust/cube-cli/**' + - '.github/workflows/cube-cli.yml' + pull_request: + paths: + - 'rust/cube-cli/**' + - '.github/workflows/cube-cli.yml' + +concurrency: + group: cube-cli-ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-22.04 + defaults: + run: + working-directory: rust/cube-cli + steps: + - uses: actions/checkout@v4 + - name: Setup Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: rustfmt, clippy + cache-workspaces: rust/cube-cli + - name: Check formatting + run: cargo fmt --all --check + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + build-and-test: + name: Build & test ${{ matrix.target }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + # Same targets the release builds in publish.yml, so a broken + # cross-platform build is caught in the PR rather than at release. + # Linux — fully static single binaries (musl + rustls, no OpenSSL). + - os: ubuntu-22.04 + target: x86_64-unknown-linux-musl + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + # macOS — Intel and Apple Silicon. + - os: macos-15-intel + target: x86_64-apple-darwin + - os: macos-14 + target: aarch64-apple-darwin + # Windows. + - os: windows-2022 + target: x86_64-pc-windows-msvc + defaults: + run: + working-directory: rust/cube-cli + steps: + - uses: actions/checkout@v4 + - name: Install musl tools + if: endsWith(matrix.target, '-musl') + run: sudo apt-get update && sudo apt-get install -y musl-tools + - name: Setup Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: ${{ matrix.target }} + cache-workspaces: rust/cube-cli + - name: Test + run: cargo test --locked --target ${{ matrix.target }} + - name: Build (release) + run: cargo build --release --locked --target ${{ matrix.target }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 92d806deee2b3..81952ae98409d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -770,6 +770,65 @@ jobs: tag: ${{ github.ref }} overwrite: true + cube-cli: + name: Cube CLI ${{ matrix.target }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + # Linux — fully static single binaries (musl + rustls, no OpenSSL). + - os: ubuntu-22.04 + target: x86_64-unknown-linux-musl + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + # macOS — Intel and Apple Silicon. + - os: macos-15-intel + target: x86_64-apple-darwin + - os: macos-14 + target: aarch64-apple-darwin + # Windows. + - os: windows-2022 + target: x86_64-pc-windows-msvc + steps: + - uses: actions/checkout@v4 + - name: Install musl tools + if: endsWith(matrix.target, '-musl') + run: sudo apt-get update && sudo apt-get install -y musl-tools + - name: Setup Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: ${{ matrix.target }} + cache-workspaces: rust/cube-cli + - name: Set crate version to the release version + shell: bash + run: | + v="${GITHUB_REF_NAME#v}" + sed -i.bak "s/^version = .*/version = \"$v\"/" rust/cube-cli/Cargo.toml + rm -f rust/cube-cli/Cargo.toml.bak + - name: Build + working-directory: rust/cube-cli + run: cargo build --release --target ${{ matrix.target }} + - name: Create archive + shell: bash + run: | + set -euo pipefail + bindir="rust/cube-cli/target/${{ matrix.target }}/release" + bin=cube + [ "${{ runner.os }}" = "Windows" ] && bin=cube.exe + tar -C "$bindir" -czf "cube-${{ matrix.target }}.tar.gz" "$bin" + - name: Upload Binary to Release + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: cube-${{ matrix.target }}.tar.gz + asset_name: cube-${{ matrix.target }}.tar.gz + tag: ${{ github.ref }} + overwrite: true + trigger-test-suites: name: Trigger test suites run runs-on: ubuntu-24.04 diff --git a/rust/cube-cli/.gitignore b/rust/cube-cli/.gitignore new file mode 100644 index 0000000000000..ea8c4bf7f35f6 --- /dev/null +++ b/rust/cube-cli/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust/cube-cli/Cargo.lock b/rust/cube-cli/Cargo.lock new file mode 100644 index 0000000000000..ddb8a3e9cc3ad --- /dev/null +++ b/rust/cube-cli/Cargo.lock @@ -0,0 +1,1814 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +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", + "rand_core", +] + +[[package]] +name = "clap" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "cube-cli" +version = "1.7.2" +dependencies = [ + "anyhow", + "clap", + "clap_complete", + "etcetera", + "inquire", + "owo-colors", + "reqwest", + "serde", + "serde_json", + "tokio", + "toml", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[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", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.13.1", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +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 = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[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", + "thiserror", + "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", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "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", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[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", +] + +[[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", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "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 = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio 1.2.2", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/rust/cube-cli/Cargo.toml b/rust/cube-cli/Cargo.toml new file mode 100644 index 0000000000000..f0575b6a52253 --- /dev/null +++ b/rust/cube-cli/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "cube-cli" +# Kept in lockstep with the Cube monorepo version (lerna.json). The release +# workflow overrides this from the pushed `v*` tag at build time. +version = "1.7.2" +edition = "2021" +description = "Cube Cloud command line interface" +license = "Apache-2.0" +repository = "https://github.com/cube-js/cube" +homepage = "https://cube.dev" + +[[bin]] +name = "cube" +path = "src/main.rs" + +# Standalone workspace: this crate is released on its own cadence and +# intentionally does not join the rust/cube workspace. +[workspace] + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive", "env"] } +clap_complete = "4" +inquire = "0.7" +owo-colors = "4" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +toml = "0.8" +etcetera = "0.8" + +[profile.release] +lto = "thin" +strip = true +codegen-units = 1 diff --git a/rust/cube-cli/README.md b/rust/cube-cli/README.md new file mode 100644 index 0000000000000..8c601c48f4aa7 --- /dev/null +++ b/rust/cube-cli/README.md @@ -0,0 +1,136 @@ +# Cube CLI (`cube`) + +A fully native, single-binary command line interface for the Cube Cloud +public REST API, written in Rust. Structured after the +[Railway CLI](https://github.com/railwayapp/cli): one module per command +group under `src/commands/`, a shared HTTP client, a config module, and +plain clap-derive dispatch in `main.rs`. + +## Install / build + +```bash +cd rust/cube-cli +cargo build --release +# binary at target/release/cube +``` + +The binary is fully static-friendly: TLS is provided by rustls, so there is +no OpenSSL dependency and musl builds work out of the box. + +## Versioning & releases + +The CLI version tracks the Cube monorepo version (`lerna.json`); `Cargo.toml` +is kept in sync and the release build overrides it from the pushed tag, so +`cube --version` always matches the Cube release. + +The CLI is built and published by the **same release workflow as the rest of +Cube** (`.github/workflows/publish.yml`, on `v*.*.*` tags). Its `cube-cli` +job builds a single static binary per platform and attaches them to the same +GitHub release as the Cube version, via `svenstaro/upload-release-action`: + +| Platform | Target | Asset | +|---|---|---| +| Linux x86_64 | `x86_64-unknown-linux-musl` | `cube-x86_64-unknown-linux-musl.tar.gz` | +| Linux arm64 | `aarch64-unknown-linux-musl` | `cube-aarch64-unknown-linux-musl.tar.gz` | +| macOS Intel | `x86_64-apple-darwin` | `cube-x86_64-apple-darwin.tar.gz` | +| macOS Apple Silicon | `aarch64-apple-darwin` | `cube-aarch64-apple-darwin.tar.gz` | +| Windows x86_64 | `x86_64-pc-windows-msvc` | `cube-x86_64-pc-windows-msvc.tar.gz` | + +The Linux builds are fully static (musl + rustls); each archive contains just +the `cube` binary. + +Pull-request CI (`.github/workflows/cube-cli.yml`) runs fmt, clippy, tests, +and a release build on every change under `rust/cube-cli/`. + +## Authentication + +Credentials resolve in this order: + +1. `--token` / `--api-url` flags +2. `CUBE_API_KEY` / `CUBE_API_URL` environment variables (for CI) +3. The active context in the config file, written by `cube login` + +The config file lives at `~/.config/cube/config.toml` on Linux/macOS (XDG) +and `%APPDATA%\cube\config.toml` on Windows, created with `0600` +permissions. Multiple tenants are supported as named contexts. + +`cube login` uses the browser **device authorization flow** (OAuth 2.0 +device grant, RFC 8628), the same style as the Railway CLI: it prints a URL +and a short code, opens your browser, and waits while you approve. The +resulting access token (and refresh token) are saved to the active context. + +```bash +cube login --name staging # device flow: opens browser, waits for approval +cube login --api-key # non-interactive: use an API key instead +cube context list +cube context use staging +cube whoami +``` + +Access tokens are short-lived; the CLI **auto-refreshes** them. When a +request gets a `401` and the active context has a refresh token, the client +transparently exchanges it at `/auth/oauth2/refresh`, saves the new token +pair back to the config, and retries — so a saved login keeps working +without re-authenticating every hour. If the refresh token itself is dead +(e.g. revoked), the CLI falls back to a clear "session expired — run +`cube login`" message. Auto-refresh is disabled when an explicit `--token` +/ `CUBE_API_KEY` is supplied (that token stands on its own). + +The device-flow endpoints, CLI `client_id`, scope, and (if the client is +confidential) secret can be overridden without a rebuild via +`CUBE_OAUTH_CLIENT_ID`, `CUBE_OAUTH_CLIENT_SECRET`, and `CUBE_OAUTH_SCOPE`. +For CI, skip login entirely and pass `CUBE_API_KEY` / `CUBE_API_URL`. + +## Commands + +Every endpoint of the Console Server public API is covered: + +| Group | Endpoints | +|---|---| +| `deployments` | list, get, create (`--bootstrap` scaffolds + builds a serving deployment), update, delete, token | +| `regions` | list available deployment regions | +| `data-model` (`dm`) | list, get, put, delete, rename files; branches, create-branch, dev-mode, exit-dev-mode, commit, pull | +| `environments` | list, tokens, create-token (incl. `--meta-sync`) | +| `variables` | list, set (`KEY=VALUE` upserts) | +| `folders` | list, create, update, delete, ancestors | +| `workbooks` | list, get, create, update, delete, duplicate, publish, dashboard, ai-thread | +| `reports` | list, get, create, update, delete, refresh, connect-workbook, folders | +| `workspace` | list, shared, move | +| `notifications` | list, get, create, update, delete, recipients list/add/remove | +| `users` | list, me, create, update, delete, embed-theme | +| `groups` | list, delete | +| `attributes` | list, create, update, delete, values get/set | +| `policies` | get, set-user, set-group | +| `tenant` | settings, update | +| `embed` | generate-session, token, dashboard, tenant delete/groups/delete-group | +| `integrations` | list, get, create, update, delete, tokens list/get/revoke/initiate | +| `oidc` | list, get, create, update, delete | +| `agents` | list, skills | +| `ai-engineer` | settings | +| `app` | config, theme | +| `meta` | POST /api/v1/meta/ | +| `scim` | Users/Groups CRUD + patch, resource-types, schemas, service-provider-config | +| `api` | raw escape hatch: `cube api GET /api/v1/... -q key=value -d '{...}'` | + +Conventions: + +- List commands render tables by default; `--json` prints the raw response. + Get/create/update commands always print JSON. +- Complex request bodies are passed with `-d/--data`, accepting inline JSON, + `@file.json`, or `-` for stdin (same convention as `gh api`). +- Common fields also have dedicated flags (e.g. + `cube reports create 1 --name x --json-query '...'`), which override + values in `--data`. +- `cube completion ` generates bash/zsh/fish/powershell completions. + +## Development + +```bash +cargo build +cargo test +cargo clippy +``` + +This crate is a standalone workspace, intentionally not a member of +`rust/cube`, so it can be released on its own cadence and built with plain +stable Rust. diff --git a/rust/cube-cli/src/client.rs b/rust/cube-cli/src/client.rs new file mode 100644 index 0000000000000..18590e715fdad --- /dev/null +++ b/rust/cube-cli/src/client.rs @@ -0,0 +1,220 @@ +use std::sync::Mutex; + +use anyhow::{anyhow, bail, Result}; +use reqwest::{Method, StatusCode}; +use serde_json::Value; + +use crate::oauth; + +/// Thin HTTP client over the Cube Cloud public REST API. +/// +/// All endpoints take/return JSON; commands work with `serde_json::Value` +/// so the CLI stays forward-compatible with server-side schema additions. +/// +/// When constructed with a refresh token (`with_refresh`), the client +/// transparently refreshes an expired access token on a `401` and retries +/// the request once, persisting the new token pair back to the config. +pub struct Client { + http: reqwest::Client, + base_url: String, + token: Mutex, + refresh: Option, +} + +/// State needed to refresh the access token and persist the result. +struct RefreshAuth { + refresh_token: Mutex, + /// Config context to write refreshed tokens back to. `None` disables + /// persistence (e.g. env/flag credentials). + context_name: Option, +} + +pub type Query = Vec<(String, String)>; + +impl Client { + fn build(base_url: &str, token: &str, refresh: Option) -> Result { + let base_url = base_url.trim_end_matches('/').to_string(); + if base_url.is_empty() { + bail!("API URL is empty"); + } + Ok(Self { + http: reqwest::Client::builder() + .user_agent(concat!("cube-cli/", env!("CARGO_PKG_VERSION"))) + .build()?, + base_url, + token: Mutex::new(token.to_string()), + refresh, + }) + } + + pub fn new(base_url: &str, token: &str) -> Result { + Self::build(base_url, token, None) + } + + /// Construct a client that can auto-refresh its access token. When + /// `context_name` is set, refreshed tokens are written back to that + /// config context. + pub fn with_refresh( + base_url: &str, + token: &str, + refresh_token: &str, + context_name: Option, + ) -> Result { + Self::build( + base_url, + token, + Some(RefreshAuth { + refresh_token: Mutex::new(refresh_token.to_string()), + context_name, + }), + ) + } + + fn token(&self) -> String { + self.token.lock().unwrap().clone() + } + + /// Send a single attempt, returning the status and body text. + async fn send_once( + &self, + method: &Method, + path: &str, + query: &Query, + body: Option<&Value>, + ) -> Result<(StatusCode, String)> { + let url = format!("{}{}", self.base_url, path); + let mut req = self + .http + .request(method.clone(), &url) + .bearer_auth(self.token()); + if !query.is_empty() { + req = req.query(query); + } + if let Some(body) = body { + req = req.json(body); + } else if matches!( + *method, + Method::POST | Method::PUT | Method::PATCH | Method::DELETE + ) { + // Bodyless writes still need an explicit Content-Length: 0, otherwise + // some frontends (e.g. Google GFE) reject them with 411 Length + // Required. reqwest omits the header for an empty body, so set it. + req = req + .header(reqwest::header::CONTENT_LENGTH, "0") + .body(Vec::::new()); + } + let res = req + .send() + .await + .map_err(|e| anyhow!("request to {url} failed: {e}"))?; + let status = res.status(); + let text = res.text().await.unwrap_or_default(); + Ok((status, text)) + } + + pub async fn request( + &self, + method: Method, + path: &str, + query: &Query, + body: Option<&Value>, + ) -> Result { + let (mut status, mut text) = self.send_once(&method, path, query, body).await?; + + // On 401, try a one-shot token refresh and retry. + if status == StatusCode::UNAUTHORIZED && self.try_refresh().await? { + let (s, t) = self.send_once(&method, path, query, body).await?; + status = s; + text = t; + } + + if !status.is_success() { + let detail = serde_json::from_str::(&text) + .ok() + .and_then(|v| { + v.get("message") + .or_else(|| v.get("error")) + .map(|m| m.to_string()) + }) + .unwrap_or_else(|| text.clone()); + match status { + StatusCode::UNAUTHORIZED => bail!( + "unauthorized (401): session expired — run `cube login` (or set CUBE_API_KEY). {detail}" + ), + StatusCode::FORBIDDEN => bail!("forbidden (403): {detail}"), + StatusCode::NOT_FOUND => bail!("not found (404): {method} {path}. {detail}"), + _ => bail!("{method} {path} failed with {status}: {detail}"), + } + } + + if text.trim().is_empty() { + return Ok(Value::Null); + } + Ok(serde_json::from_str(&text).unwrap_or(Value::String(text))) + } + + /// Attempt to refresh the access token. Returns `true` if a new token was + /// obtained (and the caller should retry), `false` if refresh isn't + /// possible. Only surfaces an error for unexpected failures. + async fn try_refresh(&self) -> Result { + let Some(refresh) = &self.refresh else { + return Ok(false); + }; + let refresh_token = refresh.refresh_token.lock().unwrap().clone(); + let cfg = oauth::OAuthConfig::from_env(); + match oauth::refresh(&self.http, &self.base_url, &cfg, &refresh_token).await { + Ok(tokens) => { + *self.token.lock().unwrap() = tokens.access_token.clone(); + let new_refresh = tokens.refresh_token.clone(); + if let Some(rt) = &new_refresh { + *refresh.refresh_token.lock().unwrap() = rt.clone(); + } + if let Some(name) = &refresh.context_name { + persist(name, &tokens.access_token, new_refresh.as_deref()); + } + Ok(true) + } + // Fall through to the 401 message, but surface the underlying + // reason so a transient/endpoint failure isn't mistaken for an + // expired refresh token ("session expired"). + Err(e) => { + eprintln!("warning: token refresh failed: {e:#}"); + Ok(false) + } + } + } + + pub async fn get(&self, path: &str, query: &Query) -> Result { + self.request(Method::GET, path, query, None).await + } + + pub async fn post(&self, path: &str, body: Option<&Value>) -> Result { + self.request(Method::POST, path, &Vec::new(), body).await + } + + pub async fn put(&self, path: &str, body: Option<&Value>) -> Result { + self.request(Method::PUT, path, &Vec::new(), body).await + } + + pub async fn patch(&self, path: &str, body: Option<&Value>) -> Result { + self.request(Method::PATCH, path, &Vec::new(), body).await + } + + pub async fn delete(&self, path: &str, body: Option<&Value>) -> Result { + self.request(Method::DELETE, path, &Vec::new(), body).await + } +} + +/// Write refreshed tokens back to the named config context (best effort). +fn persist(context_name: &str, access_token: &str, refresh_token: Option<&str>) { + let Ok(mut config) = crate::config::Config::load() else { + return; + }; + if let Some(ctx) = config.contexts.get_mut(context_name) { + ctx.api_key = access_token.to_string(); + if let Some(rt) = refresh_token { + ctx.refresh_token = Some(rt.to_string()); + } + let _ = config.save(); + } +} diff --git a/rust/cube-cli/src/commands/agents.rs b/rust/cube-cli/src/commands/agents.rs new file mode 100644 index 0000000000000..3b71bb14e8bda --- /dev/null +++ b/rust/cube-cli/src/commands/agents.rs @@ -0,0 +1,85 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List agents of a deployment + #[command(alias = "ls")] + List { + /// Deployment id + deployment: i64, + /// Allow embedding + #[arg(long)] + allow_embedding: Option, + }, + /// List agent skills of a deployment + Skills { + /// Deployment id + deployment: i64, + /// Space + #[arg(long)] + space: Option, + /// Branch name (defaults to the deployment default branch) + #[arg(long)] + branch: Option, + }, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List { + deployment, + allow_embedding, + } => { + let mut query = Vec::new(); + util::push(&mut query, "allowEmbedding", &allow_embedding); + let res = api + .get(&format!("/api/v1/deployments/{deployment}/agents"), &query) + .await?; + output::print_list( + ctx.json, + &res, + &[ + ("ID", "id"), + ("NAME", "name"), + ("CONFIG", "agentConfigName"), + ("SPACE", "agentSpace.name"), + ], + ); + } + Cmd::Skills { + deployment, + space, + branch, + } => { + let mut query = Vec::new(); + util::push(&mut query, "space", &space); + util::push(&mut query, "branchName", &branch); + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/agent-skills"), + &query, + ) + .await?; + output::print_list( + ctx.json, + &res, + &[ + ("NAME", "name"), + ("TITLE", "title"), + ("DESCRIPTION", "description"), + ], + ); + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/ai_engineer.rs b/rust/cube-cli/src/commands/ai_engineer.rs new file mode 100644 index 0000000000000..89799000565fd --- /dev/null +++ b/rust/cube-cli/src/commands/ai_engineer.rs @@ -0,0 +1,45 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Show AI Engineer settings + Settings { + /// Deployment id + #[arg(long)] + deployment: Option, + /// Agent + #[arg(long)] + agent: Option, + /// Security context as a JSON string + #[arg(long)] + security_context: Option, + }, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::Settings { + deployment, + agent, + security_context, + } => { + let mut query = Vec::new(); + util::push(&mut query, "deploymentId", &deployment); + util::push(&mut query, "agentId", &agent); + util::push(&mut query, "securityContext", &security_context); + let res = api.get("/api/v1/ai-engineer/settings", &query).await?; + output::print_json(&res); + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/api.rs b/rust/cube-cli/src/commands/api.rs new file mode 100644 index 0000000000000..0ad52b418402a --- /dev/null +++ b/rust/cube-cli/src/commands/api.rs @@ -0,0 +1,34 @@ +use anyhow::Result; +use reqwest::Method; + +use crate::{output, util, Ctx}; + +/// Raw authenticated request against the public API — the escape hatch for +/// endpoints without a dedicated command (mirrors `gh api`). +#[derive(clap::Args)] +pub struct Args { + /// HTTP method: GET, POST, PUT, PATCH, DELETE + method: String, + /// Request path, e.g. /api/v1/deployments/ + path: String, + /// JSON request body (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: Option, + /// Query parameters as key=value (repeatable) + #[arg(long = "query", short = 'q', value_parser = util::parse_kv)] + query: Vec<(String, String)>, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let method: Method = args.method.to_uppercase().parse()?; + let body = match args.data.as_deref() { + Some(data) => Some(util::body(util::parse_data(Some(data))?)), + None => None, + }; + let res = ctx + .api()? + .request(method, &args.path, &args.query, body.as_ref()) + .await?; + output::print_json(&res); + Ok(()) +} diff --git a/rust/cube-cli/src/commands/app.rs b/rust/cube-cli/src/commands/app.rs new file mode 100644 index 0000000000000..e54cf8aa15659 --- /dev/null +++ b/rust/cube-cli/src/commands/app.rs @@ -0,0 +1,28 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::{output, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Show app-level configuration (theme, creator mode, embedding) + Config, + /// Show the app theme + Theme, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + let res = match args.cmd { + Cmd::Config => api.get("/api/v1/app-config", &Vec::new()).await?, + Cmd::Theme => api.get("/api/v1/app-theme", &Vec::new()).await?, + }; + output::print_json(&res); + Ok(()) +} diff --git a/rust/cube-cli/src/commands/attributes.rs b/rust/cube-cli/src/commands/attributes.rs new file mode 100644 index 0000000000000..74a88b28347fb --- /dev/null +++ b/rust/cube-cli/src/commands/attributes.rs @@ -0,0 +1,184 @@ +use anyhow::Result; +use clap::Subcommand; +use serde_json::json; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List user attribute definitions + #[command(alias = "ls")] + List { + /// Pagination offset + #[arg(long)] + offset: Option, + /// Maximum number of items to return + #[arg(long)] + limit: Option, + /// Name + #[arg(long)] + name: Option, + /// string, number, boolean, string_array, number_array + #[arg(long = "type")] + attr_type: Option, + }, + /// Create a user attribute definition + Create { + /// Name + #[arg(long)] + name: String, + /// string, number, boolean, string_array, number_array + #[arg(long = "type")] + attr_type: String, + /// Display name + #[arg(long)] + display_name: Option, + /// Description + #[arg(long)] + description: Option, + /// Default value + #[arg(long)] + default_value: Option, + }, + /// Update a user attribute definition + Update { + /// User attribute id + attribute: i64, + /// Display name + #[arg(long)] + display_name: Option, + /// Description + #[arg(long)] + description: Option, + /// Default value + #[arg(long)] + default_value: Option, + }, + /// Delete a user attribute definition + #[command(alias = "rm")] + Delete { + /// User attribute id + attribute: i64, + }, + /// Get or set attribute values for users + Values { + #[command(subcommand)] + cmd: ValuesCmd, + }, +} + +#[derive(Subcommand)] +enum ValuesCmd { + /// List attribute values for a user + Get { + /// User id + user: i64, + }, + /// Upsert an attribute value binding for a user + Set { + /// User id + #[arg(long)] + user: String, + /// User attribute id + #[arg(long)] + attribute: String, + }, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List { + offset, + limit, + name, + attr_type, + } => { + let mut query = Vec::new(); + util::push(&mut query, "offset", &offset); + util::push(&mut query, "limit", &limit); + util::push(&mut query, "name", &name); + util::push(&mut query, "type", &attr_type); + let res = api.get("/api/v1/user-attributes/", &query).await?; + output::print_list( + ctx.json, + &res, + &[ + ("ID", "id"), + ("NAME", "name"), + ("TYPE", "type"), + ("DISPLAY NAME", "displayName"), + ("DEFAULT", "defaultValue"), + ], + ); + } + Cmd::Create { + name, + attr_type, + display_name, + description, + default_value, + } => { + let mut body = serde_json::Map::new(); + body.insert("name".to_string(), json!(name)); + body.insert("type".to_string(), json!(attr_type)); + util::set(&mut body, "displayName", &display_name); + util::set(&mut body, "description", &description); + util::set(&mut body, "defaultValue", &default_value); + let res = api + .post("/api/v1/user-attributes/", Some(&util::body(body))) + .await?; + output::print_json(&res); + } + Cmd::Update { + attribute, + display_name, + description, + default_value, + } => { + let mut body = serde_json::Map::new(); + util::set(&mut body, "displayName", &display_name); + util::set(&mut body, "description", &description); + util::set(&mut body, "defaultValue", &default_value); + let res = api + .put( + &format!("/api/v1/user-attributes/{attribute}"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + Cmd::Delete { attribute } => { + api.delete(&format!("/api/v1/user-attributes/{attribute}"), None) + .await?; + output::success(&format!("Deleted user attribute {attribute}")); + } + Cmd::Values { cmd } => match cmd { + ValuesCmd::Get { user } => { + let res = api + .get( + &format!("/api/v1/user-attribute-values/{user}"), + &Vec::new(), + ) + .await?; + output::print_json(&res); + } + ValuesCmd::Set { user, attribute } => { + let res = api + .post( + "/api/v1/user-attribute-values/", + Some(&json!({ "userId": user, "userAttributeId": attribute })), + ) + .await?; + output::print_json(&res); + } + }, + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/completion.rs b/rust/cube-cli/src/commands/completion.rs new file mode 100644 index 0000000000000..1abd69ea76530 --- /dev/null +++ b/rust/cube-cli/src/commands/completion.rs @@ -0,0 +1,15 @@ +use anyhow::Result; +use clap_complete::Shell; + +#[derive(clap::Args)] +pub struct Args { + /// Shell to generate completions for + #[arg(value_enum)] + shell: Shell, +} + +pub fn command(args: Args) -> Result<()> { + let mut cmd = crate::cli_command(); + clap_complete::generate(args.shell, &mut cmd, "cube", &mut std::io::stdout()); + Ok(()) +} diff --git a/rust/cube-cli/src/commands/context.rs b/rust/cube-cli/src/commands/context.rs new file mode 100644 index 0000000000000..ba9a06c3a2fe5 --- /dev/null +++ b/rust/cube-cli/src/commands/context.rs @@ -0,0 +1,51 @@ +use anyhow::{bail, Result}; +use clap::Subcommand; + +use crate::{output, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List saved contexts + List, + /// Switch the default context + Use { + /// Name + name: String, + }, +} + +pub async fn command(args: Args, ctx: &mut Ctx) -> Result<()> { + match args.cmd { + Cmd::List => { + let rows = ctx + .config + .contexts + .iter() + .map(|(name, c)| { + let active = if ctx.config.default_context.as_deref() == Some(name.as_str()) { + "*" + } else { + "" + }; + vec![active.to_string(), name.clone(), c.url.clone()] + }) + .collect(); + output::table(&["", "NAME", "URL"], rows); + } + Cmd::Use { name } => { + if !ctx.config.contexts.contains_key(&name) { + bail!("context `{name}` not found (run `cube login --name {name}`)"); + } + ctx.config.default_context = Some(name.clone()); + ctx.config.save()?; + output::success(&format!("Switched to context `{name}`")); + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/data_model.rs b/rust/cube-cli/src/commands/data_model.rs new file mode 100644 index 0000000000000..f6d4d8481c951 --- /dev/null +++ b/rust/cube-cli/src/commands/data_model.rs @@ -0,0 +1,437 @@ +use std::io::Read; + +use anyhow::{Context as _, Result}; +use clap::Subcommand; +use serde_json::json; + +use crate::client::Query; +use crate::{output, util, Ctx}; + +/// Manage a deployment's data model files (schema). +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List the data model source tree + #[command(alias = "ls")] + List { + /// Deployment id + deployment: i64, + /// Include each file's content in the output + #[arg(long)] + content: bool, + /// Branch to read (defaults to the deployment's default branch) + #[arg(long)] + branch: Option, + }, + /// Print a single file's content + Get { + /// Deployment id + deployment: i64, + /// File path within the project, e.g. model/cubes/orders.yml + path: String, + /// Branch name (defaults to the deployment default branch) + #[arg(long)] + branch: Option, + }, + /// Create or overwrite a file + Put { + /// Deployment id + deployment: i64, + /// Destination path, e.g. model/cubes/orders.yml + path: String, + /// Read content from a local file + #[arg(long, conflicts_with = "content")] + file: Option, + /// Inline content (use `-` to read stdin) + #[arg(long)] + content: Option, + /// Branch name (defaults to the deployment default branch) + #[arg(long)] + branch: Option, + }, + /// Delete files + #[command(alias = "rm")] + Delete { + /// Deployment id + deployment: i64, + /// One or more file paths to delete + #[arg(required = true)] + paths: Vec, + /// Branch name (defaults to the deployment default branch) + #[arg(long)] + branch: Option, + }, + /// Rename (move) a file + Rename { + /// Deployment id + deployment: i64, + /// Source path + from: String, + /// Destination path + to: String, + /// Branch name (defaults to the deployment default branch) + #[arg(long)] + branch: Option, + }, + /// List branches + Branches { + /// Deployment id + deployment: i64, + }, + /// Create a branch (optionally entering dev mode) + CreateBranch { + /// Deployment id + deployment: i64, + /// Name + name: String, + /// Enter dev mode on the new branch + #[arg(long)] + dev_mode: bool, + }, + /// Enter dev mode / switch to a branch + DevMode { + /// Deployment id + deployment: i64, + /// Branch to switch to (required by the API) + branch: String, + }, + /// Exit dev mode + ExitDevMode { + /// Deployment id + deployment: i64, + }, + /// Commit and push the active branch + Commit { + /// Deployment id + deployment: i64, + /// Commit message + #[arg(long, short = 'm')] + message: Option, + }, + /// Sync a branch from its remote and rebuild if it moved + Pull { + /// Deployment id + deployment: i64, + /// Branch name (defaults to the deployment default branch) + #[arg(long)] + branch: Option, + }, + /// Merge a branch into its parent branch + Merge { + /// Deployment id + deployment: i64, + /// Branch to merge (defaults to the active dev-mode branch) + #[arg(long)] + branch: Option, + /// Squash commits into one + #[arg(long)] + squash: bool, + /// Switch to the parent branch after merging + #[arg(long)] + switch_to_parent: bool, + /// Delete the branch after merging + #[arg(long)] + delete_branch: bool, + }, + /// Merge a branch straight into the deploy/default branch (production) + MergeToDefault { + /// Deployment id + deployment: i64, + /// Branch to merge (defaults to the active dev-mode branch) + #[arg(long)] + branch: Option, + /// Commit message + #[arg(long, short = 'm')] + message: Option, + /// Keep the branch after merging (default removes it) + #[arg(long)] + keep_branch: bool, + }, +} + +fn base(deployment: i64) -> String { + format!("/build/api/v1/deployments/{deployment}/data-model/files") +} + +/// The files endpoint returns a nested tree under `data`, each node carrying +/// `path`, `type` (file|directory), `content`, and `children`. Flatten it +/// depth-first into a single list of nodes. +fn flatten(nodes: &[serde_json::Value], out: &mut Vec) { + for n in nodes { + out.push(n.clone()); + if let Some(children) = n.get("children").and_then(|c| c.as_array()) { + flatten(children, out); + } + } +} + +fn tree_nodes(res: &serde_json::Value) -> Vec { + let mut out = Vec::new(); + if let Some(arr) = res.get("data").and_then(|d| d.as_array()) { + flatten(arr, &mut out); + } + out +} + +/// Write endpoints take the target branch as a `branchName` body field (with +/// the caller's active dev-mode branch as the fallback when omitted). +fn write_body( + mut body: serde_json::Map, + branch: &Option, +) -> serde_json::Value { + if let Some(b) = branch { + body.insert("branchName".into(), json!(b)); + } + util::body(body) +} + +fn read_content(file: Option, content: Option) -> Result { + if let Some(path) = file { + return std::fs::read_to_string(&path).with_context(|| format!("failed to read {path}")); + } + match content.as_deref() { + Some("-") => { + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + Ok(buf) + } + Some(c) => Ok(c.to_string()), + None => anyhow::bail!("provide --file or --content "), + } +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List { + deployment, + content, + branch, + } => { + let mut query: Query = Vec::new(); + if content { + query.push(("withContent".into(), "true".into())); + } + util::push(&mut query, "branchName", &branch); + let res = api.get(&base(deployment), &query).await?; + if ctx.json || content { + output::print_json(&res); + } else { + // Depth-first tree view: mark directories with a trailing slash. + for n in tree_nodes(&res) { + let path = output::field(&n, "path"); + if output::field(&n, "type") == "directory" { + println!("{path}/"); + } else { + println!("{path}"); + } + } + } + } + Cmd::Get { + deployment, + path, + branch, + } => { + let mut query: Query = vec![("withContent".into(), "true".into())]; + util::push(&mut query, "branchName", &branch); + let res = api.get(&base(deployment), &query).await?; + let file = tree_nodes(&res) + .into_iter() + .find(|f| output::field(f, "path") == path && output::field(f, "type") == "file"); + match file { + Some(f) => print!("{}", output::field(&f, "content")), + None => anyhow::bail!("file not found: {path}"), + } + } + Cmd::Put { + deployment, + path, + file, + content, + branch, + } => { + let text = read_content(file, content)?; + let mut map = serde_json::Map::new(); + map.insert("files".into(), json!([{ "path": path, "content": text }])); + let res = api + .put(&base(deployment), Some(&write_body(map, &branch))) + .await?; + if ctx.json { + output::print_json(&res); + } else { + output::success(&format!("Wrote {path}")); + } + } + Cmd::Delete { + deployment, + paths, + branch, + } => { + // The delete endpoint expects `files` as an array of objects. + let files: Vec<_> = paths.iter().map(|p| json!({ "path": p })).collect(); + let mut map = serde_json::Map::new(); + map.insert("files".into(), json!(files)); + let res = api + .delete(&base(deployment), Some(&write_body(map, &branch))) + .await?; + if ctx.json { + output::print_json(&res); + } else { + output::success(&format!("Deleted {} file(s)", paths.len())); + } + } + Cmd::Rename { + deployment, + from, + to, + branch, + } => { + let mut map = serde_json::Map::new(); + map.insert("from".into(), json!(from)); + map.insert("to".into(), json!(to)); + let res = api + .post( + &format!("{}/rename", base(deployment)), + Some(&write_body(map, &branch)), + ) + .await?; + if ctx.json { + output::print_json(&res); + } else { + output::success(&format!("Renamed {from} -> {to}")); + } + } + Cmd::Branches { deployment } => { + let res = api + .get( + &format!("/build/api/v1/deployments/{deployment}/branches"), + &Vec::new(), + ) + .await?; + output::print_list( + ctx.json, + &res, + &[ + ("NAME", "name"), + ("DEFAULT", "isDefault"), + ("CURRENT", "isCurrent"), + ], + ); + } + Cmd::CreateBranch { + deployment, + name, + dev_mode, + } => { + let body = json!({ "name": name, "enterDevMode": dev_mode }); + let res = api + .post( + &format!("/build/api/v1/deployments/{deployment}/branches"), + Some(&body), + ) + .await?; + if ctx.json { + output::print_json(&res); + } else { + output::success(&format!("Created branch {name}")); + } + } + Cmd::DevMode { deployment, branch } => { + let body = json!({ "branchName": branch }); + let res = api + .post( + &format!("/build/api/v1/deployments/{deployment}/dev-mode"), + Some(&body), + ) + .await?; + if ctx.json { + output::print_json(&res); + } else { + output::success(&format!("Entered dev mode on {branch}")); + } + } + Cmd::ExitDevMode { deployment } => { + api.delete( + &format!("/build/api/v1/deployments/{deployment}/dev-mode"), + None, + ) + .await?; + output::success("Exited dev mode"); + } + Cmd::Commit { + deployment, + message, + } => { + let mut body = serde_json::Map::new(); + util::set(&mut body, "message", &message); + let res = api + .post( + &format!("/build/api/v1/deployments/{deployment}/commit"), + Some(&util::body(body)), + ) + .await?; + if ctx.json { + output::print_json(&res); + } else { + output::success("Committed and pushed"); + } + } + Cmd::Pull { deployment, branch } => { + let body = branch.as_ref().map(|b| json!({ "branchName": b })); + let res = api + .post( + &format!("/build/api/v1/deployments/{deployment}/pull"), + body.as_ref(), + ) + .await?; + if ctx.json { + output::print_json(&res); + } else { + output::success("Pulled"); + } + } + Cmd::Merge { + deployment, + branch, + squash, + switch_to_parent, + delete_branch, + } => { + let mut map = serde_json::Map::new(); + map.insert("squashCommits".into(), json!(squash)); + map.insert("switchToParentBranch".into(), json!(switch_to_parent)); + map.insert("deleteBranch".into(), json!(delete_branch)); + let res = api + .post( + &format!("/build/api/v1/deployments/{deployment}/merge"), + Some(&write_body(map, &branch)), + ) + .await?; + output::print_json(&res); + } + Cmd::MergeToDefault { + deployment, + branch, + message, + keep_branch, + } => { + let mut map = serde_json::Map::new(); + util::set(&mut map, "message", &message); + map.insert("removeBranchAfterMerge".into(), json!(!keep_branch)); + let res = api + .post( + &format!("/build/api/v1/deployments/{deployment}/merge-to-default"), + Some(&write_body(map, &branch)), + ) + .await?; + output::print_json(&res); + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/deployments.rs b/rust/cube-cli/src/commands/deployments.rs new file mode 100644 index 0000000000000..1910811aa42de --- /dev/null +++ b/rust/cube-cli/src/commands/deployments.rs @@ -0,0 +1,200 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List deployments + #[command(alias = "ls")] + List { + /// Filter by creation step (repeatable): project, upload, schema, github, ssh, databases, ready, demo + #[arg(long = "creation-step")] + creation_step: Vec, + /// Pagination offset + #[arg(long)] + offset: Option, + /// Maximum number of items to return + #[arg(long)] + limit: Option, + /// Page size for cursor-based pagination + #[arg(long)] + first: Option, + /// Cursor for fetching the next page + #[arg(long)] + after: Option, + }, + /// Show a single deployment + Get { + /// Deployment id + deployment: i64, + }, + /// Create a deployment + Create { + /// Deployment name + #[arg(long)] + name: Option, + /// Region name (see `cube regions`), e.g. aws-us-east-1-2 + #[arg(long)] + region: Option, + /// Cloud provider: cubecloud, aws, gcp + #[arg(long, default_value = "cubecloud")] + cloud_provider: String, + /// Target platform, e.g. aws, gcp + #[arg(long, default_value = "aws")] + target_platform: String, + /// Provision a self-managed (BYOC/k8s-hybrid) deployment instead of managed + #[arg(long)] + unmanaged: bool, + /// Creation step: project, upload, schema, github, ssh, databases, ready, demo + #[arg(long, default_value = "project")] + creation_step: String, + /// Scaffold a project and run the first build so the deployment serves + /// immediately (POST /build/api/v1/deployments). Without this, the + /// deployment row is created but has no build until code is deployed. + #[arg(long, short = 'b')] + bootstrap: bool, + /// Full CreateDeploymentInput as JSON (overrides the flags above) + #[arg(long, short = 'd')] + data: Option, + }, + /// Update a deployment (rename, or full UpdateDeploymentInput via --data) + Update { + /// Deployment id + deployment: i64, + /// Name + #[arg(long)] + name: Option, + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: Option, + }, + /// Delete a deployment + #[command(alias = "rm")] + Delete { + /// Deployment id + deployment: i64, + }, + /// Generate a Cube API token for a deployment + Token { + /// Deployment id + deployment: i64, + }, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List { + creation_step, + offset, + limit, + first, + after, + } => { + let mut query = Vec::new(); + for step in creation_step { + query.push(("creationStep".to_string(), step)); + } + util::push(&mut query, "offset", &offset); + util::push(&mut query, "limit", &limit); + util::push(&mut query, "first", &first); + util::push(&mut query, "after", &after); + let res = api.get("/api/v1/deployments/", &query).await?; + output::print_list( + ctx.json, + &res, + &[ + ("ID", "id"), + ("NAME", "name"), + ("URL", "deploymentUrl"), + ("STEP", "creationStep"), + ], + ); + } + Cmd::Get { deployment } => { + let res = api + .get(&format!("/api/v1/deployments/{deployment}"), &Vec::new()) + .await?; + output::print_json(&res); + } + Cmd::Create { + name, + region, + cloud_provider, + target_platform, + unmanaged, + creation_step, + bootstrap, + data, + } => { + // Flags populate the body; --data (if given) overrides them. + let mut body = serde_json::Map::new(); + util::set(&mut body, "name", &name); + util::set(&mut body, "region", ®ion); + body.insert("cloudProvider".into(), serde_json::json!(cloud_provider)); + body.insert("targetPlatform".into(), serde_json::json!(target_platform)); + body.insert("isManaged".into(), serde_json::json!(!unmanaged)); + body.insert("creationStep".into(), serde_json::json!(creation_step)); + for (k, v) in util::parse_data(data.as_deref())? { + body.insert(k, v); + } + for required in ["name", "region"] { + if !body.contains_key(required) { + anyhow::bail!("--{required} is required (or provide it via --data)"); + } + } + // The bootstrap endpoint lives on the build pod and scaffolds + + // builds the project; the base endpoint only creates the row. + let path = if bootstrap { + "/build/api/v1/deployments" + } else { + "/api/v1/deployments" + }; + let res = api.post(path, Some(&util::body(body))).await?; + output::print_json(&res); + } + Cmd::Update { + deployment, + name, + data, + } => { + let mut body = util::parse_data(data.as_deref())?; + util::set(&mut body, "name", &name); + let res = api + .put( + &format!("/api/v1/deployments/{deployment}"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + Cmd::Delete { deployment } => { + let res = api + .delete(&format!("/api/v1/deployments/{deployment}"), None) + .await?; + if ctx.json { + output::print_json(&res); + } else { + output::success(&format!("Deleted deployment {deployment}")); + } + } + Cmd::Token { deployment } => { + let res = api + .post(&format!("/api/v1/deployments/{deployment}/token"), None) + .await?; + if ctx.json { + output::print_json(&res); + } else { + println!("{}", output::field(&res, "cubeApiToken")); + } + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/embed.rs b/rust/cube-cli/src/commands/embed.rs new file mode 100644 index 0000000000000..ec647f85255f7 --- /dev/null +++ b/rust/cube-cli/src/commands/embed.rs @@ -0,0 +1,130 @@ +use anyhow::Result; +use clap::Subcommand; +use serde_json::json; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Generate a one-time embed session (GenerateSession as JSON; admin only) + GenerateSession { + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: String, + }, + /// Exchange a session id for a signed embed JWT + Token { + /// Embed session id + #[arg(long)] + session_id: String, + }, + /// Fetch an embeddable dashboard by public id + Dashboard { + /// Public id + public_id: String, + }, + /// Manage embed tenants + Tenant { + #[command(subcommand)] + cmd: TenantCmd, + }, +} + +#[derive(Subcommand)] +enum TenantCmd { + /// Delete an embed tenant + #[command(alias = "rm")] + Delete { + /// Name + name: String, + }, + /// List an embed tenant's groups + Groups { + /// Name + name: String, + /// Page size (cursor pagination) + #[arg(long)] + first: Option, + /// Cursor for the next page (from a previous pageInfo.endCursor) + #[arg(long)] + after: Option, + }, + /// Delete a group of an embed tenant + DeleteGroup { + /// Name + name: String, + /// Group id + group: i64, + }, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::GenerateSession { data } => { + let body = util::parse_data(Some(&data))?; + let res = api + .post("/api/v1/embed/generate-session", Some(&util::body(body))) + .await?; + if ctx.json { + output::print_json(&res); + } else { + println!("{}", output::field(&res, "sessionId")); + } + } + Cmd::Token { session_id } => { + let res = api + .post( + "/api/v1/embed/session/token", + Some(&json!({ "sessionId": session_id })), + ) + .await?; + if ctx.json { + output::print_json(&res); + } else { + println!("{}", output::field(&res, "token")); + } + } + Cmd::Dashboard { public_id } => { + let res = api + .get(&format!("/api/v1/embed/dashboard/{public_id}"), &Vec::new()) + .await?; + output::print_json(&res); + } + Cmd::Tenant { cmd } => match cmd { + TenantCmd::Delete { name } => { + api.delete(&format!("/api/v1/embed-tenants/{name}/"), None) + .await?; + output::success(&format!("Deleted embed tenant `{name}`")); + } + TenantCmd::Groups { name, first, after } => { + let mut query = Vec::new(); + util::push(&mut query, "first", &first); + util::push(&mut query, "after", &after); + let res = api + .get(&format!("/api/v1/embed-tenants/{name}/groups"), &query) + .await?; + output::print_list( + ctx.json, + &res, + &[("ID", "id"), ("NAME", "name"), ("USERS", "userCount")], + ); + } + TenantCmd::DeleteGroup { name, group } => { + api.delete( + &format!("/api/v1/embed-tenants/{name}/groups/{group}"), + None, + ) + .await?; + output::success(&format!("Deleted group {group} of embed tenant `{name}`")); + } + }, + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/environments.rs b/rust/cube-cli/src/commands/environments.rs new file mode 100644 index 0000000000000..82521cee93fa7 --- /dev/null +++ b/rust/cube-cli/src/commands/environments.rs @@ -0,0 +1,156 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List environments of a deployment + #[command(alias = "ls")] + List { + /// Deployment id + deployment: i64, + /// Filter by type: production, staging, development + #[arg(long = "type")] + env_type: Option, + /// Pagination offset + #[arg(long)] + offset: Option, + /// Maximum number of items to return + #[arg(long)] + limit: Option, + }, + /// List tokens issued for an environment + Tokens { + /// Deployment id + deployment: i64, + /// Environment id + environment: i64, + /// Pagination offset + #[arg(long)] + offset: Option, + /// Maximum number of items to return + #[arg(long)] + limit: Option, + }, + /// Create an environment token + CreateToken { + /// Deployment id + deployment: i64, + /// Environment id + environment: i64, + /// Security context as JSON (inline, @file, or - for stdin) + #[arg(long)] + security_context: String, + /// Token TTL in seconds (1-3600) + #[arg(long)] + expires_in: Option, + /// Token scopes (repeatable) + #[arg(long)] + scope: Vec, + /// Issue a token for metadata sync instead of a regular token + #[arg(long)] + meta_sync: bool, + }, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List { + deployment, + env_type, + offset, + limit, + } => { + let mut query = Vec::new(); + util::push(&mut query, "type", &env_type); + util::push(&mut query, "offset", &offset); + util::push(&mut query, "limit", &limit); + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/environments"), + &query, + ) + .await?; + output::print_list( + ctx.json, + &res, + &[ + ("ID", "id"), + ("TYPE", "type"), + ("BRANCH", "branch"), + ("USER", "user"), + ], + ); + } + Cmd::Tokens { + deployment, + environment, + offset, + limit, + } => { + let mut query = Vec::new(); + util::push(&mut query, "offset", &offset); + util::push(&mut query, "limit", &limit); + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/environments/{environment}/tokens"), + &query, + ) + .await?; + output::print_list( + ctx.json, + &res, + &[ + ("TOKEN", "token"), + ("CREATED", "created_at"), + ("EXPIRES", "expires_at"), + ], + ); + } + Cmd::CreateToken { + deployment, + environment, + security_context, + expires_in, + scope, + meta_sync, + } => { + let mut body = serde_json::Map::new(); + body.insert( + "security_context".to_string(), + serde_json::Value::Object(util::parse_data(Some(&security_context))?), + ); + util::set(&mut body, "expires_in", &expires_in); + if !scope.is_empty() { + util::set(&mut body, "scopes", &Some(scope)); + } + let suffix = if meta_sync { + "tokens-for-meta-sync" + } else { + "tokens" + }; + let res = api + .post( + &format!( + "/api/v1/deployments/{deployment}/environments/{environment}/{suffix}" + ), + Some(&util::body(body)), + ) + .await?; + if ctx.json { + output::print_json(&res); + } else { + println!("{}", output::field(&res, "data.token")); + } + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/folders.rs b/rust/cube-cli/src/commands/folders.rs new file mode 100644 index 0000000000000..b9a23b20a4dcd --- /dev/null +++ b/rust/cube-cli/src/commands/folders.rs @@ -0,0 +1,153 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List folders at the workspace root or inside a parent folder + #[command(alias = "ls")] + List { + /// Deployment id + deployment: i64, + /// List children of this folder instead of the root + #[arg(long)] + parent: Option, + /// Page size (cursor pagination) + #[arg(long)] + first: Option, + /// Cursor for the next page (from a previous pageInfo.endCursor) + #[arg(long)] + after: Option, + }, + /// Create a folder + Create { + /// Deployment id + deployment: i64, + /// Name + #[arg(long)] + name: String, + /// Parent folder id (omit for workspace root) + #[arg(long)] + parent: Option, + /// Ordering among siblings + #[arg(long)] + position: Option, + }, + /// Rename a folder or change its position + Update { + /// Deployment id + deployment: i64, + /// Folder id + folder: i64, + /// Name + #[arg(long)] + name: Option, + /// Position + #[arg(long)] + position: Option, + }, + /// Delete a folder (must have no sub-folders; content moves to root) + #[command(alias = "rm")] + Delete { + /// Deployment id + deployment: i64, + /// Folder id + folder: i64, + }, + /// Show the ancestor chain of a folder (breadcrumb) + Ancestors { + /// Deployment id + deployment: i64, + /// Folder id + folder: i64, + }, +} + +const COLUMNS: &[(&str, &str)] = &[ + ("ID", "id"), + ("NAME", "name"), + ("PARENT", "parentId"), + ("POSITION", "position"), + ("UPDATED", "updatedAt"), +]; + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List { + deployment, + parent, + first, + after, + } => { + let mut query = Vec::new(); + util::push(&mut query, "parentId", &parent); + util::push(&mut query, "first", &first); + util::push(&mut query, "after", &after); + let res = api + .get(&format!("/api/v1/deployments/{deployment}/folders"), &query) + .await?; + output::print_list(ctx.json, &res, COLUMNS); + } + Cmd::Create { + deployment, + name, + parent, + position, + } => { + let mut body = serde_json::Map::new(); + util::set(&mut body, "name", &Some(name)); + util::set(&mut body, "parentId", &parent); + util::set(&mut body, "position", &position); + let res = api + .post( + &format!("/api/v1/deployments/{deployment}/folders"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + Cmd::Update { + deployment, + folder, + name, + position, + } => { + let mut body = serde_json::Map::new(); + util::set(&mut body, "name", &name); + util::set(&mut body, "position", &position); + let res = api + .put( + &format!("/api/v1/deployments/{deployment}/folders/{folder}"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + Cmd::Delete { deployment, folder } => { + api.delete( + &format!("/api/v1/deployments/{deployment}/folders/{folder}"), + None, + ) + .await?; + output::success(&format!("Deleted folder {folder}")); + } + Cmd::Ancestors { deployment, folder } => { + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/folders/{folder}/ancestors"), + &Vec::new(), + ) + .await?; + output::print_list(ctx.json, &res, COLUMNS); + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/groups.rs b/rust/cube-cli/src/commands/groups.rs new file mode 100644 index 0000000000000..661424ae41f94 --- /dev/null +++ b/rust/cube-cli/src/commands/groups.rs @@ -0,0 +1,57 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List user groups + #[command(alias = "ls")] + List { + /// Page size (cursor pagination) + #[arg(long)] + first: Option, + /// Cursor for the next page (from a previous pageInfo.endCursor) + #[arg(long)] + after: Option, + }, + /// Delete a user group + #[command(alias = "rm")] + Delete { + /// Group id + group: i64, + }, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List { first, after } => { + let mut query = Vec::new(); + util::push(&mut query, "first", &first); + util::push(&mut query, "after", &after); + let res = api.get("/api/v1/user-groups/", &query).await?; + output::print_list( + ctx.json, + &res, + &[ + ("ID", "id"), + ("NAME", "name"), + ("USERS", "userCount"), + ("DESCRIPTION", "description"), + ], + ); + } + Cmd::Delete { group } => { + api.delete(&format!("/api/v1/groups/{group}"), None).await?; + output::success(&format!("Deleted group {group}")); + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/integrations.rs b/rust/cube-cli/src/commands/integrations.rs new file mode 100644 index 0000000000000..e773655bea023 --- /dev/null +++ b/rust/cube-cli/src/commands/integrations.rs @@ -0,0 +1,160 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List OAuth integrations + #[command(alias = "ls")] + List, + /// Show an OAuth integration + Get { + /// OAuth integration id + integration: i64, + }, + /// Create an OAuth integration (CreateOAuthIntegrationInput as JSON) + Create { + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: String, + }, + /// Update an OAuth integration (UpdateOAuthIntegrationInput as JSON) + Update { + /// OAuth integration id + integration: i64, + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: String, + }, + /// Delete an OAuth integration + #[command(alias = "rm")] + Delete { + /// OAuth integration id + integration: i64, + }, + /// Manage the current user's OAuth tokens + Tokens { + #[command(subcommand)] + cmd: TokensCmd, + }, +} + +#[derive(Subcommand)] +enum TokensCmd { + /// List the current user's OAuth tokens + #[command(alias = "ls")] + List, + /// Show the user's OAuth token for an integration + Get { + /// OAuth integration id + integration: i64, + }, + /// Revoke the user's OAuth token for an integration + Revoke { + /// OAuth integration id + integration: i64, + }, + /// Initiate the OAuth flow for an integration + Initiate { + /// OAuth integration id + integration: i64, + }, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List => { + let res = api.get("/api/v1/oauth-integrations/", &Vec::new()).await?; + output::print_list( + ctx.json, + &res, + &[ + ("ID", "id"), + ("NAME", "name"), + ("TYPE", "type"), + ("CLIENT ID", "clientId"), + ], + ); + } + Cmd::Get { integration } => { + let res = api + .get( + &format!("/api/v1/oauth-integrations/{integration}"), + &Vec::new(), + ) + .await?; + output::print_json(&res); + } + Cmd::Create { data } => { + let body = util::parse_data(Some(&data))?; + let res = api + .post("/api/v1/oauth-integrations/", Some(&util::body(body))) + .await?; + output::print_json(&res); + } + Cmd::Update { integration, data } => { + let body = util::parse_data(Some(&data))?; + let res = api + .put( + &format!("/api/v1/oauth-integrations/{integration}"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + Cmd::Delete { integration } => { + api.delete(&format!("/api/v1/oauth-integrations/{integration}"), None) + .await?; + output::success(&format!("Deleted OAuth integration {integration}")); + } + Cmd::Tokens { cmd } => match cmd { + TokensCmd::List => { + let res = api.get("/api/v1/user-oauth-tokens/", &Vec::new()).await?; + output::print_list( + ctx.json, + &res, + &[ + ("ID", "id"), + ("INTEGRATION", "integrationId"), + ("STATUS", "status"), + ("EXPIRES", "accessTokenExpiresAt"), + ], + ); + } + TokensCmd::Get { integration } => { + let res = api + .get( + &format!("/api/v1/user-oauth-tokens/{integration}"), + &Vec::new(), + ) + .await?; + output::print_json(&res); + } + TokensCmd::Revoke { integration } => { + api.delete(&format!("/api/v1/user-oauth-tokens/{integration}"), None) + .await?; + output::success(&format!( + "Revoked OAuth token for integration {integration}" + )); + } + TokensCmd::Initiate { integration } => { + let res = api + .post( + &format!("/api/v1/user-oauth-tokens/{integration}/initiate"), + None, + ) + .await?; + output::print_json(&res); + } + }, + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/login.rs b/rust/cube-cli/src/commands/login.rs new file mode 100644 index 0000000000000..4dfc7cf9e021e --- /dev/null +++ b/rust/cube-cli/src/commands/login.rs @@ -0,0 +1,91 @@ +use anyhow::Result; +use owo_colors::OwoColorize; + +use crate::client::Client; +use crate::config::ContextConfig; +use crate::{oauth, output, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + /// Cube Cloud URL, e.g. https://.cubecloud.dev + #[arg(long)] + url: Option, + /// Authenticate with an API key instead of the browser device flow + #[arg(long)] + api_key: Option, + /// Name to save this context under + #[arg(long, default_value = "default")] + name: String, +} + +pub async fn command(args: Args, ctx: &mut Ctx) -> Result<()> { + let url = match args.url { + Some(url) => url, + None => inquire::Text::new("Cube Cloud URL:") + .with_placeholder("https://.cubecloud.dev") + .prompt()?, + }; + let url = url.trim_end_matches('/').to_string(); + + let (token, refresh_token) = match args.api_key { + Some(key) => (key, None), + None => device_login(&url).await?, + }; + + // Validate the credentials before saving them. + let client = Client::new(&url, &token)?; + let me = client.get("/api/v1/users/me", &Vec::new()).await?; + let email = output::field(&me, "email"); + + ctx.config.contexts.insert( + args.name.clone(), + ContextConfig { + url, + api_key: token, + refresh_token, + }, + ); + ctx.config.default_context = Some(args.name.clone()); + ctx.config.save()?; + + output::success(&format!( + "Logged in as {email} (context `{}`, saved to {})", + args.name, + crate::config::config_path()?.display() + )); + Ok(()) +} + +/// Drive the OAuth 2.0 device authorization grant and return +/// (access_token, refresh_token). +async fn device_login(url: &str) -> Result<(String, Option)> { + let cfg = oauth::OAuthConfig::from_env(); + let http = reqwest::Client::builder() + .user_agent(concat!("cube-cli/", env!("CARGO_PKG_VERSION"))) + .build()?; + + let device = oauth::request_device_code(&http, url, &cfg).await?; + + let verification = device + .verification_uri_complete + .clone() + .unwrap_or_else(|| device.verification_uri.clone()); + + println!(); + println!("To authorize this CLI, open the following URL in your browser:"); + println!(" {}", verification.bold().underline()); + println!(); + println!( + "and confirm this code: {}", + device.user_code.bold().green() + ); + println!(); + + if oauth::open_browser(&verification) { + println!("{}", "Opened your browser automatically…".dimmed()); + } + println!("{}", "Waiting for authorization…".dimmed()); + + let token = oauth::poll_for_token(&http, url, &cfg, &device).await?; + Ok((token.access_token, token.refresh_token)) +} diff --git a/rust/cube-cli/src/commands/logout.rs b/rust/cube-cli/src/commands/logout.rs new file mode 100644 index 0000000000000..e8709583f4ab7 --- /dev/null +++ b/rust/cube-cli/src/commands/logout.rs @@ -0,0 +1,27 @@ +use anyhow::Result; + +use crate::{output, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + /// Context to remove (defaults to the active one) + #[arg(long)] + name: Option, +} + +pub async fn command(args: Args, ctx: &mut Ctx) -> Result<()> { + let name = args + .name + .or_else(|| ctx.config.default_context.clone()) + .unwrap_or_else(|| "default".to_string()); + if ctx.config.contexts.remove(&name).is_none() { + output::success(&format!("No saved credentials for context `{name}`")); + return Ok(()); + } + if ctx.config.default_context.as_deref() == Some(name.as_str()) { + ctx.config.default_context = ctx.config.contexts.keys().next().cloned(); + } + ctx.config.save()?; + output::success(&format!("Removed context `{name}`")); + Ok(()) +} diff --git a/rust/cube-cli/src/commands/meta.rs b/rust/cube-cli/src/commands/meta.rs new file mode 100644 index 0000000000000..b729a05016f1d --- /dev/null +++ b/rust/cube-cli/src/commands/meta.rs @@ -0,0 +1,35 @@ +use anyhow::Result; +use serde_json::json; + +use crate::{output, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + /// Metadata selectors as JSON (inline, @file, or - for stdin) + #[arg(long)] + selectors: String, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let selectors: serde_json::Value = { + // Selectors may be any JSON shape, so parse directly rather than + // requiring an object like `--data` does. + let raw = if args.selectors == "-" { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + buf + } else if let Some(path) = args.selectors.strip_prefix('@') { + std::fs::read_to_string(path)? + } else { + args.selectors.clone() + }; + serde_json::from_str(&raw)? + }; + let res = ctx + .api()? + .post("/api/v1/meta/", Some(&json!({ "selectors": selectors }))) + .await?; + output::print_json(&res); + Ok(()) +} diff --git a/rust/cube-cli/src/commands/mod.rs b/rust/cube-cli/src/commands/mod.rs new file mode 100644 index 0000000000000..f989fd7f5e868 --- /dev/null +++ b/rust/cube-cli/src/commands/mod.rs @@ -0,0 +1,29 @@ +pub mod agents; +pub mod ai_engineer; +pub mod api; +pub mod app; +pub mod attributes; +pub mod completion; +pub mod context; +pub mod data_model; +pub mod deployments; +pub mod embed; +pub mod environments; +pub mod folders; +pub mod groups; +pub mod integrations; +pub mod login; +pub mod logout; +pub mod meta; +pub mod notifications; +pub mod oidc; +pub mod policies; +pub mod regions; +pub mod reports; +pub mod scim; +pub mod tenant; +pub mod users; +pub mod variables; +pub mod whoami; +pub mod workbooks; +pub mod workspace; diff --git a/rust/cube-cli/src/commands/notifications.rs b/rust/cube-cli/src/commands/notifications.rs new file mode 100644 index 0000000000000..1bbd6786d4918 --- /dev/null +++ b/rust/cube-cli/src/commands/notifications.rs @@ -0,0 +1,280 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List scheduled notifications (admin only) + #[command(alias = "ls")] + List { + /// Deployment id + deployment: i64, + /// Dashboard + #[arg(long)] + dashboard: Option, + /// Dashboard public id + #[arg(long)] + dashboard_public_id: Option, + /// Recipient user + #[arg(long)] + recipient_user: Option, + /// Recipient email + #[arg(long)] + recipient_email: Option, + /// Recipient embed tenant + #[arg(long)] + recipient_embed_tenant: Option, + /// Recipient external id + #[arg(long)] + recipient_external_id: Option, + /// Page size (cursor pagination) + #[arg(long)] + first: Option, + /// Cursor for the next page (from a previous pageInfo.endCursor) + #[arg(long)] + after: Option, + }, + /// Show a scheduled notification + Get { + /// Deployment id + deployment: i64, + /// Notification id + notification: i64, + }, + /// Create a scheduled notification (CreateNotificationInput as JSON) + Create { + /// Deployment id + deployment: i64, + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: String, + }, + /// Update a scheduled notification (UpdateNotificationInput as JSON) + Update { + /// Deployment id + deployment: i64, + /// Notification id + notification: i64, + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: String, + }, + /// Delete a scheduled notification and all its recipients + #[command(alias = "rm")] + Delete { + /// Deployment id + deployment: i64, + /// Notification id + notification: i64, + }, + /// Manage notification recipients + Recipients { + #[command(subcommand)] + cmd: RecipientsCmd, + }, +} + +#[derive(Subcommand)] +enum RecipientsCmd { + /// List recipients of a notification + #[command(alias = "ls")] + List { + /// Deployment id + deployment: i64, + /// Notification id + notification: i64, + /// Page size (cursor pagination) + #[arg(long)] + first: Option, + /// Cursor for the next page (from a previous pageInfo.endCursor) + #[arg(long)] + after: Option, + }, + /// Subscribe recipients (AddNotificationRecipientsInput as JSON) + Add { + /// Deployment id + deployment: i64, + /// Notification id + notification: i64, + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: String, + }, + /// Unsubscribe recipients (RemoveNotificationRecipientsInput as JSON) + Remove { + /// Deployment id + deployment: i64, + /// Notification id + notification: i64, + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: String, + }, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List { + deployment, + dashboard, + dashboard_public_id, + recipient_user, + recipient_email, + recipient_embed_tenant, + recipient_external_id, + first, + after, + } => { + let mut query = Vec::new(); + util::push(&mut query, "dashboardId", &dashboard); + util::push(&mut query, "dashboardPublicId", &dashboard_public_id); + util::push(&mut query, "recipientUserId", &recipient_user); + util::push(&mut query, "recipientEmail", &recipient_email); + util::push( + &mut query, + "recipientEmbedTenantName", + &recipient_embed_tenant, + ); + util::push(&mut query, "recipientExternalId", &recipient_external_id); + util::push(&mut query, "first", &first); + util::push(&mut query, "after", &after); + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/notifications"), + &query, + ) + .await?; + output::print_list( + ctx.json, + &res, + &[ + ("ID", "id"), + ("DASHBOARD", "dashboardId"), + ("SCHEDULE", "humanReadableSchedule"), + ("TZ", "timezone"), + ("FORMAT", "notificationFormat"), + ("ENABLED", "isEnabled"), + ], + ); + } + Cmd::Get { + deployment, + notification, + } => { + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/notifications/{notification}"), + &Vec::new(), + ) + .await?; + output::print_json(&res); + } + Cmd::Create { deployment, data } => { + let body = util::parse_data(Some(&data))?; + let res = api + .post( + &format!("/api/v1/deployments/{deployment}/notifications"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + Cmd::Update { + deployment, + notification, + data, + } => { + let body = util::parse_data(Some(&data))?; + let res = api + .put( + &format!("/api/v1/deployments/{deployment}/notifications/{notification}"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + Cmd::Delete { + deployment, + notification, + } => { + api.delete( + &format!("/api/v1/deployments/{deployment}/notifications/{notification}"), + None, + ) + .await?; + output::success(&format!("Deleted notification {notification}")); + } + Cmd::Recipients { cmd } => match cmd { + RecipientsCmd::List { + deployment, + notification, + first, + after, + } => { + let mut query = Vec::new(); + util::push(&mut query, "first", &first); + util::push(&mut query, "after", &after); + let res = api + .get( + &format!( + "/api/v1/deployments/{deployment}/notifications/{notification}/recipients" + ), + &query, + ) + .await?; + output::print_list( + ctx.json, + &res, + &[ + ("TYPE", "type"), + ("USER", "userId"), + ("EMAIL", "email"), + ("EMBED TENANT", "embedTenantName"), + ("EXTERNAL ID", "externalId"), + ("CHANNEL", "channelName"), + ], + ); + } + RecipientsCmd::Add { + deployment, + notification, + data, + } => { + let body = util::parse_data(Some(&data))?; + let res = api + .post( + &format!( + "/api/v1/deployments/{deployment}/notifications/{notification}/recipients" + ), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + RecipientsCmd::Remove { + deployment, + notification, + data, + } => { + let body = util::parse_data(Some(&data))?; + api.delete( + &format!( + "/api/v1/deployments/{deployment}/notifications/{notification}/recipients" + ), + Some(&util::body(body)), + ) + .await?; + output::success("Removed recipients"); + } + }, + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/oidc.rs b/rust/cube-cli/src/commands/oidc.rs new file mode 100644 index 0000000000000..ebf38ae11dad0 --- /dev/null +++ b/rust/cube-cli/src/commands/oidc.rs @@ -0,0 +1,91 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List OIDC token configs + #[command(alias = "ls")] + List, + /// Show an OIDC token config + Get { + /// OIDC token config id + config: i64, + }, + /// Create an OIDC token config (CreateOidcTokenConfigInput as JSON) + Create { + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: String, + }, + /// Update an OIDC token config (UpdateOidcTokenConfigInput as JSON) + Update { + /// OIDC token config id + config: i64, + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: String, + }, + /// Delete an OIDC token config + #[command(alias = "rm")] + Delete { + /// OIDC token config id + config: i64, + }, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List => { + let res = api.get("/api/v1/oidc-token-configs/", &Vec::new()).await?; + output::print_list( + ctx.json, + &res, + &[ + ("ID", "id"), + ("NAME", "name"), + ("AUDIENCE", "audience"), + ("ENABLED", "isEnabled"), + ("TARGET ENV", "targetEnvVar"), + ], + ); + } + Cmd::Get { config } => { + let res = api + .get(&format!("/api/v1/oidc-token-configs/{config}"), &Vec::new()) + .await?; + output::print_json(&res); + } + Cmd::Create { data } => { + let body = util::parse_data(Some(&data))?; + let res = api + .post("/api/v1/oidc-token-configs/", Some(&util::body(body))) + .await?; + output::print_json(&res); + } + Cmd::Update { config, data } => { + let body = util::parse_data(Some(&data))?; + let res = api + .put( + &format!("/api/v1/oidc-token-configs/{config}"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + Cmd::Delete { config } => { + api.delete(&format!("/api/v1/oidc-token-configs/{config}"), None) + .await?; + output::success(&format!("Deleted OIDC token config {config}")); + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/policies.rs b/rust/cube-cli/src/commands/policies.rs new file mode 100644 index 0000000000000..ad9e63cef7f37 --- /dev/null +++ b/rust/cube-cli/src/commands/policies.rs @@ -0,0 +1,102 @@ +use anyhow::Result; +use clap::Subcommand; +use serde_json::json; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Show user and group policies for a resource + Get { + /// Global, Deployment, Report, ReportFolder, Agent, AgentSpace, Workbook, Dashboard, Folder, ChatThread + #[arg(long)] + resource_type: String, + /// Resource id + #[arg(long)] + resource_id: i64, + }, + /// Set (or clear) a user's policy on a resource + SetUser { + /// Resource type + #[arg(long)] + resource_type: String, + /// Resource id + #[arg(long)] + resource_id: i64, + /// User id + #[arg(long)] + user: Option, + /// Action to grant, e.g. WorkbookRead; omit to clear + #[arg(long)] + action: Option, + }, + /// Set (or clear) a group's policy on a resource + SetGroup { + /// Resource type + #[arg(long)] + resource_type: String, + /// Resource id + #[arg(long)] + resource_id: i64, + /// Group id + #[arg(long)] + group: i64, + /// Action to grant, e.g. WorkbookRead; omit to clear + #[arg(long)] + action: Option, + }, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::Get { + resource_type, + resource_id, + } => { + let query = vec![ + ("resourceType".to_string(), resource_type), + ("resourceId".to_string(), resource_id.to_string()), + ]; + let res = api.get("/api/v1/resource-policies/", &query).await?; + output::print_json(&res); + } + Cmd::SetUser { + resource_type, + resource_id, + user, + action, + } => { + let mut body = serde_json::Map::new(); + body.insert("resourceType".to_string(), json!(resource_type)); + body.insert("resourceId".to_string(), json!(resource_id)); + util::set(&mut body, "userId", &user); + util::set(&mut body, "action", &action); + api.put("/api/v1/resource-policies/user", Some(&util::body(body))) + .await?; + output::success("Updated user policy"); + } + Cmd::SetGroup { + resource_type, + resource_id, + group, + action, + } => { + let mut body = serde_json::Map::new(); + body.insert("resourceType".to_string(), json!(resource_type)); + body.insert("resourceId".to_string(), json!(resource_id)); + body.insert("groupId".to_string(), json!(group)); + util::set(&mut body, "action", &action); + api.put("/api/v1/resource-policies/group", Some(&util::body(body))) + .await?; + output::success("Updated group policy"); + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/regions.rs b/rust/cube-cli/src/commands/regions.rs new file mode 100644 index 0000000000000..5110ffe58ac03 --- /dev/null +++ b/rust/cube-cli/src/commands/regions.rs @@ -0,0 +1,23 @@ +use anyhow::Result; + +use crate::{output, Ctx}; + +/// List the account's available deployment regions (names usable as the +/// `--region` value for `cube deployments create`). +#[derive(clap::Args)] +pub struct Args {} + +pub async fn command(_args: Args, ctx: &Ctx) -> Result<()> { + let res = ctx.api()?.get("/api/v1/regions", &Vec::new()).await?; + output::print_list( + ctx.json, + &res, + &[ + ("ID", "id"), + ("NAME", "name"), + ("TITLE", "title"), + ("PROVIDER", "provider"), + ], + ); + Ok(()) +} diff --git a/rust/cube-cli/src/commands/reports.rs b/rust/cube-cli/src/commands/reports.rs new file mode 100644 index 0000000000000..5a8094bfaf303 --- /dev/null +++ b/rust/cube-cli/src/commands/reports.rs @@ -0,0 +1,291 @@ +use anyhow::Result; +use clap::Subcommand; +use serde_json::json; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List reports + #[command(alias = "ls")] + List { + /// Deployment id + deployment: i64, + /// Workbook id + #[arg(long)] + workbook: Option, + /// Folder id + #[arg(long)] + folder: Option, + /// External workbook + #[arg(long)] + external_workbook: Option, + /// Maximum number of items to return + #[arg(long)] + limit: Option, + /// Page number + #[arg(long)] + page: Option, + /// Sort by: name, createdAt, updatedAt, lastViewedAt + #[arg(long)] + sort_by: Option, + /// ASC or DESC + #[arg(long)] + sort_direction: Option, + /// Page size (cursor pagination) + #[arg(long)] + first: Option, + /// Cursor for the next page (from a previous pageInfo.endCursor) + #[arg(long)] + after: Option, + }, + /// Show a report + Get { + /// Deployment id + deployment: i64, + /// Report id + report: i64, + }, + /// Create a report (CreateReportInput as JSON) + Create { + /// Deployment id + deployment: i64, + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: Option, + /// Name + #[arg(long)] + name: Option, + /// Folder id + #[arg(long)] + folder: Option, + /// Workbook id + #[arg(long)] + workbook: Option, + /// Cube JSON query for the report + #[arg(long)] + json_query: Option, + /// SQL query for the report + #[arg(long)] + sql_query: Option, + }, + /// Update a report (UpdateReportInput as JSON) + Update { + /// Deployment id + deployment: i64, + /// Report id + report: i64, + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: Option, + /// Name + #[arg(long)] + name: Option, + /// Folder id + #[arg(long)] + folder: Option, + /// Json query + #[arg(long)] + json_query: Option, + /// Sql query + #[arg(long)] + sql_query: Option, + }, + /// Delete a report + #[command(alias = "rm")] + Delete { + /// Deployment id + deployment: i64, + /// Report id + report: i64, + }, + /// Re-run a report's query + Refresh { + /// Deployment id + deployment: i64, + /// Report id + report: i64, + }, + /// Link a report to a spreadsheet placement + ConnectWorkbook { + /// Deployment id + deployment: i64, + /// Report id + report: i64, + /// External workbook id + #[arg(long)] + external_workbook_id: String, + /// Result location + #[arg(long)] + result_location: String, + /// End result cell + #[arg(long)] + end_result_cell: Option, + }, + /// List report folders + Folders { + /// Deployment id + deployment: i64, + }, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List { + deployment, + workbook, + folder, + external_workbook, + limit, + page, + sort_by, + sort_direction, + first, + after, + } => { + let mut query = Vec::new(); + util::push(&mut query, "workbookId", &workbook); + util::push(&mut query, "folderId", &folder); + util::push(&mut query, "externalWorkbookId", &external_workbook); + util::push(&mut query, "limit", &limit); + util::push(&mut query, "page", &page); + util::push(&mut query, "sortBy", &sort_by); + util::push(&mut query, "sortDirection", &sort_direction); + util::push(&mut query, "first", &first); + util::push(&mut query, "after", &after); + let res = api + .get(&format!("/api/v1/deployments/{deployment}/reports"), &query) + .await?; + output::print_list( + ctx.json, + &res, + &[ + ("ID", "id"), + ("NAME", "name"), + ("PUBLIC ID", "publicId"), + ("WORKBOOK", "workbookId"), + ("OWNER", "user.email"), + ("UPDATED", "updatedAt"), + ], + ); + } + Cmd::Get { deployment, report } => { + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/reports/{report}"), + &Vec::new(), + ) + .await?; + output::print_json(&res); + } + Cmd::Create { + deployment, + data, + name, + folder, + workbook, + json_query, + sql_query, + } => { + let mut body = util::parse_data(data.as_deref())?; + util::set(&mut body, "name", &name); + util::set(&mut body, "folderId", &folder); + util::set(&mut body, "workbookId", &workbook); + util::set(&mut body, "jsonQuery", &json_query); + util::set(&mut body, "sqlQuery", &sql_query); + let res = api + .post( + &format!("/api/v1/deployments/{deployment}/reports"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + Cmd::Update { + deployment, + report, + data, + name, + folder, + json_query, + sql_query, + } => { + let mut body = util::parse_data(data.as_deref())?; + util::set(&mut body, "name", &name); + util::set(&mut body, "folderId", &folder); + util::set(&mut body, "jsonQuery", &json_query); + util::set(&mut body, "sqlQuery", &sql_query); + let res = api + .put( + &format!("/api/v1/deployments/{deployment}/reports/{report}"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + Cmd::Delete { deployment, report } => { + api.delete( + &format!("/api/v1/deployments/{deployment}/reports/{report}"), + None, + ) + .await?; + output::success(&format!("Deleted report {report}")); + } + Cmd::Refresh { deployment, report } => { + let res = api + .put( + &format!("/api/v1/deployments/{deployment}/reports/{report}/refresh"), + None, + ) + .await?; + if ctx.json { + output::print_json(&res); + } else { + output::success(&format!("Refreshed report {report}")); + } + } + Cmd::ConnectWorkbook { + deployment, + report, + external_workbook_id, + result_location, + end_result_cell, + } => { + let mut body = serde_json::Map::new(); + body.insert( + "externalWorkbookId".to_string(), + json!(external_workbook_id), + ); + body.insert("resultLocation".to_string(), json!(result_location)); + util::set(&mut body, "endResultCell", &end_result_cell); + let res = api + .put( + &format!("/api/v1/deployments/{deployment}/reports/{report}/connect-workbook"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + Cmd::Folders { deployment } => { + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/report-folders"), + &Vec::new(), + ) + .await?; + output::print_list( + ctx.json, + &res, + &[("ID", "id"), ("NAME", "name"), ("REPORTS", "reportsCount")], + ); + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/scim.rs b/rust/cube-cli/src/commands/scim.rs new file mode 100644 index 0000000000000..2a9607b95bea3 --- /dev/null +++ b/rust/cube-cli/src/commands/scim.rs @@ -0,0 +1,154 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// SCIM v2 users + Users { + #[command(subcommand)] + cmd: ResourceCmd, + }, + /// SCIM v2 groups + Groups { + #[command(subcommand)] + cmd: ResourceCmd, + }, + /// Show SCIM resource types + ResourceTypes, + /// Show SCIM schemas + Schemas, + /// Show the SCIM service provider config + ServiceProviderConfig, +} + +#[derive(Subcommand)] +enum ResourceCmd { + /// List resources + #[command(alias = "ls")] + List { + /// SCIM filter expression + #[arg(long)] + filter: Option, + /// Start index + #[arg(long)] + start_index: Option, + /// Count + #[arg(long)] + count: Option, + }, + /// Show a resource + Get { + /// Resource id + id: String, + }, + /// Create a resource (SCIM JSON body) + Create { + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: String, + }, + /// Patch a resource (SCIM PatchOp JSON body) + Patch { + /// Resource id + id: String, + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: String, + }, + /// Replace a resource (SCIM JSON body) + Replace { + /// Resource id + id: String, + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: String, + }, + /// Delete a resource + #[command(alias = "rm")] + Delete { + /// Resource id + id: String, + }, +} + +async fn resource(base: &str, cmd: ResourceCmd, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match cmd { + ResourceCmd::List { + filter, + start_index, + count, + } => { + let mut query = Vec::new(); + util::push(&mut query, "filter", &filter); + util::push(&mut query, "startIndex", &start_index); + util::push(&mut query, "count", &count); + let res = api.get(base, &query).await?; + output::print_json(&res); + } + ResourceCmd::Get { id } => { + let res = api.get(&format!("{base}/{id}"), &Vec::new()).await?; + output::print_json(&res); + } + ResourceCmd::Create { data } => { + let body = util::parse_data(Some(&data))?; + let res = api.post(base, Some(&util::body(body))).await?; + output::print_json(&res); + } + ResourceCmd::Patch { id, data } => { + let body = util::parse_data(Some(&data))?; + let res = api + .patch(&format!("{base}/{id}"), Some(&util::body(body))) + .await?; + output::print_json(&res); + } + ResourceCmd::Replace { id, data } => { + let body = util::parse_data(Some(&data))?; + let res = api + .put(&format!("{base}/{id}"), Some(&util::body(body))) + .await?; + output::print_json(&res); + } + ResourceCmd::Delete { id } => { + api.delete(&format!("{base}/{id}"), None).await?; + output::success(&format!("Deleted {id}")); + } + } + Ok(()) +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + match args.cmd { + Cmd::Users { cmd } => resource("/api/scim/v2/Users", cmd, ctx).await, + Cmd::Groups { cmd } => resource("/api/scim/v2/Groups", cmd, ctx).await, + Cmd::ResourceTypes => { + let res = ctx + .api()? + .get("/api/scim/v2/ResourceTypes", &Vec::new()) + .await?; + output::print_json(&res); + Ok(()) + } + Cmd::Schemas => { + let res = ctx.api()?.get("/api/scim/v2/Schemas", &Vec::new()).await?; + output::print_json(&res); + Ok(()) + } + Cmd::ServiceProviderConfig => { + let res = ctx + .api()? + .get("/api/scim/v2/ServiceProviderConfig", &Vec::new()) + .await?; + output::print_json(&res); + Ok(()) + } + } +} diff --git a/rust/cube-cli/src/commands/tenant.rs b/rust/cube-cli/src/commands/tenant.rs new file mode 100644 index 0000000000000..ffea7eaefa169 --- /dev/null +++ b/rust/cube-cli/src/commands/tenant.rs @@ -0,0 +1,40 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Show tenant settings + Settings, + /// Update tenant settings (TenantSettingsInput as JSON) + Update { + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: String, + }, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::Settings => { + let res = api.get("/api/v1/tenant/settings", &Vec::new()).await?; + output::print_json(&res); + } + Cmd::Update { data } => { + let body = util::parse_data(Some(&data))?; + let res = api + .put("/api/v1/tenant/settings", Some(&util::body(body))) + .await?; + output::print_json(&res); + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/users.rs b/rust/cube-cli/src/commands/users.rs new file mode 100644 index 0000000000000..1f783702d3906 --- /dev/null +++ b/rust/cube-cli/src/commands/users.rs @@ -0,0 +1,90 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List users + #[command(alias = "ls")] + List { + /// Page size (cursor pagination) + #[arg(long)] + first: Option, + /// Cursor for the next page (from a previous pageInfo.endCursor) + #[arg(long)] + after: Option, + }, + /// Show the current user + Me, + /// Create a user (UserCreateInput as JSON, admin only) + Create { + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: String, + }, + /// Update a user (UserUpdateInput as JSON, admin only) + Update { + /// User id + user: i64, + /// Request body as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: String, + }, + /// Delete a user (admin only) + #[command(alias = "rm")] + Delete { + /// User id + user: i64, + }, + /// Show the embed theme for the current user + EmbedTheme, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List { first, after } => { + let mut query = Vec::new(); + util::push(&mut query, "first", &first); + util::push(&mut query, "after", &after); + let res = api.get("/api/v1/users/", &query).await?; + output::print_list( + ctx.json, + &res, + &[("ID", "id"), ("EMAIL", "email"), ("NAME", "firstName")], + ); + } + Cmd::Me => { + let res = api.get("/api/v1/users/me", &Vec::new()).await?; + output::print_json(&res); + } + Cmd::Create { data } => { + let body = util::parse_data(Some(&data))?; + let res = api.post("/api/v1/users/", Some(&util::body(body))).await?; + output::print_json(&res); + } + Cmd::Update { user, data } => { + let body = util::parse_data(Some(&data))?; + let res = api + .put(&format!("/api/v1/users/{user}"), Some(&util::body(body))) + .await?; + output::print_json(&res); + } + Cmd::Delete { user } => { + api.delete(&format!("/api/v1/users/{user}"), None).await?; + output::success(&format!("Deleted user {user}")); + } + Cmd::EmbedTheme => { + let res = api.get("/api/v1/users/embed-theme", &Vec::new()).await?; + output::print_json(&res); + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/variables.rs b/rust/cube-cli/src/commands/variables.rs new file mode 100644 index 0000000000000..d98ddc26baff5 --- /dev/null +++ b/rust/cube-cli/src/commands/variables.rs @@ -0,0 +1,62 @@ +use anyhow::Result; +use clap::Subcommand; +use serde_json::json; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List environment variables (secret values are masked by the API) + #[command(alias = "ls")] + List { + /// Deployment id + deployment: i64, + }, + /// Upsert environment variables; omitted variables keep their values + Set { + /// Deployment id + deployment: i64, + /// Variables as KEY=VALUE pairs + #[arg(value_parser = util::parse_kv, required = true)] + vars: Vec<(String, String)>, + }, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List { deployment } => { + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/env-vars"), + &Vec::new(), + ) + .await?; + output::print_list(ctx.json, &res, &[("NAME", "name"), ("VALUE", "value")]); + } + Cmd::Set { deployment, vars } => { + let env_variables: Vec<_> = vars + .iter() + .map(|(name, value)| json!({ "name": name, "value": value })) + .collect(); + let res = api + .put( + &format!("/api/v1/deployments/{deployment}/env-vars"), + Some(&json!({ "env_variables": env_variables })), + ) + .await?; + if ctx.json { + output::print_json(&res); + } else { + output::success(&format!("Set {} variable(s)", vars.len())); + } + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/whoami.rs b/rust/cube-cli/src/commands/whoami.rs new file mode 100644 index 0000000000000..e69314973ac34 --- /dev/null +++ b/rust/cube-cli/src/commands/whoami.rs @@ -0,0 +1,24 @@ +use anyhow::Result; + +use crate::{output, Ctx}; + +#[derive(clap::Args)] +pub struct Args {} + +pub async fn command(_args: Args, ctx: &Ctx) -> Result<()> { + let me = ctx.api()?.get("/api/v1/users/me", &Vec::new()).await?; + if ctx.json { + output::print_json(&me); + return Ok(()); + } + println!( + "{} ({})", + output::field(&me, "email"), + output::field(&me, "username") + ); + println!("id: {}", output::field(&me, "id")); + if output::field(&me, "isAdmin") == "true" { + println!("role: admin"); + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/workbooks.rs b/rust/cube-cli/src/commands/workbooks.rs new file mode 100644 index 0000000000000..1441c5decc62f --- /dev/null +++ b/rust/cube-cli/src/commands/workbooks.rs @@ -0,0 +1,288 @@ +use anyhow::Result; +use clap::Subcommand; +use serde_json::json; + +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List workbooks + #[command(alias = "ls")] + List { + /// Deployment id + deployment: i64, + /// Folder id + #[arg(long)] + folder: Option, + /// Page size (cursor pagination) + #[arg(long)] + first: Option, + /// Cursor for the next page (from a previous pageInfo.endCursor) + #[arg(long)] + after: Option, + }, + /// Show a workbook + Get { + /// Deployment id + deployment: i64, + /// Workbook id + workbook: i64, + }, + /// Create a workbook + Create { + /// Deployment id + deployment: i64, + /// Name + #[arg(long)] + name: Option, + /// Folder id + #[arg(long)] + folder: Option, + /// Full CreateWorkbookInput as JSON (inline, @file, or -) + #[arg(long, short = 'd')] + data: Option, + }, + /// Update a workbook (rename, move, metadata, slug) + Update { + /// Deployment id + deployment: i64, + /// Workbook id + workbook: i64, + /// Name + #[arg(long)] + name: Option, + /// Destination folder id; pass 0 to move to the workspace root + #[arg(long)] + folder: Option, + /// Slug + #[arg(long)] + slug: Option, + /// Full UpdateWorkbookInput as JSON (inline, @file, or -) + #[arg(long, short = 'd')] + data: Option, + }, + /// Delete a workbook + #[command(alias = "rm")] + Delete { + /// Deployment id + deployment: i64, + /// Workbook id + workbook: i64, + }, + /// Clone a workbook including reports and published dashboard + Duplicate { + /// Deployment id + deployment: i64, + /// Workbook id + workbook: i64, + /// Clone from the shared workspace (creator-mode embed sessions) + #[arg(long)] + shared: bool, + }, + /// Publish a workbook's dashboard + Publish { + /// Deployment id + deployment: i64, + /// Workbook id + workbook: i64, + /// PublishDashboardInput as JSON; workbookId is filled in automatically + #[arg(long, short = 'd')] + data: Option, + }, + /// Update a workbook's dashboard draft + Dashboard { + /// Deployment id + deployment: i64, + /// Workbook id + workbook: i64, + /// WorkbookDashboardInput as JSON (inline, @file, or -) + #[arg(long, short = 'd')] + data: String, + }, + /// Attach an AI widget thread to a published dashboard + AiThread { + /// Deployment id + deployment: i64, + /// Workbook id + workbook: i64, + /// Widget id + #[arg(long)] + widget_id: String, + /// Thread id + #[arg(long)] + thread_id: String, + }, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List { + deployment, + folder, + first, + after, + } => { + let mut query = Vec::new(); + util::push(&mut query, "folderId", &folder); + util::push(&mut query, "first", &first); + util::push(&mut query, "after", &after); + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/workbooks"), + &query, + ) + .await?; + output::print_list( + ctx.json, + &res, + &[ + ("ID", "id"), + ("NAME", "name"), + ("FOLDER", "folderId"), + ("OWNER", "user.email"), + ("UPDATED", "updatedAt"), + ], + ); + } + Cmd::Get { + deployment, + workbook, + } => { + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/workbooks/{workbook}"), + &Vec::new(), + ) + .await?; + output::print_json(&res); + } + Cmd::Create { + deployment, + name, + folder, + data, + } => { + let mut body = util::parse_data(data.as_deref())?; + util::set(&mut body, "name", &name); + util::set(&mut body, "folderId", &folder); + let res = api + .post( + &format!("/api/v1/deployments/{deployment}/workbooks"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + Cmd::Update { + deployment, + workbook, + name, + folder, + slug, + data, + } => { + let mut body = util::parse_data(data.as_deref())?; + util::set(&mut body, "name", &name); + util::set(&mut body, "slug", &slug); + if let Some(folder) = folder { + body.insert( + "folderId".to_string(), + if folder == 0 { + serde_json::Value::Null + } else { + json!(folder) + }, + ); + } + let res = api + .put( + &format!("/api/v1/deployments/{deployment}/workbooks/{workbook}"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + Cmd::Delete { + deployment, + workbook, + } => { + api.delete( + &format!("/api/v1/deployments/{deployment}/workbooks/{workbook}"), + None, + ) + .await?; + output::success(&format!("Deleted workbook {workbook}")); + } + Cmd::Duplicate { + deployment, + workbook, + shared, + } => { + let body = if shared { + Some(json!({ "shared": true })) + } else { + None + }; + let res = api + .post( + &format!("/api/v1/deployments/{deployment}/workbooks/{workbook}/duplicate"), + body.as_ref(), + ) + .await?; + output::print_json(&res); + } + Cmd::Publish { + deployment, + workbook, + data, + } => { + let mut body = util::parse_data(data.as_deref())?; + body.insert("workbookId".to_string(), json!(workbook)); + let res = api + .post( + &format!("/api/v1/deployments/{deployment}/workbooks/{workbook}/publish"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + Cmd::Dashboard { + deployment, + workbook, + data, + } => { + let body = util::parse_data(Some(&data))?; + let res = api + .put( + &format!("/api/v1/deployments/{deployment}/workbooks/{workbook}/dashboard"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + Cmd::AiThread { + deployment, + workbook, + widget_id, + thread_id, + } => { + let res = api + .post( + &format!( + "/api/v1/deployments/{deployment}/workbooks/{workbook}/dashboard/ai-widget-thread" + ), + Some(&json!({ "widgetId": widget_id, "threadId": thread_id })), + ) + .await?; + output::print_json(&res); + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/workspace.rs b/rust/cube-cli/src/commands/workspace.rs new file mode 100644 index 0000000000000..88a677958024a --- /dev/null +++ b/rust/cube-cli/src/commands/workspace.rs @@ -0,0 +1,136 @@ +use anyhow::Result; +use clap::Subcommand; +use serde_json::json; + +use crate::client::Query; +use crate::{output, util, Ctx}; + +#[derive(clap::Args)] +pub struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(clap::Args)] +struct ListFlags { + /// List the contents of this folder (omit for the root) + #[arg(long)] + folder: Option, + /// Restrict to item types (repeatable): FOLDER, WORKBOOK, REPORT + #[arg(long = "type")] + types: Vec, + /// Case-insensitive substring match on item names + #[arg(long)] + search: Option, + /// Sort field: updated_at, created_at, name, viewer_last_viewed_at + #[arg(long)] + order_by: Option, + /// ASC or DESC + #[arg(long)] + direction: Option, + /// Page size (cursor pagination) + #[arg(long)] + first: Option, + /// Cursor for the next page (from a previous pageInfo.endCursor) + #[arg(long)] + after: Option, +} + +#[derive(Subcommand)] +enum Cmd { + /// List workspace items (folders, workbooks, reports) + #[command(alias = "ls")] + List { + /// Deployment id + deployment: i64, + #[command(flatten)] + flags: ListFlags, + }, + /// List items shared with embed users + Shared { + /// Deployment id + deployment: i64, + #[command(flatten)] + flags: ListFlags, + }, + /// Move a workbook, report, or folder into a folder + Move { + /// Deployment id + deployment: i64, + /// Item type: WORKBOOK, REPORT, or FOLDER + #[arg(long = "type")] + item_type: String, + /// Id of the item to move + #[arg(long)] + id: i64, + /// Destination folder id (omit to move to the workspace root) + #[arg(long)] + folder: Option, + }, +} + +fn list_query(flags: &ListFlags) -> Query { + let mut query = Vec::new(); + util::push(&mut query, "folderId", &flags.folder); + for t in &flags.types { + query.push(("types".to_string(), t.clone())); + } + util::push(&mut query, "search", &flags.search); + util::push(&mut query, "orderByField", &flags.order_by); + util::push(&mut query, "orderByDirection", &flags.direction); + util::push(&mut query, "first", &flags.first); + util::push(&mut query, "after", &flags.after); + query +} + +const COLUMNS: &[(&str, &str)] = &[ + ("ID", "id"), + ("TYPE", "type"), + ("NAME", "name"), + ("FOLDER", "folderId"), + ("UPDATED", "updatedAt"), +]; + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List { deployment, flags } => { + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/workspace"), + &list_query(&flags), + ) + .await?; + output::print_list(ctx.json, &res, COLUMNS); + } + Cmd::Shared { deployment, flags } => { + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/shared-workspace"), + &list_query(&flags), + ) + .await?; + output::print_list(ctx.json, &res, COLUMNS); + } + Cmd::Move { + deployment, + item_type, + id, + folder, + } => { + let body = json!({ + "type": item_type, + "id": id, + "folderId": folder, + }); + let res = api + .post( + &format!("/api/v1/deployments/{deployment}/workspace/move"), + Some(&body), + ) + .await?; + output::print_json(&res); + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/config.rs b/rust/cube-cli/src/config.rs new file mode 100644 index 0000000000000..db09dcaced181 --- /dev/null +++ b/rust/cube-cli/src/config.rs @@ -0,0 +1,91 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +use anyhow::{Context as _, Result}; +use etcetera::{choose_base_strategy, BaseStrategy}; +use serde::{Deserialize, Serialize}; + +/// A named connection to a Cube Cloud tenant. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextConfig { + pub url: String, + /// Bearer token used for API calls: an API key, or an OAuth access token. + pub api_key: String, + /// OAuth refresh token, when the context was created via `cube login` + /// device flow. Absent for API-key contexts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, +} + +/// On-disk CLI configuration. +/// +/// Stored as TOML in the platform config directory: +/// - Linux/macOS: `~/.config/cube/config.toml` (XDG) +/// - Windows: `%APPDATA%\cube\config.toml` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Config { + #[serde(skip_serializing_if = "Option::is_none")] + pub default_context: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub contexts: BTreeMap, +} + +pub fn config_path() -> Result { + let strategy = choose_base_strategy().context("could not determine config directory")?; + Ok(strategy.config_dir().join("cube").join("config.toml")) +} + +impl Config { + pub fn load() -> Result { + let path = config_path()?; + if !path.exists() { + return Ok(Self::default()); + } + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display())) + } + + pub fn save(&self) -> Result<()> { + let path = config_path()?; + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700)); + } + } + let raw = toml::to_string_pretty(self)?; + + // Write to a sibling temp file created with restrictive permissions up + // front (no world-readable window), then atomically rename into place so + // a crash mid-write can't truncate an existing token file. + let tmp = path.with_extension("toml.tmp"); + { + let mut opts = fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut file = opts + .open(&tmp) + .with_context(|| format!("failed to create {}", tmp.display()))?; + use std::io::Write as _; + file.write_all(raw.as_bytes())?; + file.sync_all()?; + } + fs::rename(&tmp, &path).with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) + } + + pub fn context(&self, name: Option<&str>) -> Option<(&str, &ContextConfig)> { + let name = name.or(self.default_context.as_deref())?; + self.contexts + .get_key_value(name) + .map(|(k, c)| (k.as_str(), c)) + } +} diff --git a/rust/cube-cli/src/main.rs b/rust/cube-cli/src/main.rs new file mode 100644 index 0000000000000..2bc53b22c3786 --- /dev/null +++ b/rust/cube-cli/src/main.rs @@ -0,0 +1,237 @@ +mod client; +mod commands; +mod config; +mod oauth; +mod output; +mod util; + +use anyhow::{bail, Result}; +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command( + name = "cube", + version, + about = "Cube Cloud command line interface", + propagate_version = true +)] +struct Cli { + #[command(flatten)] + global: GlobalArgs, + #[command(subcommand)] + command: Command, +} + +#[derive(clap::Args, Clone)] +pub struct GlobalArgs { + /// Output raw JSON instead of tables + #[arg(long, global = true)] + json: bool, + /// Cube Cloud API base URL, e.g. https://.cubecloud.dev + #[arg(long, global = true, env = "CUBE_API_URL")] + api_url: Option, + /// API token (API key, JWT, or OAuth access token) + #[arg(long, global = true, env = "CUBE_API_KEY", hide_env_values = true)] + token: Option, + /// Named context from the config file to use + #[arg(long, global = true)] + context: Option, +} + +/// Shared state passed to every command. +pub struct Ctx { + pub json: bool, + pub config: config::Config, + api_url: Option, + token: Option, + context: Option, +} + +impl Ctx { + fn new(global: &GlobalArgs) -> Result { + Ok(Self { + json: global.json, + config: config::Config::load()?, + api_url: global.api_url.clone(), + token: global.token.clone(), + context: global.context.clone(), + }) + } + + /// Build an authenticated API client from flags, env, or the config file. + pub fn api(&self) -> Result { + let ctx = self.config.context(self.context.as_deref()); + if let Some(name) = &self.context { + if ctx.is_none() { + bail!("context `{name}` not found in config (run `cube login --context {name}`)"); + } + } + let url = self + .api_url + .clone() + .or_else(|| ctx.map(|(_, c)| c.url.clone())); + // An explicit --token / CUBE_API_KEY wins and disables auto-refresh + // (it isn't tied to a stored refresh token). + let token = self + .token + .clone() + .or_else(|| ctx.map(|(_, c)| c.api_key.clone())); + match (url, token) { + (Some(url), Some(token)) => { + // Enable auto-refresh only when using the context's own access + // token and it has a refresh token saved alongside it. + let refresh = if self.token.is_none() { + ctx.and_then(|(name, c)| { + c.refresh_token + .as_ref() + .map(|rt| (rt.clone(), name.to_string())) + }) + } else { + None + }; + match refresh { + Some((rt, name)) => client::Client::with_refresh(&url, &token, &rt, Some(name)), + None => client::Client::new(&url, &token), + } + } + _ => bail!( + "not logged in: run `cube login`, or set CUBE_API_URL and CUBE_API_KEY \ + (or pass --api-url/--token)" + ), + } + } +} + +#[derive(Subcommand)] +enum Command { + /// Log in to Cube Cloud and save credentials + Login(commands::login::Args), + /// Remove saved credentials + Logout(commands::logout::Args), + /// Show the currently authenticated user + Whoami(commands::whoami::Args), + /// Manage saved contexts (tenants) + Context(commands::context::Args), + + /// Manage deployments + #[command(alias = "deployment")] + Deployments(commands::deployments::Args), + /// List available deployment regions + #[command(alias = "region")] + Regions(commands::regions::Args), + /// Manage a deployment's data model files + #[command(name = "data-model", alias = "dm")] + DataModel(commands::data_model::Args), + /// Manage deployment environments and environment tokens + #[command(alias = "environment", alias = "envs")] + Environments(commands::environments::Args), + /// Manage deployment environment variables + #[command(alias = "vars", alias = "variable")] + Variables(commands::variables::Args), + + /// Manage workspace folders + #[command(alias = "folder")] + Folders(commands::folders::Args), + /// Manage workbooks and dashboards + #[command(alias = "workbook")] + Workbooks(commands::workbooks::Args), + /// Manage reports + #[command(alias = "report")] + Reports(commands::reports::Args), + /// Browse and organize the deployment workspace + Workspace(commands::workspace::Args), + /// Manage scheduled notifications and their recipients + #[command(alias = "notification")] + Notifications(commands::notifications::Args), + + /// Manage users + #[command(alias = "user")] + Users(commands::users::Args), + /// Manage user groups + #[command(alias = "group")] + Groups(commands::groups::Args), + /// Manage user attributes and their values + #[command(alias = "attribute")] + Attributes(commands::attributes::Args), + /// Manage resource access policies + #[command(alias = "policy")] + Policies(commands::policies::Args), + /// View and update tenant settings + Tenant(commands::tenant::Args), + + /// Embed sessions, tokens, dashboards, and embed tenants + Embed(commands::embed::Args), + /// Manage OAuth integrations and user OAuth tokens + #[command(alias = "integration", alias = "oauth")] + Integrations(commands::integrations::Args), + /// Manage OIDC token configs + Oidc(commands::oidc::Args), + + /// List agents and agent skills + #[command(alias = "agent")] + Agents(commands::agents::Args), + /// AI Engineer settings and active region + AiEngineer(commands::ai_engineer::Args), + /// App-level config and theme + App(commands::app::Args), + /// Fetch data-model metadata + Meta(commands::meta::Args), + /// SCIM v2 user and group provisioning + Scim(commands::scim::Args), + + /// Make an authenticated raw API request (escape hatch) + Api(commands::api::Args), + /// Generate shell completions + Completion(commands::completion::Args), +} + +/// Expose the clap command tree for `cube completion`. +pub fn cli_command() -> clap::Command { + use clap::CommandFactory; + Cli::command() +} + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + if let Err(err) = run(cli).await { + eprintln!("error: {err:#}"); + std::process::exit(1); + } +} + +async fn run(cli: Cli) -> Result<()> { + let mut ctx = Ctx::new(&cli.global)?; + use Command::*; + match cli.command { + Login(args) => commands::login::command(args, &mut ctx).await, + Logout(args) => commands::logout::command(args, &mut ctx).await, + Whoami(args) => commands::whoami::command(args, &ctx).await, + Context(args) => commands::context::command(args, &mut ctx).await, + Deployments(args) => commands::deployments::command(args, &ctx).await, + Regions(args) => commands::regions::command(args, &ctx).await, + DataModel(args) => commands::data_model::command(args, &ctx).await, + Environments(args) => commands::environments::command(args, &ctx).await, + Variables(args) => commands::variables::command(args, &ctx).await, + Folders(args) => commands::folders::command(args, &ctx).await, + Workbooks(args) => commands::workbooks::command(args, &ctx).await, + Reports(args) => commands::reports::command(args, &ctx).await, + Workspace(args) => commands::workspace::command(args, &ctx).await, + Notifications(args) => commands::notifications::command(args, &ctx).await, + Users(args) => commands::users::command(args, &ctx).await, + Groups(args) => commands::groups::command(args, &ctx).await, + Attributes(args) => commands::attributes::command(args, &ctx).await, + Policies(args) => commands::policies::command(args, &ctx).await, + Tenant(args) => commands::tenant::command(args, &ctx).await, + Embed(args) => commands::embed::command(args, &ctx).await, + Integrations(args) => commands::integrations::command(args, &ctx).await, + Oidc(args) => commands::oidc::command(args, &ctx).await, + Agents(args) => commands::agents::command(args, &ctx).await, + AiEngineer(args) => commands::ai_engineer::command(args, &ctx).await, + App(args) => commands::app::command(args, &ctx).await, + Meta(args) => commands::meta::command(args, &ctx).await, + Scim(args) => commands::scim::command(args, &ctx).await, + Api(args) => commands::api::command(args, &ctx).await, + Completion(args) => commands::completion::command(args), + } +} diff --git a/rust/cube-cli/src/oauth.rs b/rust/cube-cli/src/oauth.rs new file mode 100644 index 0000000000000..4a745e7a48ad9 --- /dev/null +++ b/rust/cube-cli/src/oauth.rs @@ -0,0 +1,221 @@ +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, bail, Result}; +use serde::Deserialize; + +/// OAuth 2.0 Device Authorization Grant (RFC 8628). +/// +/// Cube Cloud exposes a confidential authorization-code + device server under +/// `/auth/oauth2`. The device-authorization and token endpoints, the CLI +/// `client_id`, and the optional `client_secret` are the only deployment- +/// specific knobs — everything else is standards-compliant. +/// +/// Endpoints implemented by the console-server `DeviceOAuthController` +/// (base `/auth/device`). The `cube-cli` client is public (empty secret), +/// so no `client_secret` is sent. Overridable at runtime with `CUBE_OAUTH_*` +/// env vars for non-default deployments. +const DEVICE_CODE_PATH: &str = "/auth/device/code"; +const TOKEN_PATH: &str = "/auth/device/token"; +const REFRESH_PATH: &str = "/auth/oauth2/refresh"; +const DEFAULT_CLIENT_ID: &str = "cube-cli"; +/// Empty scope lets the server default to all OAUTH_SCOPES. +const DEFAULT_SCOPE: &str = ""; +const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code"; + +pub struct OAuthConfig { + pub client_id: String, + pub client_secret: Option, + pub scope: String, +} + +impl OAuthConfig { + pub fn from_env() -> Self { + Self { + client_id: std::env::var("CUBE_OAUTH_CLIENT_ID") + .unwrap_or_else(|_| DEFAULT_CLIENT_ID.to_string()), + client_secret: std::env::var("CUBE_OAUTH_CLIENT_SECRET").ok(), + scope: std::env::var("CUBE_OAUTH_SCOPE").unwrap_or_else(|_| DEFAULT_SCOPE.to_string()), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct DeviceAuthorization { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + #[serde(default)] + pub verification_uri_complete: Option, + #[serde(default = "default_expires_in")] + pub expires_in: u64, + #[serde(default = "default_interval")] + pub interval: u64, +} + +fn default_expires_in() -> u64 { + 900 +} +fn default_interval() -> u64 { + 5 +} + +/// Token response from `POST /auth/device/token`. The controller returns +/// camelCase `accessToken`/`refreshToken` (with expiry timestamps, scope, and +/// tenantUrl); aliases keep it tolerant of the RFC 8628 snake_case spelling. +#[derive(Debug, Deserialize)] +pub struct TokenResponse { + #[serde(alias = "accessToken")] + pub access_token: String, + #[serde(default, alias = "refreshToken")] + pub refresh_token: Option, +} + +#[derive(Debug, Deserialize)] +struct TokenError { + error: String, + #[serde(default)] + error_description: Option, +} + +fn base(url: &str) -> String { + url.trim_end_matches('/').to_string() +} + +/// Step 1 — request a device + user code. +pub async fn request_device_code( + http: &reqwest::Client, + url: &str, + cfg: &OAuthConfig, +) -> Result { + let endpoint = format!("{}{}", base(url), DEVICE_CODE_PATH); + let mut form = vec![("client_id", cfg.client_id.as_str())]; + if !cfg.scope.is_empty() { + form.push(("scope", cfg.scope.as_str())); + } + if let Some(secret) = &cfg.client_secret { + form.push(("client_secret", secret)); + } + let res = http.post(&endpoint).form(&form).send().await?; + let status = res.status(); + let text = res.text().await.unwrap_or_default(); + if !status.is_success() { + bail!( + "device authorization request failed ({status}) at {endpoint}: {}", + text.trim() + ); + } + serde_json::from_str(&text) + .map_err(|e| anyhow!("could not parse device authorization response: {e}\n{text}")) +} + +/// Step 3 — poll the token endpoint until the user approves (or it fails). +pub async fn poll_for_token( + http: &reqwest::Client, + url: &str, + cfg: &OAuthConfig, + device: &DeviceAuthorization, +) -> Result { + let endpoint = format!("{}{}", base(url), TOKEN_PATH); + let deadline = Instant::now() + Duration::from_secs(device.expires_in); + let mut interval = device.interval.max(1); + + loop { + if Instant::now() >= deadline { + bail!("device code expired before it was authorized; run `cube login` again"); + } + tokio::time::sleep(Duration::from_secs(interval)).await; + + let mut form = vec![ + ("grant_type", DEVICE_CODE_GRANT), + ("device_code", device.device_code.as_str()), + ("client_id", cfg.client_id.as_str()), + ]; + if let Some(secret) = &cfg.client_secret { + form.push(("client_secret", secret)); + } + let res = http.post(&endpoint).form(&form).send().await?; + let status = res.status(); + let text = res.text().await.unwrap_or_default(); + + if status.is_success() { + return serde_json::from_str(&text) + .map_err(|e| anyhow!("could not parse token response: {e}\n{text}")); + } + + // RFC 8628 §3.5: pending/slow_down keep polling; anything else is fatal. + match serde_json::from_str::(&text) { + Ok(err) => match err.error.as_str() { + "authorization_pending" => continue, + "slow_down" => { + interval += 5; + continue; + } + "access_denied" => bail!("authorization was denied in the browser"), + "expired_token" => { + bail!("device code expired before it was authorized; run `cube login` again") + } + other => bail!( + "authorization failed: {other}{}", + err.error_description + .map(|d| format!(" ({d})")) + .unwrap_or_default() + ), + }, + Err(_) => bail!( + "token poll failed ({status}) at {endpoint}: {}", + text.trim() + ), + } + } +} + +/// Exchange a refresh token for a new access/refresh token pair +/// (OAuth 2.0 refresh_token grant). Used transparently by the API client +/// when an access token has expired. +pub async fn refresh( + http: &reqwest::Client, + url: &str, + cfg: &OAuthConfig, + refresh_token: &str, +) -> Result { + let endpoint = format!("{}{}", base(url), REFRESH_PATH); + let mut form = vec![ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", cfg.client_id.as_str()), + ]; + if let Some(secret) = &cfg.client_secret { + form.push(("client_secret", secret)); + } + let res = http.post(&endpoint).form(&form).send().await?; + let status = res.status(); + let text = res.text().await.unwrap_or_default(); + if !status.is_success() { + bail!("token refresh failed ({status}): {}", text.trim()); + } + serde_json::from_str(&text) + .map_err(|e| anyhow!("could not parse refresh response: {e}\n{text}")) +} + +/// Best-effort attempt to open a URL in the user's browser (no extra deps). +pub fn open_browser(url: &str) -> bool { + let candidates: &[(&str, &[&str])] = if cfg!(target_os = "macos") { + &[("open", &[])] + } else if cfg!(target_os = "windows") { + // Not `cmd /C start`: cmd.exe parses the URL and treats `&` as a + // command separator, mangling query strings. rundll32 passes it through. + &[("rundll32", &["url.dll,FileProtocolHandler"])] + } else { + &[("xdg-open", &[]), ("gio", &["open"]), ("wslview", &[])] + }; + for (cmd, prefix) in candidates { + let mut c = std::process::Command::new(cmd); + c.args(prefix.iter()).arg(url); + c.stdout(std::process::Stdio::null()); + c.stderr(std::process::Stdio::null()); + if c.spawn().is_ok() { + return true; + } + } + false +} diff --git a/rust/cube-cli/src/output.rs b/rust/cube-cli/src/output.rs new file mode 100644 index 0000000000000..5735f44ebaaeb --- /dev/null +++ b/rust/cube-cli/src/output.rs @@ -0,0 +1,97 @@ +use owo_colors::OwoColorize; +use serde_json::Value; + +/// Pretty-print a JSON value to stdout. +pub fn print_json(value: &Value) { + println!( + "{}", + serde_json::to_string_pretty(value).unwrap_or_default() + ); +} + +/// Extract the list payload from a response. The public API wraps lists as +/// `{items: [...]}` and/or `{data: [...]}`; older endpoints return bare arrays. +pub fn items(value: &Value) -> Vec { + for key in ["items", "data"] { + if let Some(arr) = value.get(key).and_then(Value::as_array) { + return arr.clone(); + } + } + match value { + Value::Array(arr) => arr.clone(), + Value::Null => vec![], + other => vec![other.clone()], + } +} + +/// Render a value as a table cell. +fn stringify(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// Look up a (possibly dotted) field on an object and stringify it. +pub fn field(obj: &Value, path: &str) -> String { + let mut cur = obj; + for part in path.split('.') { + match cur.get(part) { + Some(v) => cur = v, + None => return String::new(), + } + } + stringify(cur) +} + +/// Print a simple aligned table. +pub fn table(headers: &[&str], rows: Vec>) { + if rows.is_empty() { + eprintln!("{}", "No results".dimmed()); + return; + } + let mut widths: Vec = headers.iter().map(|h| h.len()).collect(); + for row in &rows { + for (i, cell) in row.iter().enumerate() { + if i < widths.len() { + widths[i] = widths[i].max(cell.chars().count()); + } + } + } + let header_line = headers + .iter() + .enumerate() + .map(|(i, h)| format!("{:>() + .join(" "); + println!("{}", header_line.bold()); + for row in rows { + let line = row + .iter() + .enumerate() + .map(|(i, c)| format!("{:>() + .join(" "); + println!("{}", line.trim_end()); + } +} + +/// Print a list response: raw JSON in `--json` mode, otherwise a table with +/// the given columns (header, field path). +pub fn print_list(json: bool, response: &Value, columns: &[(&str, &str)]) { + if json { + print_json(response); + return; + } + let headers: Vec<&str> = columns.iter().map(|(h, _)| *h).collect(); + let rows = items(response) + .iter() + .map(|item| columns.iter().map(|(_, f)| field(item, f)).collect()) + .collect(); + table(&headers, rows); +} + +pub fn success(message: &str) { + println!("{} {}", "✓".green(), message); +} diff --git a/rust/cube-cli/src/util.rs b/rust/cube-cli/src/util.rs new file mode 100644 index 0000000000000..ffc346b8d4b52 --- /dev/null +++ b/rust/cube-cli/src/util.rs @@ -0,0 +1,92 @@ +use std::io::Read; + +use anyhow::{bail, Context as _, Result}; +use serde_json::{Map, Value}; + +use crate::client::Query; + +/// Parse a `--data` argument into a JSON object. +/// +/// Accepts inline JSON (`'{"name": "x"}'`), `@path/to/file.json`, or `-` +/// to read from stdin — the same convention as `gh api` / `curl -d`. +pub fn parse_data(data: Option<&str>) -> Result> { + let Some(data) = data else { + return Ok(Map::new()); + }; + let raw = if data == "-" { + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + buf + } else if let Some(path) = data.strip_prefix('@') { + std::fs::read_to_string(path).with_context(|| format!("failed to read {path}"))? + } else { + data.to_string() + }; + let value: Value = serde_json::from_str(&raw).context("--data is not valid JSON")?; + match value { + Value::Object(map) => Ok(map), + _ => bail!("--data must be a JSON object"), + } +} + +/// Insert a flag value into a JSON body if it was provided on the CLI. +pub fn set(body: &mut Map, key: &str, value: &Option) { + if let Some(v) = value { + body.insert(key.to_string(), serde_json::to_value(v).unwrap()); + } +} + +/// Push a query parameter if the flag was provided. +pub fn push(query: &mut Query, key: &str, value: &Option) { + if let Some(v) = value { + query.push((key.to_string(), v.to_string())); + } +} + +/// Parse a `KEY=VALUE` pair (for `cube variables set`). +pub fn parse_kv(s: &str) -> Result<(String, String), String> { + match s.split_once('=') { + Some((k, v)) if !k.is_empty() => Ok((k.to_string(), v.to_string())), + _ => Err(format!("`{s}` is not in KEY=VALUE format")), + } +} + +pub fn body(map: Map) -> Value { + Value::Object(map) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_kv_accepts_key_value_pairs() { + assert_eq!(parse_kv("A=1").unwrap(), ("A".to_string(), "1".to_string())); + assert_eq!( + parse_kv("A=b=c").unwrap(), + ("A".to_string(), "b=c".to_string()) + ); + assert!(parse_kv("no-equals").is_err()); + assert!(parse_kv("=value").is_err()); + } + + #[test] + fn parse_data_accepts_inline_json_objects_only() { + let map = parse_data(Some(r#"{"name": "x", "n": 1}"#)).unwrap(); + assert_eq!(map.get("name"), Some(&json!("x"))); + assert_eq!(map.get("n"), Some(&json!(1))); + assert!(parse_data(Some("[1, 2]")).is_err()); + assert!(parse_data(Some("not json")).is_err()); + assert!(parse_data(None).unwrap().is_empty()); + } + + #[test] + fn set_skips_missing_flags() { + let mut map = Map::new(); + set(&mut map, "present", &Some("v")); + set(&mut map, "absent", &None::); + assert_eq!(map.get("present"), Some(&json!("v"))); + assert!(!map.contains_key("absent")); + } +}