From 7c3984156d0febc5586ce5e1d5fd150970c95562 Mon Sep 17 00:00:00 2001 From: Pavel Tiunov Date: Fri, 17 Jul 2026 22:54:07 -0700 Subject: [PATCH] chore(cube-cli): native Rust CLI for the Cube Cloud public API (#11289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cube-cli): add native Rust CLI for the Cube Cloud public API New single-binary CLI (`cube`) under rust/cube-cli, structured after the Railway CLI: one module per command group, shared rustls-based HTTP client, multi-context TOML config (~/.config/cube/config.toml, 0600) with CUBE_API_URL/CUBE_API_KEY env and flag overrides. Covers every endpoint of the Console Server public API: deployments, environments and tokens, env vars, folders, workbooks, reports, workspace, notifications and recipients, users, groups, user attributes, resource policies, tenant settings, embed sessions/tenants, OAuth integrations and user tokens, OIDC token configs, agents, AI Engineer, app config/theme, meta, SCIM v2, plus a raw `cube api` escape hatch and shell completions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * feat(cube-cli): add OAuth device-flow login Implement `cube login` via the OAuth 2.0 device authorization grant (RFC 8628), Railway-CLI style: request a device/user code, print the verification URL, open the browser, and poll the token endpoint through authorization_pending/slow_down until approved. Store the resulting access and refresh tokens in the context config. Endpoints, client_id, scope, and optional client_secret are overridable via CUBE_OAUTH_* env vars. `--api-key` remains as a non-interactive fallback. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * fix(cube-cli): align device-flow login with DeviceOAuthController contract Point the device flow at the real console-server endpoints: - POST /auth/device/code (initiation) and POST /auth/device/token (poll), base /auth/device, instead of the placeholder /auth/oauth2/* paths. - Treat cube-cli as a public client: send no client_secret; let the server default the scope to all OAUTH_SCOPES when omitted. - Accept the controller's camelCase accessToken/refreshToken (with snake_case aliases for RFC 8628 tolerance). Verified end-to-end against a mock implementing the controller lifecycle (pending -> approve -> single-use mint), including using the minted bearer token on /api/v1/users/me. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * fix(cube-cli): send Content-Length: 0 on bodyless writes Bodyless POST/PUT/PATCH/DELETE requests (e.g. `deployments token`, `reports refresh`) were sent with no Content-Length header, which Google's frontend rejects with 411 Length Required. reqwest omits the header for an empty body, so set it explicitly alongside an empty body. Verified against real Cube Cloud staging: the request that previously failed with 411 now reaches the origin normally. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * feat(cube-cli): deployment create/update/delete + regions Add the deployment lifecycle commands backed by the new public REST endpoints: - deployments create (POST /api/v1/deployments) with flags for name, region, cloud-provider, target-platform, managed/unmanaged, and creation-step, plus --data for the full CreateDeploymentInput. - deployments update (PUT /api/v1/deployments/:id) and delete (DELETE /api/v1/deployments/:id). - regions (GET /api/v1/regions) to discover valid --region values. Also drop the removed ai-engineer active-region command. Verified end-to-end against Cube Cloud staging: regions -> create -> update -> delete, with a follow-up get confirming cleanup (404). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * feat(cube-cli): auto-refresh expired access tokens On a 401, the API client transparently exchanges the saved refresh token at /auth/oauth2/refresh (OAuth refresh_token grant), persists the new token pair back to the active config context, and retries the request once — so a saved login survives the 1-hour access-token TTL without re-authenticating. A dead refresh token falls back to a clear 'session expired' message. Auto-refresh is disabled when an explicit --token/CUBE_API_KEY is supplied. Verified against a mock: expired token -> refresh -> retry -> success with the rotated pair persisted, and dead-refresh-token -> clean 401. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * feat(cube-cli): deployments create --bootstrap (scaffold + build) Add --bootstrap/-b to `deployments create`, which targets the build-pod endpoint POST /build/api/v1/deployments. That path scaffolds a project and enqueues the first build, so the deployment serves immediately — unlike the base POST /api/v1/deployments, which only creates the row. Verified end-to-end against Cube Cloud staging: bootstrap-create a deployment, connect it to the demo-db ecom Postgres via env-vars, and query it live (orders.count returned real rows). Flag routing covered by a mock. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * feat(cube-cli): data-model file commands Add `cube data-model` (alias `dm`) for managing a deployment's data model files via the DataModelPublicController endpoints: - list [--content] [--branch] -> GET /build/api/v1/deployments/:id/data-model/files - get -> GET (withContent, client-side filter) - put --file/--content -> PUT create/overwrite - delete -> DELETE - rename -> POST .../rename Request bodies use { files:[{path,content}] } / { paths:[...] } / { from,to } with an optional branchName; pending confirmation against the live endpoint. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * fix(cube-cli): walk data-model file tree; branchName as query param The data-model files endpoint returns a nested tree ({data:[{path,type, content,children}]}); list/get now flatten it depth-first instead of only reading the top level, so nested files (e.g. /model/cubes/orders.yml) list and print correctly. Send branchName as a query parameter on write/rename too, matching GET. Verified against staging: list and get work end-to-end through the CLI. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * feat(cube-cli): data-model branch, dev-mode, commit, pull commands Add CLI commands for the branch workflow needed to edit a Git-backed data model (master is read-only): branches (list), create-branch (optionally entering dev mode), dev-mode / exit-dev-mode, commit (commit + push the active branch), and pull. Backed by the new /build/api/v1/deployments/:id/ branches, /dev-mode, /commit, /pull endpoints. Request DTOs pending live verification against staging. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * fix(cube-cli): dev-mode requires a branch name The POST /dev-mode endpoint rejects requests without a branch ("Branch name is required"), so make `data-model dev-mode ` a required argument instead of an option. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * fix(cube-cli): data-model delete uses files:[{path}] DTO The delete endpoint validates `files` as an array of objects, not a `paths` string array. Send [{path}] per file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * fix(cube-cli): send data-model write branch as branchName body field The write endpoints (PUT/DELETE/rename /data-model/files) resolve the target branch from a branchName body field (falling back to the active dev-mode branch), not a query param. Move --branch into the request body for writes; GET keeps branchName as a query parameter. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * docs(cube-cli): document data-model commands Verified end-to-end against staging: explored the ecom schema via an information_schema introspection cube (data-model put + query), authored real cubes (users, orders, line_items, products, product_categories, suppliers) with joins and measures, and confirmed live queries return real data (orders.count=10000, multi-hop joins across users/products). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * feat(cube-cli): data-model merge and merge-to-default Add merge (into parent branch; --squash/--switch-to-parent/--delete-branch) and merge-to-default (straight into the deploy branch; -m message, --keep-branch) backed by the new /merge and /merge-to-default endpoints. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * docs(cube-cli): describe every command argument in --help Add doc comments to all command arguments (positionals like and previously-undescribed flags), so `--help` prints a description for every argument, not just the command. Improve the generic pagination/data descriptions (first/after/offset/limit/data) and normalize doc-before-attr ordering; cargo fmt. The deployment token command already exists as `cube deployments token ` (POST /api/v1/deployments/:id/token → cubeApiToken). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * ci(cube-cli): add build/test CI and cross-platform release workflows - cube-cli.yml: on changes under rust/cube-cli, run fmt --check, clippy -D warnings, tests, and a release build. - cube-cli-release.yml: on cube-cli-v* tags (or manual dispatch), build a single static binary per platform on native runners (no cross/Docker needed thanks to rustls/musl) and publish archives + SHA256SUMS as GitHub release assets. Targets: x86_64/aarch64-unknown-linux-musl (static), x86_64/aarch64-apple- darwin, x86_64-pc-windows-msvc. Verified the musl target builds a 5.6M statically-linked single binary locally. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * ci(cube-cli): release from the main Release workflow, version follows Cube Instead of a standalone workflow + cube-cli-v* tags, build and publish the CLI from the existing Release workflow (publish.yml, on v*.*.* tags): - Add a cube-cli matrix job that builds a single static binary per platform (x86_64/aarch64 linux-musl, x86_64/aarch64 macos, x86_64 windows) and attaches them to the same GitHub release as Cube, via svenstaro/upload-release-action (matching cubestore/native jobs). - The job overrides the crate version from the pushed tag, and Cargo.toml is pinned to the current Cube version (1.7.2), so `cube --version` always matches the Cube release. - Remove the standalone cube-cli-release.yml; keep cube-cli.yml for PR CI. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * ci(cube-cli): build+publish CLI from main Release workflow; version follows Cube Add a cube-cli matrix job to publish.yml (v*.*.* tags) that builds a single static binary per platform and attaches it to the same GitHub release as Cube via svenstaro/upload-release-action. The job sets the crate version from the pushed tag; Cargo.toml is pinned to the current Cube version (1.7.2) so `cube --version` matches the Cube release. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * fix(cube-cli): address PR review — token file race, windows browser, refresh errors - config: write config.toml via a 0600 temp file + atomic rename, closing the world-readable window between fs::write and set_permissions (and avoiding a truncated token file on a crash mid-write). - oauth: open the browser on Windows with rundll32 FileProtocolHandler instead of `cmd /C start`, which treats `&` in the verification URL as a command separator. - client: log the underlying refresh error instead of silently reporting the original 401 as 'session expired' for non-expiry failures. - output: drop the always-true guard in items(). - cube-cli CI: add an explicit `permissions: contents: read` block (CodeQL). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk * style(cube-cli): rustfmt the config atomic-write change * ci(cube-cli): build and test across release targets on PRs Split the single-host lint-and-test job into a lint job (fmt + clippy) and a cross-platform build-and-test matrix that mirrors the release targets in publish.yml (linux musl x86_64/aarch64, macOS Intel/ARM, windows-msvc). Each PR now compiles and runs the CLI test suite on every target it releases for, so a broken cross-platform build is caught in review instead of at tag time. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VdcyqFwUF9BbWLeX4uERnk --------- Co-authored-by: Claude --- .github/workflows/cube-cli.yml | 78 + .github/workflows/publish.yml | 59 + rust/cube-cli/.gitignore | 1 + rust/cube-cli/Cargo.lock | 1814 +++++++++++++++++++ rust/cube-cli/Cargo.toml | 36 + rust/cube-cli/README.md | 136 ++ rust/cube-cli/src/client.rs | 220 +++ rust/cube-cli/src/commands/agents.rs | 85 + rust/cube-cli/src/commands/ai_engineer.rs | 45 + rust/cube-cli/src/commands/api.rs | 34 + rust/cube-cli/src/commands/app.rs | 28 + rust/cube-cli/src/commands/attributes.rs | 184 ++ rust/cube-cli/src/commands/completion.rs | 15 + rust/cube-cli/src/commands/context.rs | 51 + rust/cube-cli/src/commands/data_model.rs | 437 +++++ rust/cube-cli/src/commands/deployments.rs | 200 ++ rust/cube-cli/src/commands/embed.rs | 130 ++ rust/cube-cli/src/commands/environments.rs | 156 ++ rust/cube-cli/src/commands/folders.rs | 153 ++ rust/cube-cli/src/commands/groups.rs | 57 + rust/cube-cli/src/commands/integrations.rs | 160 ++ rust/cube-cli/src/commands/login.rs | 91 + rust/cube-cli/src/commands/logout.rs | 27 + rust/cube-cli/src/commands/meta.rs | 35 + rust/cube-cli/src/commands/mod.rs | 29 + rust/cube-cli/src/commands/notifications.rs | 280 +++ rust/cube-cli/src/commands/oidc.rs | 91 + rust/cube-cli/src/commands/policies.rs | 102 ++ rust/cube-cli/src/commands/regions.rs | 23 + rust/cube-cli/src/commands/reports.rs | 291 +++ rust/cube-cli/src/commands/scim.rs | 154 ++ rust/cube-cli/src/commands/tenant.rs | 40 + rust/cube-cli/src/commands/users.rs | 90 + rust/cube-cli/src/commands/variables.rs | 62 + rust/cube-cli/src/commands/whoami.rs | 24 + rust/cube-cli/src/commands/workbooks.rs | 288 +++ rust/cube-cli/src/commands/workspace.rs | 136 ++ rust/cube-cli/src/config.rs | 91 + rust/cube-cli/src/main.rs | 237 +++ rust/cube-cli/src/oauth.rs | 221 +++ rust/cube-cli/src/output.rs | 97 + rust/cube-cli/src/util.rs | 92 + 42 files changed, 6580 insertions(+) create mode 100644 .github/workflows/cube-cli.yml create mode 100644 rust/cube-cli/.gitignore create mode 100644 rust/cube-cli/Cargo.lock create mode 100644 rust/cube-cli/Cargo.toml create mode 100644 rust/cube-cli/README.md create mode 100644 rust/cube-cli/src/client.rs create mode 100644 rust/cube-cli/src/commands/agents.rs create mode 100644 rust/cube-cli/src/commands/ai_engineer.rs create mode 100644 rust/cube-cli/src/commands/api.rs create mode 100644 rust/cube-cli/src/commands/app.rs create mode 100644 rust/cube-cli/src/commands/attributes.rs create mode 100644 rust/cube-cli/src/commands/completion.rs create mode 100644 rust/cube-cli/src/commands/context.rs create mode 100644 rust/cube-cli/src/commands/data_model.rs create mode 100644 rust/cube-cli/src/commands/deployments.rs create mode 100644 rust/cube-cli/src/commands/embed.rs create mode 100644 rust/cube-cli/src/commands/environments.rs create mode 100644 rust/cube-cli/src/commands/folders.rs create mode 100644 rust/cube-cli/src/commands/groups.rs create mode 100644 rust/cube-cli/src/commands/integrations.rs create mode 100644 rust/cube-cli/src/commands/login.rs create mode 100644 rust/cube-cli/src/commands/logout.rs create mode 100644 rust/cube-cli/src/commands/meta.rs create mode 100644 rust/cube-cli/src/commands/mod.rs create mode 100644 rust/cube-cli/src/commands/notifications.rs create mode 100644 rust/cube-cli/src/commands/oidc.rs create mode 100644 rust/cube-cli/src/commands/policies.rs create mode 100644 rust/cube-cli/src/commands/regions.rs create mode 100644 rust/cube-cli/src/commands/reports.rs create mode 100644 rust/cube-cli/src/commands/scim.rs create mode 100644 rust/cube-cli/src/commands/tenant.rs create mode 100644 rust/cube-cli/src/commands/users.rs create mode 100644 rust/cube-cli/src/commands/variables.rs create mode 100644 rust/cube-cli/src/commands/whoami.rs create mode 100644 rust/cube-cli/src/commands/workbooks.rs create mode 100644 rust/cube-cli/src/commands/workspace.rs create mode 100644 rust/cube-cli/src/config.rs create mode 100644 rust/cube-cli/src/main.rs create mode 100644 rust/cube-cli/src/oauth.rs create mode 100644 rust/cube-cli/src/output.rs create mode 100644 rust/cube-cli/src/util.rs 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")); + } +}