diff --git a/.github/workflows/cube-cli.yml b/.github/workflows/cube-cli.yml index fe23a02fd80a1..d1e0e6a286874 100644 --- a/.github/workflows/cube-cli.yml +++ b/.github/workflows/cube-cli.yml @@ -5,10 +5,14 @@ on: branches: [master] paths: - 'rust/cube-cli/**' + - 'install-cli.sh' + - 'install-cli.ps1' - '.github/workflows/cube-cli.yml' pull_request: paths: - 'rust/cube-cli/**' + - 'install-cli.sh' + - 'install-cli.ps1' - '.github/workflows/cube-cli.yml' concurrency: diff --git a/install-cli.ps1 b/install-cli.ps1 new file mode 100644 index 0000000000000..b14a9090acfc1 --- /dev/null +++ b/install-cli.ps1 @@ -0,0 +1,44 @@ +# Cube CLI installer for Windows (x86_64). +# +# irm https://raw.githubusercontent.com/cube-js/cube/master/install-cli.ps1 | iex +# +# Environment overrides: +# CUBE_INSTALL_DIR install directory (default: %LOCALAPPDATA%\cube\bin) +# CUBE_VERSION release tag to install, e.g. v1.7.5 (default: latest) +$ErrorActionPreference = "Stop" + +$Repo = "cube-js/cube" +$Target = "x86_64-pc-windows-msvc" + +$Version = if ($env:CUBE_VERSION) { $env:CUBE_VERSION } else { "latest" } +$Url = if ($Version -eq "latest") { + "https://github.com/$Repo/releases/latest/download/cube-$Target.tar.gz" +} else { + "https://github.com/$Repo/releases/download/$Version/cube-$Target.tar.gz" +} + +$Dir = if ($env:CUBE_INSTALL_DIR) { $env:CUBE_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA "cube\bin" } +New-Item -ItemType Directory -Force -Path $Dir | Out-Null + +$Tmp = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString()) +New-Item -ItemType Directory -Force -Path $Tmp | Out-Null +try { + Write-Host "Downloading cube ($Target) from $Url…" + $Archive = Join-Path $Tmp "cube.tar.gz" + Invoke-WebRequest -Uri $Url -OutFile $Archive -UseBasicParsing + + # tar ships with Windows 10 1803+. + tar -xzf $Archive -C $Tmp + Copy-Item -Force (Join-Path $Tmp "cube.exe") (Join-Path $Dir "cube.exe") + + $Exe = Join-Path $Dir "cube.exe" + Write-Host "Installed $(& $Exe --version) to $Exe" + + $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") + if (($UserPath -split ";") -notcontains $Dir) { + [Environment]::SetEnvironmentVariable("Path", "$Dir;$UserPath", "User") + Write-Host "Added $Dir to your user PATH (restart your terminal to pick it up)." + } +} finally { + Remove-Item -Recurse -Force $Tmp -ErrorAction SilentlyContinue +} diff --git a/install-cli.sh b/install-cli.sh new file mode 100644 index 0000000000000..99c1bf60c9af3 --- /dev/null +++ b/install-cli.sh @@ -0,0 +1,73 @@ +#!/bin/sh +# Cube CLI installer for Linux and macOS. +# +# curl -fsSL https://raw.githubusercontent.com/cube-js/cube/master/install-cli.sh | sh +# +# Environment overrides: +# CUBE_INSTALL_DIR install directory (default: /usr/local/bin if +# writable, else ~/.local/bin) +# CUBE_VERSION release tag to install, e.g. v1.7.5 (default: latest) +set -eu + +REPO="cube-js/cube" + +main() { + os=$(uname -s) + arch=$(uname -m) + case "$os" in + Linux) os_part="unknown-linux-musl" ;; + Darwin) os_part="apple-darwin" ;; + *) err "unsupported OS: $os (use install.ps1 on Windows)" ;; + esac + case "$arch" in + x86_64|amd64) arch_part="x86_64" ;; + arm64|aarch64) arch_part="aarch64" ;; + *) err "unsupported architecture: $arch" ;; + esac + target="${arch_part}-${os_part}" + + if [ "${CUBE_VERSION:-latest}" = "latest" ]; then + url="https://github.com/${REPO}/releases/latest/download/cube-${target}.tar.gz" + else + url="https://github.com/${REPO}/releases/download/${CUBE_VERSION}/cube-${target}.tar.gz" + fi + + dir="${CUBE_INSTALL_DIR:-}" + if [ -z "$dir" ]; then + if [ -w /usr/local/bin ]; then + dir=/usr/local/bin + else + dir="$HOME/.local/bin" + fi + fi + mkdir -p "$dir" + + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + + echo "Downloading cube (${target}) from ${url}…" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$tmp/cube.tar.gz" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$tmp/cube.tar.gz" "$url" + else + err "neither curl nor wget is available" + fi + + tar -xzf "$tmp/cube.tar.gz" -C "$tmp" + install -m 755 "$tmp/cube" "$dir/cube" + + echo "Installed $("$dir/cube" --version) to $dir/cube" + case ":$PATH:" in + *":$dir:"*) ;; + *) echo "NOTE: $dir is not on your PATH — add it, e.g.:" + echo " export PATH=\"$dir:\$PATH\"" ;; + esac +} + +err() { + echo "error: $1" >&2 + exit 1 +} + +main "$@" diff --git a/rust/cube-cli/Cargo.lock b/rust/cube-cli/Cargo.lock index ddb8a3e9cc3ad..68735fccb01d3 100644 --- a/rust/cube-cli/Cargo.lock +++ b/rust/cube-cli/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "anstream" version = "1.0.0" @@ -82,6 +88,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -129,7 +144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core", ] @@ -188,6 +203,31 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -197,6 +237,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.25.0" @@ -222,6 +271,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cube-cli" version = "1.7.2" @@ -230,15 +289,30 @@ dependencies = [ "clap", "clap_complete", "etcetera", + "flate2", "inquire", + "libc", "owo-colors", "reqwest", "serde", "serde_json", + "sha1", + "sha2", + "tar", "tokio", "toml", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -283,12 +357,32 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -349,6 +443,16 @@ dependencies = [ "byteorder", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -466,10 +570,10 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -660,6 +764,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -693,6 +803,32 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "0.8.11" @@ -737,6 +873,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "owo-colors" version = "4.3.0" @@ -911,6 +1053,7 @@ dependencies = [ "base64", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -919,10 +1062,12 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -937,7 +1082,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", ] [[package]] @@ -960,6 +1104,19 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.42" @@ -974,6 +1131,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.0" @@ -1007,12 +1176,44 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.228" @@ -1077,6 +1278,28 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1114,6 +1337,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1185,6 +1414,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1386,6 +1626,18 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1434,6 +1686,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -1524,15 +1782,6 @@ dependencies = [ "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" @@ -1724,6 +1973,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/rust/cube-cli/Cargo.toml b/rust/cube-cli/Cargo.toml index f0575b6a52253..9142ff06e353b 100644 --- a/rust/cube-cli/Cargo.toml +++ b/rust/cube-cli/Cargo.toml @@ -23,13 +23,23 @@ 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"] } +# Native root store (honors SSL_CERT_FILE and OS-installed CAs, e.g. behind +# corporate TLS-inspecting proxies) with rustls — still no OpenSSL, still +# fully static on musl. +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "json", "multipart"] } +sha1 = "0.10" +sha2 = "0.10" +flate2 = "1" +tar = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } toml = "0.8" etcetera = "0.8" +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [profile.release] lto = "thin" strip = true diff --git a/rust/cube-cli/README.md b/rust/cube-cli/README.md index 8c601c48f4aa7..ed45f6e28f5d7 100644 --- a/rust/cube-cli/README.md +++ b/rust/cube-cli/README.md @@ -6,7 +6,46 @@ public REST API, written in Rust. Structured after the group under `src/commands/`, a shared HTTP client, a config module, and plain clap-derive dispatch in `main.rs`. -## Install / build +## Install + +Linux / macOS: + +```bash +curl -fsSL https://raw.githubusercontent.com/cube-js/cube/master/install-cli.sh | sh +``` + +Windows (PowerShell): + +```powershell +irm https://raw.githubusercontent.com/cube-js/cube/master/install-cli.ps1 | iex +``` + +Both scripts download the latest release binary for your platform and put it +on your `PATH` (`CUBE_INSTALL_DIR` overrides the location; `CUBE_VERSION` +pins a specific release tag). + +### Updates + +Every run checks GitHub for a newer release in the background and prints a +notice when one is available (set `CUBE_NO_UPDATE_CHECK=1` to disable, e.g. +in CI; the notice only goes to interactive terminals, on stderr). Update +in place any time with: + +```bash +cube update # download the latest release and replace this binary +cube update --check # just report what's available +``` + +### Telemetry + +The CLI sends anonymous usage events (command group, success/failure, +version, platform) to `track.cube.dev` — the same pipeline and wire format +as the legacy `cubejs` CLI. No personal data is collected; the anonymous id +is a SHA-256 hash of the OS machine id. Telemetry is disabled automatically +in CI (the `CI` env var), or explicitly with `CUBE_NO_TELEMETRY=1` (or the +legacy `CUBEJS_TELEMETRY=false`). + +### Build from source ```bash cd rust/cube-cli @@ -87,8 +126,10 @@ 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 | +| `deployments` | list, get, create (`--bootstrap` scaffolds + builds a serving deployment), update, delete, token, advance-step, reset-step | | `regions` | list available deployment regions | +| `logs` | tail deployment pod logs (`--pod`, `-c/--container`; defaults to the Cube API container) | +| `github` (`gh`) | status, installations, repos, branches, connect (import a repo into a deployment + first build) | | `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) | @@ -106,7 +147,6 @@ Every endpoint of the Console Server public API is covered: | `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 | diff --git a/rust/cube-cli/src/client.rs b/rust/cube-cli/src/client.rs index 18590e715fdad..a5ed881377a1f 100644 --- a/rust/cube-cli/src/client.rs +++ b/rust/cube-cli/src/client.rs @@ -74,6 +74,20 @@ impl Client { self.token.lock().unwrap().clone() } + /// Authorization header value for a credential. JWTs (three dot-separated + /// segments — OAuth access tokens, legacy deploy JWTs) use the `Bearer` + /// scheme; opaque credentials are Cube Cloud API keys and use `Api-Key`. + /// `CUBE_AUTH_SCHEME=bearer|api-key` overrides the heuristic. + fn authorization(token: &str) -> String { + let scheme = match std::env::var("CUBE_AUTH_SCHEME").as_deref() { + Ok("bearer") | Ok("Bearer") => "Bearer", + Ok("api-key") | Ok("Api-Key") => "Api-Key", + _ if token.split('.').count() == 3 => "Bearer", + _ => "Api-Key", + }; + format!("{scheme} {token}") + } + /// Send a single attempt, returning the status and body text. async fn send_once( &self, @@ -83,10 +97,10 @@ impl Client { 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()); + let mut req = self.http.request(method.clone(), &url).header( + reqwest::header::AUTHORIZATION, + Self::authorization(&self.token()), + ); if !query.is_empty() { req = req.query(query); } @@ -128,6 +142,50 @@ impl Client { text = t; } + self.finish_response(&method, path, status, text) + } + + /// POST a multipart form. `build_form` is called per attempt because a + /// form can only be sent once (the 401-refresh retry needs a fresh one). + pub async fn post_multipart(&self, path: &str, build_form: F) -> Result + where + F: Fn() -> reqwest::multipart::Form, + { + let url = format!("{}{}", self.base_url, path); + let send = |form: reqwest::multipart::Form, token: String| { + let http = &self.http; + let url = &url; + async move { + let res = http + .post(url) + .header(reqwest::header::AUTHORIZATION, Self::authorization(&token)) + .multipart(form) + .send() + .await + .map_err(|e| anyhow!("request to {url} failed: {e}"))?; + let status = res.status(); + let text = res.text().await.unwrap_or_default(); + Ok::<_, anyhow::Error>((status, text)) + } + }; + + let (mut status, mut text) = send(build_form(), self.token()).await?; + if status == StatusCode::UNAUTHORIZED && self.try_refresh().await? { + let (s, t) = send(build_form(), self.token()).await?; + status = s; + text = t; + } + self.finish_response(&Method::POST, path, status, text) + } + + /// Shared tail of every request: error mapping + JSON/HTML handling. + fn finish_response( + &self, + method: &Method, + path: &str, + status: StatusCode, + text: String, + ) -> Result { if !status.is_success() { let detail = serde_json::from_str::(&text) .ok() @@ -150,6 +208,19 @@ impl Client { if text.trim().is_empty() { return Ok(Value::Null); } + // Cube Cloud serves the web app (200 + HTML) for unknown routes. + // Surface that as "endpoint not available" instead of returning the + // HTML as a JSON string, which downstream renders as an empty table. + let trimmed = text.trim_start(); + let looks_like_html = + trimmed.len() >= 2 && trimmed.starts_with('<') && !trimmed.starts_with(", - /// 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_keys.rs b/rust/cube-cli/src/commands/api_keys.rs new file mode 100644 index 0000000000000..d98fa18628f42 --- /dev/null +++ b/rust/cube-cli/src/commands/api_keys.rs @@ -0,0 +1,68 @@ +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 API keys + #[command(alias = "ls")] + List, + /// Create an API key (the secret is only returned once) + Create { + /// Key name + #[arg(long)] + name: Option, + /// Extra request body fields as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: Option, + }, + /// Show a single API key + Get { + /// API key id + id: String, + }, + /// Revoke (delete) an API key + #[command(alias = "rm", alias = "revoke")] + Delete { + /// API key id + id: String, + }, +} + +const BASE: &str = "/api/v1/api-keys"; + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::List => { + let res = api.get(BASE, &Vec::new()).await?; + output::print_list(ctx.json, &res, &[("ID", "id"), ("NAME", "name")]); + } + Cmd::Create { name, data } => { + let mut body = util::parse_data(data.as_deref())?; + util::set(&mut body, "name", &name); + let res = api.post(BASE, Some(&util::body(body))).await?; + output::print_json(&res); + } + Cmd::Get { id } => { + let res = api.get(&format!("{BASE}/{id}"), &Vec::new()).await?; + output::print_json(&res); + } + Cmd::Delete { id } => { + let res = api.delete(&format!("{BASE}/{id}"), None).await?; + if ctx.json { + output::print_json(&res); + } else { + output::success(&format!("Deleted API key {id}")); + } + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/data_model.rs b/rust/cube-cli/src/commands/data_model.rs index f6d4d8481c951..297d137534d81 100644 --- a/rust/cube-cli/src/commands/data_model.rs +++ b/rust/cube-cli/src/commands/data_model.rs @@ -105,13 +105,24 @@ enum Cmd { /// Deployment id deployment: i64, }, - /// Commit and push the active branch + /// Commit and push a branch Commit { /// Deployment id deployment: i64, /// Commit message #[arg(long, short = 'm')] message: Option, + /// Branch to commit (defaults to the active dev-mode branch) + #[arg(long)] + branch: Option, + }, + /// List server-side content hashes of data model files + FileHashes { + /// Deployment id + deployment: i64, + /// Branch name (defaults to the deployment default branch) + #[arg(long)] + branch: Option, }, /// Sync a branch from its remote and rebuild if it moved Pull { @@ -367,9 +378,11 @@ pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { Cmd::Commit { deployment, message, + branch, } => { let mut body = serde_json::Map::new(); util::set(&mut body, "message", &message); + util::set(&mut body, "branchName", &branch); let res = api .post( &format!("/build/api/v1/deployments/{deployment}/commit"), @@ -382,6 +395,17 @@ pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { output::success("Committed and pushed"); } } + Cmd::FileHashes { deployment, branch } => { + let mut query = Vec::new(); + util::push(&mut query, "branchName", &branch); + let res = api + .get( + &format!("/build/api/v1/deployments/{deployment}/data-model/file-hashes"), + &query, + ) + .await?; + output::print_json(&res); + } Cmd::Pull { deployment, branch } => { let body = branch.as_ref().map(|b| json!({ "branchName": b })); let res = api diff --git a/rust/cube-cli/src/commands/deploy.rs b/rust/cube-cli/src/commands/deploy.rs new file mode 100644 index 0000000000000..2944a778aee17 --- /dev/null +++ b/rust/cube-cli/src/commands/deploy.rs @@ -0,0 +1,173 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context as _, Result}; +use serde_json::Value; +use sha1::{Digest, Sha1}; + +use crate::{output, util, Ctx}; + +/// Upload a local project directory to a deployment. +/// +/// Mirrors the legacy `cubejs deploy` flow on the public API: diff local +/// content hashes against the server, upload only changed files inside a +/// transaction, then finish with a manifest that prunes deleted files and +/// triggers a single build. +#[derive(clap::Args)] +pub struct Args { + /// Deployment id + deployment: i64, + /// Project directory to upload + #[arg(long, default_value = ".")] + directory: PathBuf, + /// Branch to deploy to (defaults to the active dev-mode branch, else the + /// deploy branch) + #[arg(long)] + branch: Option, + /// Keep remote files that don't exist locally (default prunes them) + #[arg(long)] + keep_missing: bool, + /// Commit message for the deploy commit + #[arg(long, short = 'm')] + message: Option, +} + +/// Collect deployable files as (posix relative path, absolute path). +/// Same filter as the legacy CLI: dotfiles are skipped (except .gitignore), +/// as are node_modules and dashboard-app directories. +fn collect_files(root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<()> { + for entry in + std::fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? + { + let entry = entry?; + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + let hidden = name.starts_with('.') && name != ".gitignore"; + if hidden || name == "node_modules" || name == "dashboard-app" { + continue; + } + if path.is_dir() { + collect_files(root, &path, out)?; + } else { + let rel = path + .strip_prefix(root) + .unwrap_or(&path) + .components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + out.push((rel, path)); + } + } + Ok(()) +} + +fn sha1_hex(data: &[u8]) -> String { + let mut hasher = Sha1::new(); + hasher.update(data); + format!("{:x}", hasher.finalize()) +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + let deployment = args.deployment; + let base = format!("/build/api/v1/deployments/{deployment}/data-model"); + + if !args.directory.is_dir() { + bail!("{} is not a directory", args.directory.display()); + } + let mut files = Vec::new(); + collect_files(&args.directory, &args.directory, &mut files)?; + if files.is_empty() { + bail!("no deployable files found in {}", args.directory.display()); + } + files.sort(); + + // Hash local files and diff against the server's content hashes. + let mut query = Vec::new(); + util::push(&mut query, "branchName", &args.branch); + let upstream = api.get(&format!("{base}/file-hashes"), &query).await?; + let upstream = upstream.get("files").cloned().unwrap_or(upstream); + + let mut manifest = serde_json::Map::new(); + let mut to_upload = Vec::new(); + for (rel, path) in &files { + let data = + std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; + let hash = sha1_hex(&data); + let unchanged = upstream + .get(rel) + .and_then(|f| f.get("hash")) + .and_then(Value::as_str) + == Some(hash.as_str()); + if !unchanged { + to_upload.push((rel.clone(), data)); + } + manifest.insert(rel.clone(), serde_json::json!({ "hash": hash })); + } + + // Same event name the legacy `cubejs deploy` emitted. + crate::telemetry::event("Cube Cloud CLI Deploy", serde_json::Map::new()); + + // Open the upload transaction. + let mut body = serde_json::Map::new(); + util::set(&mut body, "branchName", &args.branch); + let start = api + .post(&format!("{base}/upload/start"), Some(&util::body(body))) + .await?; + let transaction = start.get("transaction").cloned().unwrap_or(start.clone()); + let transaction_str = serde_json::to_string(&transaction)?; + + eprintln!( + "Uploading {} changed file(s) of {} to deployment {deployment}…", + to_upload.len(), + files.len() + ); + for (rel, data) in &to_upload { + eprintln!(" {rel}"); + let basename = rel.rsplit('/').next().unwrap_or(rel).to_string(); + let transaction_str = transaction_str.clone(); + let rel_owned = rel.clone(); + let data = data.clone(); + api.post_multipart(&format!("{base}/upload/file"), move || { + reqwest::multipart::Form::new() + .text("transaction", transaction_str.clone()) + .text("fileName", rel_owned.clone()) + .part( + "file", + reqwest::multipart::Part::bytes(data.clone()) + .file_name(basename.clone()) + .mime_str("application/octet-stream") + .expect("static mime type is valid"), + ) + }) + .await?; + } + + // Commit the manifest: prunes files absent from it (unless --keep-missing) + // and triggers a single build. + let mut body = serde_json::Map::new(); + body.insert("transaction".into(), transaction); + body.insert("files".into(), Value::Object(manifest)); + body.insert( + "autoRemoveFiles".into(), + serde_json::json!(!args.keep_missing), + ); + util::set(&mut body, "commitMessage", &args.message); + util::set(&mut body, "branchName", &args.branch); + let res = api + .post(&format!("{base}/upload/finish"), Some(&util::body(body))) + .await?; + + crate::telemetry::event("Cube Cloud CLI Deploy Success", serde_json::Map::new()); + + if ctx.json { + output::print_json(&res); + } else { + output::success(&format!( + "Deployed {} file(s) ({} uploaded). Check progress with `cube deployments build-status {deployment}`", + files.len(), + to_upload.len() + )); + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/deployments.rs b/rust/cube-cli/src/commands/deployments.rs index 1910811aa42de..c53c26855c593 100644 --- a/rust/cube-cli/src/commands/deployments.rs +++ b/rust/cube-cli/src/commands/deployments.rs @@ -86,6 +86,26 @@ enum Cmd { /// Deployment id deployment: i64, }, + /// Show the latest build status for a branch + BuildStatus { + /// Deployment id + deployment: i64, + /// Branch (defaults to the active dev-mode branch, else the deploy branch) + #[arg(long)] + branch: Option, + }, + /// Advance onboarding to a target creation step + AdvanceStep { + /// Deployment id + deployment: i64, + /// Target step: project, upload, schema, github, ssh, databases, ready, demo + step: String, + }, + /// Reset onboarding back to the first creation step (project) + ResetStep { + /// Deployment id + deployment: i64, + }, } pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { @@ -185,6 +205,35 @@ pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { output::success(&format!("Deleted deployment {deployment}")); } } + Cmd::BuildStatus { deployment, branch } => { + let mut query = Vec::new(); + util::push(&mut query, "branchName", &branch); + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/build-status"), + &query, + ) + .await?; + output::print_json(&res); + } + Cmd::AdvanceStep { deployment, step } => { + let res = api + .post( + &format!("/api/v1/deployments/{deployment}/creation-step/advance"), + Some(&serde_json::json!({ "creationStep": step })), + ) + .await?; + output::print_json(&res); + } + Cmd::ResetStep { deployment } => { + let res = api + .post( + &format!("/api/v1/deployments/{deployment}/creation-step/reset"), + None, + ) + .await?; + output::print_json(&res); + } Cmd::Token { deployment } => { let res = api .post(&format!("/api/v1/deployments/{deployment}/token"), None) diff --git a/rust/cube-cli/src/commands/github.rs b/rust/cube-cli/src/commands/github.rs new file mode 100644 index 0000000000000..865997cecf3ba --- /dev/null +++ b/rust/cube-cli/src/commands/github.rs @@ -0,0 +1,129 @@ +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 GitHub link/install state and browser URLs to complete setup + Status, + /// List the user's GitHub App installations + #[command(alias = "ls")] + Installations, + /// List repositories available to an installation + #[command(alias = "repositories")] + Repos { + /// GitHub App installation id (see `cube github installations`) + installation: i64, + }, + /// List branches of a repository + Branches { + /// Repository as owner/repo, e.g. cube-js/cube + repo: String, + /// GitHub App installation id the repository belongs to + #[arg(long)] + installation: i64, + }, + /// Connect a deployment to a GitHub repo: clones it into the + /// deployment's git storage and triggers the first build + Connect { + /// Deployment id + deployment: i64, + /// Repository name, e.g. cube (an owner/repo form is also + /// accepted; the owner is implied by the installation) + repo: String, + /// GitHub App installation id the repository belongs to + #[arg(long)] + installation: i64, + /// Branch to import (defaults to the repository's default branch) + #[arg(long)] + branch: Option, + /// Extra request body fields as JSON (inline, @file, or - for stdin) + #[arg(long, short = 'd')] + data: Option, + }, +} + +/// Split an `owner/repo` argument into its two parts. +fn split_repo(repo: &str) -> Result<(&str, &str)> { + repo.split_once('/') + .filter(|(owner, name)| !owner.is_empty() && !name.is_empty() && !name.contains('/')) + .ok_or_else(|| anyhow::anyhow!("repository must be in owner/repo form, got `{repo}`")) +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + match args.cmd { + Cmd::Status => { + let res = api.get("/api/v1/github/status", &Vec::new()).await?; + output::print_json(&res); + } + Cmd::Installations => { + let res = api.get("/api/v1/github/installations", &Vec::new()).await?; + output::print_list( + ctx.json, + &res, + &[("INSTALLATION", "installationId"), ("ACCOUNT", "login")], + ); + } + Cmd::Repos { installation } => { + let res = api + .get( + &format!("/api/v1/github/installations/{installation}/repositories"), + &Vec::new(), + ) + .await?; + output::print_list(ctx.json, &res, &[("NAME", "name"), ("URL", "htmlUrl")]); + } + Cmd::Branches { repo, installation } => { + let (owner, name) = split_repo(&repo)?; + let query = vec![("installationId".to_string(), installation.to_string())]; + let res = api + .get( + &format!("/api/v1/github/repositories/{owner}/{name}/branches"), + &query, + ) + .await?; + output::print_list( + ctx.json, + &res, + &[("BRANCH", "name"), ("DEFAULT", "isDefault")], + ); + } + Cmd::Connect { + deployment, + repo, + installation, + branch, + data, + } => { + // Accept either a plain repo name or owner/repo; the owner is + // implied by the installation, so only the name is sent. + let name = match repo.rsplit_once('/') { + Some((_, name)) => name, + None => repo.as_str(), + }; + let mut body = serde_json::Map::new(); + body.insert("installationId".into(), serde_json::json!(installation)); + body.insert("name".into(), serde_json::json!(name)); + util::set(&mut body, "branch", &branch); + for (k, v) in util::parse_data(data.as_deref())? { + body.insert(k, v); + } + let res = api + .post( + &format!("/build/api/v1/deployments/{deployment}/github/connect"), + Some(&util::body(body)), + ) + .await?; + output::print_json(&res); + } + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/login.rs b/rust/cube-cli/src/commands/login.rs index 4dfc7cf9e021e..dba8f98fbc8ed 100644 --- a/rust/cube-cli/src/commands/login.rs +++ b/rust/cube-cli/src/commands/login.rs @@ -48,6 +48,9 @@ pub async fn command(args: Args, ctx: &mut Ctx) -> Result<()> { ctx.config.default_context = Some(args.name.clone()); ctx.config.save()?; + // Same event name the legacy `cubejs auth` emitted. + crate::telemetry::event("Cube Cloud CLI Authenticate", serde_json::Map::new()); + output::success(&format!( "Logged in as {email} (context `{}`, saved to {})", args.name, diff --git a/rust/cube-cli/src/commands/logs.rs b/rust/cube-cli/src/commands/logs.rs new file mode 100644 index 0000000000000..91fecd87f09b6 --- /dev/null +++ b/rust/cube-cli/src/commands/logs.rs @@ -0,0 +1,57 @@ +use anyhow::Result; +use owo_colors::OwoColorize; + +use crate::{output, util, Ctx}; + +/// Tail a deployment's pod logs (API and worker pods; the same source as +/// the UI logs page). +#[derive(clap::Args)] +pub struct Args { + /// Deployment id + deployment: i64, + /// Only this pod (defaults to all tailable pods) + #[arg(long)] + pod: Option, + /// Container within the pod: cubejs-server (default), vector, api-proxy, … + #[arg(long, short = 'c')] + container: Option, + /// Log source: production (pods, default) or dev (dev-mode worker; + /// ignores --pod/--container) + #[arg(long, value_parser = ["production", "dev"])] + source: Option, +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let api = ctx.api()?; + let mut query = Vec::new(); + util::push(&mut query, "pod", &args.pod); + util::push(&mut query, "container", &args.container); + util::push(&mut query, "source", &args.source); + let res = api + .get( + &format!("/api/v1/deployments/{}/logs", args.deployment), + &query, + ) + .await?; + if ctx.json { + output::print_json(&res); + return Ok(()); + } + let items = output::items(&res); + if items.is_empty() { + eprintln!("{}", "No log lines".dimmed()); + return Ok(()); + } + for item in items { + let date = output::field(&item, "date"); + let pod = output::field(&item, "pod"); + let message = output::field(&item, "message"); + println!( + "{} {} {}", + date.dimmed(), + format!("[{pod}]").cyan(), + message.trim_end() + ); + } + Ok(()) +} diff --git a/rust/cube-cli/src/commands/mod.rs b/rust/cube-cli/src/commands/mod.rs index f989fd7f5e868..96e17a55de8bb 100644 --- a/rust/cube-cli/src/commands/mod.rs +++ b/rust/cube-cli/src/commands/mod.rs @@ -1,19 +1,22 @@ pub mod agents; -pub mod ai_engineer; pub mod api; +pub mod api_keys; pub mod app; pub mod attributes; pub mod completion; pub mod context; pub mod data_model; +pub mod deploy; pub mod deployments; pub mod embed; pub mod environments; pub mod folders; +pub mod github; pub mod groups; pub mod integrations; pub mod login; pub mod logout; +pub mod logs; pub mod meta; pub mod notifications; pub mod oidc; @@ -22,6 +25,7 @@ pub mod regions; pub mod reports; pub mod scim; pub mod tenant; +pub mod update; pub mod users; pub mod variables; pub mod whoami; diff --git a/rust/cube-cli/src/commands/update.rs b/rust/cube-cli/src/commands/update.rs new file mode 100644 index 0000000000000..f2a44f6d3a190 --- /dev/null +++ b/rust/cube-cli/src/commands/update.rs @@ -0,0 +1,137 @@ +use std::path::Path; + +use anyhow::{bail, Context as _, Result}; +use owo_colors::OwoColorize; + +use crate::update::{self, CURRENT_VERSION}; +use crate::{output, Ctx}; + +/// Update the CLI to the latest release. +#[derive(clap::Args)] +pub struct Args { + /// Only check for a newer release, don't install it + #[arg(long)] + check: bool, +} + +pub async fn command(args: Args, _ctx: &Ctx) -> Result<()> { + let http = reqwest::Client::builder() + .user_agent(concat!("cube-cli/", env!("CARGO_PKG_VERSION"))) + .build()?; + + let release = update::latest_release(&http).await?; + let latest = release.version(); + + if !is_newer(latest, CURRENT_VERSION) { + output::success(&format!( + "cube {CURRENT_VERSION} is up to date (latest release: {latest})" + )); + return Ok(()); + } + println!("Current version: {CURRENT_VERSION}"); + println!("Latest release: {}", latest.bold().green()); + if args.check { + println!("Run {} to install it.", "cube update".bold().cyan()); + return Ok(()); + } + + let Some(asset) = release.asset_for_this_platform() else { + bail!( + "the latest release has no binary for this platform ({}-{}); \ + it may still be building — try again in a few minutes", + std::env::consts::OS, + std::env::consts::ARCH + ); + }; + + eprintln!("Downloading {}…", asset.name); + let bytes = http + .get(&asset.browser_download_url) + .send() + .await? + .error_for_status() + .context("download failed")? + .bytes() + .await?; + + let new_binary = extract_binary(&bytes)?; + replace_current_exe(&new_binary)?; + + output::success(&format!("Updated cube {CURRENT_VERSION} → {latest}")); + Ok(()) +} + +fn is_newer(candidate: &str, current: &str) -> bool { + let parse = |v: &str| -> Vec { + v.split(['.', '-']) + .map_while(|s| s.parse::().ok()) + .collect() + }; + let (a, b) = (parse(candidate), parse(current)); + if a.is_empty() || b.is_empty() { + return candidate != current; + } + a > b +} + +/// Extract the `cube` binary from the release tar.gz archive. +fn extract_binary(archive: &[u8]) -> Result> { + let gz = flate2::read::GzDecoder::new(archive); + let mut tar = tar::Archive::new(gz); + for entry in tar.entries()? { + let mut entry = entry?; + let path = entry.path()?; + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if name == "cube" || name == "cube.exe" { + let mut data = Vec::new(); + std::io::Read::read_to_end(&mut entry, &mut data)?; + return Ok(data); + } + } + bail!("release archive does not contain a cube binary"); +} + +/// Swap the running executable for the new binary. +/// +/// The running file is first renamed aside (allowed on every platform, +/// including Windows, where an in-use file can't be overwritten but can be +/// renamed), then the new binary is written to the original path. The `.old` +/// leftover is cleaned up on the next run (see `cleanup_stale_binary`). +fn replace_current_exe(new_binary: &[u8]) -> Result<()> { + let exe = std::env::current_exe().context("could not locate the running executable")?; + let old = exe.with_extension("old"); + let _ = std::fs::remove_file(&old); + std::fs::rename(&exe, &old) + .with_context(|| format!("could not move {} aside", exe.display()))?; + + if let Err(e) = write_executable(&exe, new_binary) { + // Restore the original on failure so the install isn't left broken. + let _ = std::fs::rename(&old, &exe); + return Err(e); + } + // Best effort: on Windows the old (still running) image can't be + // deleted; the next invocation cleans it up. + let _ = std::fs::remove_file(&old); + Ok(()) +} + +fn write_executable(path: &Path, data: &[u8]) -> Result<()> { + std::fs::write(path, data).with_context(|| format!("could not write {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?; + } + Ok(()) +} + +/// Remove the `.old` binary a previous `cube update` left behind (Windows +/// can't delete the running image during the update itself). +pub fn cleanup_stale_binary() { + if let Ok(exe) = std::env::current_exe() { + let _ = std::fs::remove_file(exe.with_extension("old")); + } +} diff --git a/rust/cube-cli/src/main.rs b/rust/cube-cli/src/main.rs index 2bc53b22c3786..3cdd4d51f26af 100644 --- a/rust/cube-cli/src/main.rs +++ b/rust/cube-cli/src/main.rs @@ -3,6 +3,8 @@ mod commands; mod config; mod oauth; mod output; +mod telemetry; +mod update; mod util; use anyhow::{bail, Result}; @@ -119,6 +121,13 @@ enum Command { /// List available deployment regions #[command(alias = "region")] Regions(commands::regions::Args), + /// Upload a local project directory to a deployment and build it + Deploy(commands::deploy::Args), + /// Tail a deployment's pod logs + Logs(commands::logs::Args), + /// GitHub integration: link status, installations, repos, and connect + #[command(alias = "gh")] + Github(commands::github::Args), /// Manage a deployment's data model files #[command(name = "data-model", alias = "dm")] DataModel(commands::data_model::Args), @@ -166,12 +175,13 @@ enum Command { Integrations(commands::integrations::Args), /// Manage OIDC token configs Oidc(commands::oidc::Args), + /// Manage API keys for programmatic access + #[command(name = "api-keys", alias = "api-key")] + ApiKeys(commands::api_keys::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 @@ -181,6 +191,8 @@ enum Command { /// Make an authenticated raw API request (escape hatch) Api(commands::api::Args), + /// Update the CLI to the latest release + Update(commands::update::Args), /// Generate shell completions Completion(commands::completion::Args), } @@ -191,10 +203,90 @@ pub fn cli_command() -> clap::Command { Cli::command() } +impl Command { + /// Canonical command-group name, used for telemetry. + fn name(&self) -> &'static str { + use Command::*; + match self { + Login(_) => "login", + Logout(_) => "logout", + Whoami(_) => "whoami", + Context(_) => "context", + Deployments(_) => "deployments", + Regions(_) => "regions", + Deploy(_) => "deploy", + Logs(_) => "logs", + Github(_) => "github", + DataModel(_) => "data-model", + Environments(_) => "environments", + Variables(_) => "variables", + Folders(_) => "folders", + Workbooks(_) => "workbooks", + Reports(_) => "reports", + Workspace(_) => "workspace", + Notifications(_) => "notifications", + Users(_) => "users", + Groups(_) => "groups", + Attributes(_) => "attributes", + Policies(_) => "policies", + Tenant(_) => "tenant", + Embed(_) => "embed", + Integrations(_) => "integrations", + Oidc(_) => "oidc", + ApiKeys(_) => "api-keys", + Agents(_) => "agents", + App(_) => "app", + Meta(_) => "meta", + Scim(_) => "scim", + Api(_) => "api", + Update(_) => "update", + Completion(_) => "completion", + } + } +} + #[tokio::main] async fn main() { + // Rust ignores SIGPIPE by default, turning `cube ... | head` into a + // broken-pipe panic. Restore the default disposition so the process + // exits quietly when the reader goes away, like other CLI tools. + #[cfg(unix)] + unsafe { + libc::signal(libc::SIGPIPE, libc::SIG_DFL); + } let cli = Cli::parse(); - if let Err(err) = run(cli).await { + commands::update::cleanup_stale_binary(); + + // Check for a newer release concurrently with the command; the notice + // (if any) prints after the command output. Skipped for `update` itself + // and `completion` (whose output is eval'd by shells). Completion also + // skips telemetry — it runs on shell startup. + let is_completion = matches!(cli.command, Command::Completion(_)); + let check = match &cli.command { + Command::Update(_) | Command::Completion(_) => None, + _ => Some(update::spawn_check()), + }; + let command_name = cli.command.name(); + + let result = run(cli).await; + + if !is_completion { + let mut props = serde_json::Map::new(); + props.insert("command".into(), serde_json::json!(command_name)); + props.insert("success".into(), serde_json::json!(result.is_ok())); + telemetry::event("Cube CLI Command", props); + if let Err(err) = &result { + let mut props = serde_json::Map::new(); + props.insert("command".into(), serde_json::json!(command_name)); + props.insert("error".into(), serde_json::json!(format!("{err:#}"))); + telemetry::event("Error", props); + } + telemetry::flush().await; + } + if let Some(check) = check { + update::print_notice(check).await; + } + if let Err(err) = result { eprintln!("error: {err:#}"); std::process::exit(1); } @@ -210,6 +302,9 @@ async fn run(cli: Cli) -> Result<()> { 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, + Deploy(args) => commands::deploy::command(args, &ctx).await, + Logs(args) => commands::logs::command(args, &ctx).await, + Github(args) => commands::github::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, @@ -226,12 +321,13 @@ async fn run(cli: Cli) -> Result<()> { Embed(args) => commands::embed::command(args, &ctx).await, Integrations(args) => commands::integrations::command(args, &ctx).await, Oidc(args) => commands::oidc::command(args, &ctx).await, + ApiKeys(args) => commands::api_keys::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, + Update(args) => commands::update::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 index 4a745e7a48ad9..812feafe642c0 100644 --- a/rust/cube-cli/src/oauth.rs +++ b/rust/cube-cli/src/oauth.rs @@ -133,7 +133,12 @@ pub async fn poll_for_token( if let Some(secret) = &cfg.client_secret { form.push(("client_secret", secret)); } - let res = http.post(&endpoint).form(&form).send().await?; + // Transient network failures (server redeploy, flaky connection) must + // not abort the login — keep polling until the device code expires. + let res = match http.post(&endpoint).form(&form).send().await { + Ok(res) => res, + Err(_) => continue, + }; let status = res.status(); let text = res.text().await.unwrap_or_default(); diff --git a/rust/cube-cli/src/telemetry.rs b/rust/cube-cli/src/telemetry.rs new file mode 100644 index 0000000000000..e0fef8fa6d66e --- /dev/null +++ b/rust/cube-cli/src/telemetry.rs @@ -0,0 +1,216 @@ +use std::sync::Mutex; +use std::time::Duration; + +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; + +/// Anonymous usage telemetry, wire-compatible with the legacy `cubejs` CLI +/// (`@cubejs-backend/shared` `track()`): events are POSTed as a JSON array to +/// track.cube.dev with the same field names, so they land in the existing +/// pipeline. No personal data is sent — the anonymous id is a SHA-256 hash +/// of the OS machine id. +/// +/// Disabled when `CUBE_NO_TELEMETRY`/`CUBEJS_TELEMETRY=false` is set, or in +/// CI (the `CI` env var, matching the legacy CLI). +const TRACK_URL: &str = "https://track.cube.dev/track"; + +fn track_url() -> String { + std::env::var("CUBE_TELEMETRY_URL").unwrap_or_else(|_| TRACK_URL.to_string()) +} + +static QUEUE: Mutex> = Mutex::new(Vec::new()); + +pub fn enabled() -> bool { + if std::env::var_os("CI").is_some() || std::env::var_os("CUBE_NO_TELEMETRY").is_some() { + return false; + } + !matches!( + std::env::var("CUBEJS_TELEMETRY").as_deref(), + Ok("false") | Ok("0") + ) +} + +/// Queue an event. `props` are merged into the payload. +pub fn event(name: &str, props: Map) { + if !enabled() { + return; + } + let mut payload = json!({ + "event": name, + "cliVersion": env!("CARGO_PKG_VERSION"), + "clientTimestamp": timestamp(), + "id": random_id(), + "platform": platform(), + "arch": arch(), + "anonymousId": anonymous_id(), + "sentFrom": "backend", + }); + if let Some(obj) = payload.as_object_mut() { + obj.extend(props); + } + QUEUE.lock().unwrap().push(payload); +} + +/// Flush queued events (best effort, bounded, silent on failure). +pub async fn flush() { + let events = std::mem::take(&mut *QUEUE.lock().unwrap()); + if events.is_empty() { + return; + } + let Ok(http) = reqwest::Client::builder() + .user_agent(concat!("cube-cli/", env!("CARGO_PKG_VERSION"))) + .timeout(Duration::from_secs(2)) + .build() + else { + return; + }; + let sent_at = timestamp(); + let body: Vec = events + .into_iter() + .map(|mut e| { + if let Some(obj) = e.as_object_mut() { + obj.insert("sentAt".into(), json!(sent_at)); + } + e + }) + .collect(); + let _ = http.post(track_url()).json(&body).send().await; +} + +/// ISO-8601 UTC timestamp without a chrono dependency. +fn timestamp() -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let (secs, millis) = (now.as_secs() as i64, now.subsec_millis()); + // Civil-from-days algorithm (Howard Hinnant), valid across leap years. + let days = secs.div_euclid(86_400); + let rem = secs.rem_euclid(86_400); + let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60); + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}.{millis:03}Z") +} + +/// Random-enough event id (not security sensitive). +fn random_id() -> String { + let mut hasher = Sha256::new(); + hasher.update(std::process::id().to_le_bytes()); + hasher.update( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .to_le_bytes(), + ); + hasher.update(QUEUE.lock().unwrap().len().to_le_bytes()); + format!("{:x}", hasher.finalize())[..32].to_string() +} + +/// Node-compatible platform name, so dashboards see one vocabulary. +fn platform() -> &'static str { + match std::env::consts::OS { + "macos" => "darwin", + "windows" => "win32", + other => other, + } +} + +/// Node-compatible arch name. +fn arch() -> &'static str { + match std::env::consts::ARCH { + "x86_64" => "x64", + "aarch64" => "arm64", + other => other, + } +} + +/// SHA-256 of the OS machine id — same sources as the legacy CLI's +/// machine-id module (dbus/systemd id on Linux, IOPlatformUUID on macOS, +/// MachineGuid on Windows), falling back to the hostname. +fn anonymous_id() -> String { + let raw = machine_id().unwrap_or_else(|| "unknown".to_string()); + let mut hasher = Sha256::new(); + hasher.update(raw.trim().as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn machine_id() -> Option { + #[cfg(target_os = "linux")] + { + for path in ["/var/lib/dbus/machine-id", "/etc/machine-id"] { + if let Ok(id) = std::fs::read_to_string(path) { + if !id.trim().is_empty() { + return Some(id); + } + } + } + return hostname(); + } + #[cfg(target_os = "macos")] + { + let out = std::process::Command::new("ioreg") + .args(["-rd1", "-c", "IOPlatformExpertDevice"]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout).to_string(); + return text + .lines() + .find(|l| l.contains("IOPlatformUUID")) + .and_then(|l| l.split('"').nth(3)) + .map(|s| s.to_lowercase()); + } + #[cfg(target_os = "windows")] + { + let out = std::process::Command::new("REG") + .args([ + "QUERY", + r"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography", + "/v", + "MachineGuid", + ]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout).to_string(); + return text.split_whitespace().last().map(|s| s.to_lowercase()); + } + #[allow(unreachable_code)] + hostname() +} + +fn hostname() -> Option { + let out = std::process::Command::new("hostname").output().ok()?; + let name = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (!name.is_empty()).then_some(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn timestamp_is_iso8601() { + let ts = timestamp(); + // e.g. 2026-07-20T12:34:56.789Z + assert_eq!(ts.len(), 24); + assert!(ts.ends_with('Z')); + assert_eq!(&ts[4..5], "-"); + assert_eq!(&ts[10..11], "T"); + assert!(ts.starts_with("20")); + } + + #[test] + fn ids_are_stable_length_and_distinct() { + assert_eq!(anonymous_id().len(), 64); + assert_eq!(anonymous_id(), anonymous_id()); + assert_eq!(random_id().len(), 32); + } +} diff --git a/rust/cube-cli/src/update.rs b/rust/cube-cli/src/update.rs new file mode 100644 index 0000000000000..b084fb00d5f04 --- /dev/null +++ b/rust/cube-cli/src/update.rs @@ -0,0 +1,148 @@ +use std::io::IsTerminal as _; +use std::time::Duration; + +use anyhow::{anyhow, bail, Result}; +use owo_colors::OwoColorize; +use serde::Deserialize; + +/// GitHub repository that hosts CLI release assets. Overridable for testing. +pub fn release_repo() -> String { + std::env::var("CUBE_UPDATE_REPO").unwrap_or_else(|_| "cube-js/cube".to_string()) +} + +/// GitHub API base URL. Overridable for tests and GitHub Enterprise mirrors. +fn release_api_base() -> String { + std::env::var("CUBE_UPDATE_API").unwrap_or_else(|_| "https://api.github.com".to_string()) +} + +pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// The release target triple this binary maps to. Linux always maps to the +/// musl asset — that's the only Linux artifact we ship, and it runs anywhere. +pub fn release_target() -> Option<&'static str> { + Some(match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => "x86_64-unknown-linux-musl", + ("linux", "aarch64") => "aarch64-unknown-linux-musl", + ("macos", "x86_64") => "x86_64-apple-darwin", + ("macos", "aarch64") => "aarch64-apple-darwin", + ("windows", "x86_64") => "x86_64-pc-windows-msvc", + _ => return None, + }) +} + +pub fn asset_name() -> Option { + release_target().map(|t| format!("cube-{t}.tar.gz")) +} + +#[derive(Debug, Deserialize)] +pub struct Release { + pub tag_name: String, + #[serde(default)] + pub assets: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Asset { + pub name: String, + pub browser_download_url: String, +} + +impl Release { + pub fn version(&self) -> &str { + self.tag_name.trim_start_matches('v') + } + + pub fn asset_for_this_platform(&self) -> Option<&Asset> { + let name = asset_name()?; + self.assets.iter().find(|a| a.name == name) + } +} + +/// Fetch the latest release metadata from the GitHub API. +pub async fn latest_release(http: &reqwest::Client) -> Result { + let url = format!( + "{}/repos/{}/releases/latest", + release_api_base(), + release_repo() + ); + let res = http + .get(&url) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .timeout(Duration::from_secs(10)) + .send() + .await?; + if !res.status().is_success() { + bail!("release lookup failed ({}) at {url}", res.status()); + } + res.json::() + .await + .map_err(|e| anyhow!("could not parse release metadata: {e}")) +} + +/// Order-compare two dotted versions numerically, segment by segment. +fn newer_than(candidate: &str, current: &str) -> bool { + let parse = |v: &str| -> Vec { + v.split(['.', '-']) + .map_while(|s| s.parse::().ok()) + .collect() + }; + let (a, b) = (parse(candidate), parse(current)); + if a.is_empty() || b.is_empty() { + return candidate != current; + } + a > b +} + +/// Spawn a background check for a newer release. Await the returned handle +/// after the command finishes; it resolves to a printable notice, or `None`. +/// Failures (offline, rate limit) resolve silently to `None`. +pub fn spawn_check() -> tokio::task::JoinHandle> { + tokio::spawn(async { + if std::env::var_os("CUBE_NO_UPDATE_CHECK").is_some() { + return None; + } + let http = reqwest::Client::builder() + .user_agent(concat!("cube-cli/", env!("CARGO_PKG_VERSION"))) + .build() + .ok()?; + let release = latest_release(&http).await.ok()?; + let latest = release.version().to_string(); + if newer_than(&latest, CURRENT_VERSION) { + Some(format!( + "\n{} {} → {}\nRun {} to install it.", + "A new release of cube is available:".yellow(), + CURRENT_VERSION.dimmed(), + latest.bold().green(), + "cube update".bold().cyan(), + )) + } else { + None + } + }) +} + +/// Print a pending update notice (best effort, never blocks long). +pub async fn print_notice(handle: tokio::task::JoinHandle>) { + if !std::io::stderr().is_terminal() { + return; + } + // The check runs concurrently with the command; give a short grace + // period in case the command finished faster than the API call. + if let Ok(Ok(Some(notice))) = tokio::time::timeout(Duration::from_millis(1500), handle).await { + eprintln!("{notice}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn newer_than_compares_numerically() { + assert!(newer_than("1.7.10", "1.7.2")); + assert!(newer_than("1.8.0", "1.7.9")); + assert!(!newer_than("1.7.2", "1.7.2")); + assert!(!newer_than("1.7.1", "1.7.2")); + assert!(newer_than("2.0.0", "1.99.99")); + } +}